diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d928669a8..cb7ce25f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: 'npm' @@ -77,7 +77,7 @@ jobs: timeout-minutes: 5 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Run isolated Python regression tests run: python3 -m unittest discover -s scripts/tests -p 'test_backup_restore.py' @@ -90,7 +90,7 @@ jobs: timeout-minutes: 5 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download the yt-dlp distribution used by the Docker image run: curl --fail --location --retry 3 https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp --output "$RUNNER_TEMP/yt-dlp" @@ -107,10 +107,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: 'npm' @@ -153,7 +153,7 @@ jobs: fi - name: Upload backend coverage artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: backend-coverage path: | @@ -187,9 +187,9 @@ jobs: --health-retries=20 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: 'npm' @@ -215,10 +215,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: 'npm' @@ -266,7 +266,7 @@ jobs: fi - name: Upload frontend coverage artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: frontend-coverage path: | @@ -279,10 +279,10 @@ jobs: timeout-minutes: 15 steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: 'npm' @@ -300,7 +300,7 @@ jobs: - name: Cache Storybook build id: cache-storybook - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: client/storybook-static key: ${{ runner.os }}-storybook-${{ hashFiles('client/src/**', 'client/.storybook/**', 'client/package-lock.json') }} @@ -322,10 +322,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: 'npm' @@ -358,9 +358,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: 'npm' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 769c9096e..f32c67ef8 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -24,10 +24,10 @@ jobs: if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: claude - uses: anthropics/claude-code-action@86eb26bf0139bdd75acd15ea5f00f45ee0a284c2 # v1 + uses: anthropics/claude-code-action@374f254f5efaf968abfe15d399fd90972e8138b9 # v1.0.251 continue-on-error: true with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} @@ -128,10 +128,10 @@ jobs: ) runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - id: claude - uses: anthropics/claude-code-action@86eb26bf0139bdd75acd15ea5f00f45ee0a284c2 # v1 + uses: anthropics/claude-code-action@374f254f5efaf968abfe15d399fd90972e8138b9 # v1.0.251 continue-on-error: true with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/coverage-badges.yml b/.github/workflows/coverage-badges.yml index e1a949436..cd8379a43 100644 --- a/.github/workflows/coverage-badges.yml +++ b/.github/workflows/coverage-badges.yml @@ -26,12 +26,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ secrets.ACTION_RUNNER_TOKEN }} - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: 'npm' diff --git a/.github/workflows/coverage-comment.yml b/.github/workflows/coverage-comment.yml index cf0b6f6a1..38c7b3596 100644 --- a/.github/workflows/coverage-comment.yml +++ b/.github/workflows/coverage-comment.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Download backend coverage - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: backend-coverage path: backend-coverage @@ -30,7 +30,7 @@ jobs: continue-on-error: true - name: Download frontend coverage - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: frontend-coverage path: frontend-coverage @@ -39,7 +39,7 @@ jobs: continue-on-error: true - name: Generate coverage report comment - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index f48e9e648..762b51b6c 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -17,9 +17,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20.x' cache: npm @@ -34,7 +34,7 @@ jobs: - name: Build documentation site run: npm run docs:build - name: Upload Pages artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: website/build @@ -50,7 +50,7 @@ jobs: id-token: write steps: - name: Configure Pages - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index 65f4dc8a2..e7521b133 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -39,7 +39,7 @@ jobs: echo "🏷️ RC tag: ${RC_TAG}" - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' @@ -69,19 +69,19 @@ jobs: echo "✅ MSW worker correctly excluded from production bundle" - name: Set up QEMU (multi-arch) - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push RC Docker images - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . push: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d60df6dc..36fce0929 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main fetch-depth: 0 @@ -41,7 +41,7 @@ jobs: - name: Calculate next version (dry run) id: calculate_version - uses: mathieudutour/github-tag-action@a22cf08638b34d5badda920f9daf6e72c477b07b # v6.2 + uses: mathieudutour/github-tag-action@af99e60ce8132224b8e6ebab5023449fe256ed46 # v7.0.0 with: github_token: ${{ secrets.ACTION_RUNNER_TOKEN }} tag_prefix: v @@ -56,7 +56,7 @@ jobs: echo "No conventional commit changes since last tag (${{ steps.calculate_version.outputs.previous_tag }}). Skipping release." - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: '20' @@ -83,14 +83,14 @@ jobs: cd .. - name: Set up QEMU (multi-arch) - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to Docker Hub if: ${{ !inputs.dry_run && steps.calculate_version.outputs.release_type != 'none' }} - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -125,7 +125,7 @@ jobs: - name: Build Docker image (verification) if: ${{ steps.calculate_version.outputs.release_type != 'none' }} - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . push: false @@ -141,7 +141,7 @@ jobs: - name: Create and push tag if: ${{ !inputs.dry_run && steps.calculate_version.outputs.release_type != 'none' }} id: tag_version - uses: mathieudutour/github-tag-action@a22cf08638b34d5badda920f9daf6e72c477b07b # v6.2 + uses: mathieudutour/github-tag-action@af99e60ce8132224b8e6ebab5023449fe256ed46 # v7.0.0 with: github_token: ${{ secrets.ACTION_RUNNER_TOKEN }} tag_prefix: v @@ -201,7 +201,7 @@ jobs: - name: Build and push Docker images if: ${{ !inputs.dry_run && steps.calculate_version.outputs.release_type != 'none' }} - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . push: true @@ -284,7 +284,7 @@ jobs: - name: Cleanup tag and release on failure if: ${{ failure() && !inputs.dry_run && steps.calculate_version.outputs.release_type != 'none' }} - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.ACTION_RUNNER_TOKEN }} script: | diff --git a/CLAUDE.md b/CLAUDE.md index c2299263c..da0199e80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,11 +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 - 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. +- `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 modal's File Details Status row; chip tooltips render it through `shared/RemovalDescription`, which sets a missing file's path apart in a monospace pill, using `describeMissing` so both share the wording) and `utils/videoStatus.tsx` (`deleted` status, neutral style, `isRemovedStatus`). `videoTrashModule` serves `GET /api/videos/removed-summary` (deleted, missing, legacy counts, the library check (reported as passing when it covers no present or missing row, so the card's warning and Rescan anyway match the Videos page banner), `missingActionsBlocked` (it covers missing rows, the only rows the trash actions touch), and `downloadsActive` from `archiveAccess.isDownloading()`, which the Empty trash dialog uses to disable archive removal before the user confirms) and `POST /api/videos/mark-deleted` (`{ videoIds }` or `{ all: true }`: only missing rows change, to `marked_deleted`; `all` is refused with 409 `library-unavailable` while the check covers missing rows, unless it also sends `ignoreLibraryCheck: true` and the reason is `empty` or `subfolder-empty` (a folder the user emptied on purpose; never `missing` or unreadable), and a selection is never refused; the video modal marks one missing video through it), and `POST /api/videos/purge-removed` (`{ states: ['deleted'|'missing'], removeFromArchive }`, "Empty trash" in Settings > Maintenance): one purge at a time (409 `running`), refused for `missing` while the check covers missing rows; in chunks of 500, each in a transaction, it deletes `video_watch_status` and `jobvideos` rows (the latter has a restricting FK) before the `videos` row and nulls `playlistvideos.downloaded_at`, then unlinks `jobs/info/<id>.info.json` and the cached `videothumb-<id>*.jpg` (library repair would otherwise recreate the row as missing from the archive), removes the `complete.list` lines only when asked (`archiveModule.removeVideosFromArchive`, an atomic file replacement per chunk before the database transaction commits; keeping them means automatic downloads keep skipping the videos), refreshes the affected in-memory jobs through the injected `jobModule.reloadJobVideos` (the route hands it in) and only then broadcasts `videosUpdated` + `jobsUpdated`, so a Download History refetch never shows ghosts; each chunk is re-read under lock with the state predicate inside its transaction, so a video re-downloaded or restored between the select and the delete is kept and its fresh info.json left alone. An archive rewrite failure rolls back that chunk so its rows remain selectable for retry; earlier committed chunks still get the job reload and broadcasts before the error is rethrown. If the database commit fails after a successful archive rewrite, retrying the retained rows safely removes already-absent entries. Nothing on disk under the downloads folder is touched. +- `modules/playlistOutputRefresh.js`: rebuilds the `.m3u` file and re-syncs media servers (`mediaServerSync.syncPlaylist`) for every enabled playlist that includes (not excluded) any of the given videos, one playlist at a time. `videoDeletionModule` calls it in the background after every delete path (manual by id or YouTube id, and automatic cleanup's strategies), so neither keeps listing a deleted file; downloads use `downloadModule.afterDownloadHook` instead. - `modules/playlistConstants.js`: backend playlist fetch/batch limits, preview default, and shared refresh-conflict text. Client feature defaults live in `components/PlaylistPage/playlistConstants.ts`. - First-setup recovery persists `playlists.auto_download_setup_error`: legacy scheduled incomplete snapshots stay enabled and retry with a visible waiting notice; oversized playlists are disabled with an explanation. Recovery refreshes the cache without setting a baseline or clearing requests and cannot become a successful idle sweep. Successful setup clears the error. Subscribe/restore returns 201 with a warning for expected following failures, disables auto-download for size/completeness failures, and preserves settings on a concurrent refresh. Warnings travel through the subscription hook and router state to the page. - 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. @@ -50,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/) @@ -59,9 +62,10 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - `components/shared/`: reusable components used across multiple features (e.g. `VideoModal/` for the video detail modal, `ThumbnailClickOverlay` for clickable thumbnail hotspots, `DeleteVideosDialog`, `AddChannelDialog` for confirming a channel add from search results or an unsubscribed channel name click, `OpenInYouTubeLink` for the link out to YouTube on the channel and playlist page headers and Find Channels result cards). - `components/ui/`: theme-neutral UI primitives (Button, Card, Dialog, Select, etc.) built on Radix and styled via CSS variables + Tailwind. Use these instead of Material-UI imports in new code. `AccordionContent` stays mounted when collapsed (tests can query it) but is `invisible`, so its controls leave the tab order and accessibility tree. - `components/layout/`: app shell and navigation chrome. `AppShell.tsx` is the outer frame; `NavSidebar.tsx` / `NavHeader.tsx` own desktop and mobile nav; `navLayoutConstants.ts` holds shared sidebar/header sizing constants; `layoutFallback.css` provides fallback CSS variables for themes that skip layout overrides. -- `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#<scheduleKey>` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. +- `components/Settings/`: Settings page wrapper and splash index (`SettingsIndex.tsx`) listing the per-section routes under `/settings/<key>`. `MaintenanceSection.tsx` hosts the manual filesystem rescan trigger and the "Deleted and missing videos" card (`Settings/components/RemovedVideosCard.tsx` + `EmptyTrashDialog.tsx`, data through `Settings/hooks/useRemovedVideos.ts`: the counts, "Mark all missing as deleted", and "Empty trash..." with its which-videos / download-archive choices and an explicit cannot-be-undone acknowledgement; the missing choices are disabled with the reason while the downloads folder looks unmounted, driven by `missingActionsBlocked`, except that for an empty folder Mark all stays available once the user confirms they emptied it, sending `ignoreLibraryCheck`, so "Rescan anyway" is never a dead end; Mark all is disabled while a rescan runs). The Videos page has the matching per-selection "Mark as deleted" action (`VideosPage/components/MarkDeletedDialog.tsx`, `VideosPage/hooks/useMarkDeleted.ts`; the shared video modal offers it for one missing video, from any page), a `Deleted` filter chip resting at `exclude` and remembered in localStorage (`shared/VideoList/usePersistedChipFilter.ts`; status chips with a `defaultValue` other than off count as active, and show a `Show: ...` chip, only when moved off it), cleanup hints (`VideosPage/components/HiddenDeletedNote.tsx`: the "N deleted videos hidden." note with Show and Clean up buttons and an info tooltip, on the Library heading's line on wide screens and under it on phones; `LibraryCleanupHints.tsx`: a line under the Missing or Deleted filter when it shows only those videos; each links to the Maintenance card at `/settings/maintenance#deleted-and-missing`, which `MaintenanceSection` scrolls to), and the unmounted-folder warning banner. `shared/AvailabilityChip` renders Available / Deleted (neutral) / Missing (red) with the plain-language reason as its tooltip; on coarse-pointer devices (the query Tooltip uses to open on tap) a tap on it opens the tooltip without reaching the row underneath; mouse clicks, and taps on fine-pointer touchscreens, pass through, for rows that expand on click. `/settings/watch-status` routes to `Configuration/sections/WatchStatusSection.tsx`, which surfaces the sync toggle and a manual "Sync Now" trigger through `Configuration/hooks/useWatchStatusSync.ts` (polls `/api/mediaservers/watch-status` while a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for a `running` 409 refusal from Sync Now, not other refusals such as no media server configured). `/settings/scheduling` routes to `Configuration/sections/SchedulingSection.tsx`: every cron schedule from the `Configuration/schedules.ts` registry (grouped, with `ScheduleEditor` daily/preset/custom modes, and a per-task `frequentRunWarning` from `schedules.ts` shown when any task other than automatic downloads is set to run more than once an hour; `describeSchedule` renders custom expressions in words through cronstrue, but only when `isSupportedCronSyntax` passes, a client mirror of the server's field rules and node-cron's parser, so `10/11`, `L`, `#`, and other syntax the save would reject are echoed instead of described. The expression handed to cronstrue is rebuilt from node-cron 3's own reading of each field (canonical numbers, reversed ranges low to high, the first `7` in the weekday field rewritten to `0` before the range is read, as node-cron does, and a step kept only where standard cron expands it to the same values, otherwise the explicit values), so `0/15` reads as hourly, `1-31/10` as days 10, 20, 30, and `5-7` as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state from `GET /api/schedules` through `Configuration/hooks/useScheduleStatus.ts` (a one-line summary - task count, running, failed last run, next run - and per task the next run, the last recorded run, a `runNow` availability, and a warning when the saved expression could not be scheduled; the hook polls every 60 s, every 30 s while a task is running, pauses while the tab is hidden, refreshes on return, refetches on relevant WebSocket events (`scheduledTaskStatus`, `rescanStatus`, `jobsUpdated`, `downloadComplete`, `downloadPauseChanged`, `connectionRestored`, debounced 300 ms so a burst of download events costs one refetch; skipped while the tab is hidden, refetching once it becomes visible again), and times a refetch just after a blocked task's `availableAt` passes so Run now re-enables on schedule; every timestamp is rendered in the server's zone via `utils/formatters.formatDateTimeInZone`). Tasks are collapsed accordion rows (`sections/components/ScheduleTaskRow.tsx`): the header reads at a glance (status dot and label from `scheduleDisplay.ts`, the schedule in words including unsaved edits, relative next/last run ticked by `hooks/useNow` and corrected by the hook's `clockOffsetMs`, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines, `lg` lines the times up in columns), and `ScheduleTaskDetails` holds the description, settings link, `ScheduleRunStatus`, the full `runNowHint.ts` hint, and the editor. Rows open on a `#<scheduleKey>` deep link and when a save error or failed Run now newly lands on them; `savedConfig` (Settings' `initialConfig`) drives the Unsaved tag. Run lengths come from each task's `lastFinishedRun` (`GET /api/schedules`, via `scheduledTaskRuns.getLatestFinishedRuns`: the newest success/error run, so a skipped or interrupted latest run does not hide the previous length, shown as "previous run took") ; a last run marked interrupted (only the startup pass does that) shows "by a server restart" in place of a length. Each row's `RunNowControl` (icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run through `Configuration/hooks/useRunScheduledTask.ts` (`POST /api/schedules/:key/run`, tracking pending/error state per task and refetching status on completion); automatic video cleanup confirms first, since Run now deletes files. The other settings pages link to it through `ScheduleSummary`. `layout/ScheduledTaskIndicator.tsx` (header, beside the download indicator) shows while any scheduled task except automatic downloads runs, via `hooks/useRunningScheduledTasks.ts` (`GET /api/schedules` once, then on `scheduledTaskStatus`/`connectionRestored` broadcasts and tab return, polling every 30 s only while something runs), and links to `/settings/scheduling`. - `components/Configuration/sections/LoggingSection.tsx` (`/settings/logging`): log level select ('' = LOG_LEVEL), the current level's source from the saved setting plus `/getconfig`'s `logging.envLevel` (via `useConfig().loggingStatus`), log file limits or an unwritable-folder warning, and Download logs through `Configuration/hooks/useLogDownload.ts` (axios blob, since a link cannot send `x-access-token`). - `components/FindChannels/`: channel search page at `/subscriptions/find`; each result card has an Open in YouTube link below its clickable area; clicking an unsubscribed card opens the shared `AddChannelDialog`, whose confirm hands off to `Subscriptions` via `location.state.addChannelUrl` and auto-runs the add flow. +- `components/FindVideos/`: video search page at `/videos/find`. The server returns YouTube's relevance order; `sortResults.ts` sorts the loaded set client-side (relevance, published, views, duration, title, channel; nulls last either way), through the desktop table's headers (natural direction, reversed, then back to relevance) or the shared `VideoList/VideoListSortButton` on phones and in grid view (its `SortOption.undirected` marks relevance, which has no arrow). `hooks/useFindVideosPreferences.ts` remembers view (table by default), sort, and minimum duration in localStorage; `hooks/useVideoSearch.ts` keeps the last search in sessionStorage and restores it when the page opens with the same `?q=`, which the page writes after every search (a `?q=` with nothing remembered runs the search). `components/resultParts.tsx` holds the pieces the table, phone list, and cards share: relative published date with the exact date as tooltip (yt-dlp dates are approximate), compact view count, a Short badge at 60 s or less, a status chip only for states other than never-downloaded, the per-row download button (`hooks/useResultDownload.ts` owns the dialog for either one row or the selection, never touching the selection for a single download), and the channel name through the shared `ChannelNameDisplay` (link when `channelSubscribed`, plus a Subscribed chip; otherwise the add-channel affordance opening `AddChannelDialog`). - `themes/`: theme definitions (`playful`, `linear`, `flat`), shared layout policy (`layoutPolicy.ts`), and the `ALL_THEMES` registry. New themes add an entry here and implement the required token surface. - `hooks/`: app-wide custom hooks for data fetching and state. - `contexts/` and `providers/`: React Context for cross-cutting concerns (auth token, WebSocket, theme). `contexts/ThemeEngineContext.tsx` owns the active theme mode, resolves the layout policy for the current viewport, and injects theme CSS variables onto the document root. @@ -73,7 +77,8 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), - Collation is `utf8mb4_unicode_ci` on every table from migration `20260907000000-normalize-utf8mb4-unicode-collation` onward (older installs had `utf8mb4_general_ci` legacy tables beside explicitly `unicode_ci` newer ones, which breaks any SQL comparing string columns across them). Migrations sorted before it must not compare string columns across tables; `migrations/lib/unicodeCollation.js` is the lowercase-table-name collation helper, `jobsUuidCollation.js` is legacy. The helper drops the foreign keys on the jobs UUID chain before converting and recreates them (stored names and rules, read from information_schema, never assumed) after restoring `utf8mb4_bin`, because MariaDB 10.4.31+/10.5.22+/10.6.15+/10.11.5+/11.x/12.x refuse to alter a foreign-key column even with `FOREIGN_KEY_CHECKS=0`; a key missing after an interrupted run is recreated from the canonical definition as `<table>_job_id_fk`. MariaDB 12.1+ keeps `<Table>_ibfk_N` names across renames and auto-names new constraints `1`, `2`, so Elfhosted-style databases carry mixed-case constraint names on lowercase tables. Test migrations that alter foreign-key columns against `mariadb:12.3` as well as the bundled 10.3. - `server/modules/scheduleConfig.js`: registry of the eight configurable schedules (config key, label, default), `getScheduleError` (syntax via node-cron plus the 15-minute minimum interval, computed structurally with node-cron's own step semantics: a step keeps values divisible by it; the across-midnight case asks the matcher whether two firing days are consecutive, so a Sundays-only `5,55 0,23` schedule is not rejected), `violatesMinimumInterval` with `normalizeToMinimumInterval` (what `configModule` applies at startup: runs closer than 15 minutes apart are dropped, never added, and the hour and calendar fields are kept, so `0,5 2 * * 0` becomes `0 2 * * 0` and pre-floor hand edits keep running instead of blocking every Settings save), and `getNextRun` (steps node-cron's internal time matcher over the minutes/hours the expression can fire on, so predictions match what actually fires, and adds the repeated period when clocks fall back, derived from the offset change so thirty-minute zones such as Lord Howe work, because `setHours` only yields an ambiguous time's first occurrence). `scheduledTaskManager.js` owns the timers: `updateTask({ id, expression, enabled, run, isRunning, getRunBlocker, manualCooldownMs, manualRunRequiresEnabled })` replaces a timer only when the expression changes, keeps the previous timer on an invalid edit, skips a scheduled occurrence while the previous run is still active, and records every run through the recorder injected by `setRunRecorder` (wired in server.js once the DB is ready); `manualRunRequiresEnabled: false` (channel downloads) means the feature switch only stops the timer, not a manual run. A task whose work outlives it may resolve with `finalRecord` (a promise of the run record): the lock is released as usual and only the history row waits for it (`finishWhenSettled`; a rejection closes the row as an error, a row still open at shutdown becomes interrupted), so a slow or stuck `finalRecord` can never hold a task running. `run` is always called as `run({ trigger, ...args })`. `runNow(id, { trigger, args, enforceEnabled, enforceCooldown })` is the single entry point for manual runs -- Scheduling's Run now via `routes/runNowResponse.js` and `POST /api/schedules/:key/run`, the Maintenance rescan, Watch Status Sync Now, the YT-DLP update button, and Download New -- returning `{ started, completion }` or a blocker, checked in order: running (the manager's own flag or the task's `isRunning` probe) -> the task's `getRunBlocker` -> turned off -> the manual-run cooldown (`MANUAL_RUN_COOLDOWN_MS`, kept in memory, applied to channel video counts only; channel downloads are refused only while a sweep is running). `getTaskSnapshot(id)` reads the run blocker and the task's status together, after the run history has been read, so `running` and `runNow` on `GET /api/schedules` never disagree; `scheduledTaskStatus` broadcasts (`{ key }`) tell open Scheduling pages to refetch, and `announceRun(id, promise)` gives the same broadcast to startup passes that run outside the manager (library repair, the yt-dlp channel re-apply, the channel-count refresh). Tasks resolve to `{ status: success|error|skipped, outcome, message, details }`; the feature tasks map their own results through adapters (`mediaServers/watchStatusRunSummary`, `channelAutoDownload`, `backfillFromCompleteList`, `rescanRunSummary`, `ytdlpUpdateRunSummary`) so an overlapped, deferred, or partially failed run is never recorded as a success; a run whose work partly failed (some deletions or empty-folder removals, some archive record writes, the playlist half of the download sweep or any individual playlist in it, one media server of a sync) records as `error` with outcome `partial` and counts in its message. `scheduledTaskRuns.js` is that recorder: a bounded history (20 rows per task, never pruning a running row or the newest row of each outcome, so "last installed" survives any number of later checks) in `scheduled_task_runs`, best-effort so a DB problem never reaches the task, with `markInterruptedRuns()` at startup flagging rows a crashed process left running. `rescanRunSummary.js` and `ytdlpUpdateRunSummary.js` map the rescan and yt-dlp update results to run records and back; startup passes (the filesystem rescan, library repair, and the channel-count refresh) record themselves, while every scheduled and manual run, including the rescan's, is recorded by `scheduledTaskManager` through `runNow`. `jobModule.runStartupBackfill` records the archive repair's startup pass the same way (one finished row, because it starts before `markInterruptedRuns()`), and their last-run displays (Maintenance page, YT-DLP page via `/api/ytdlp/latest-version`) read the history, falling back to the legacy `rescanLastRun` / `ytdlpLast*` config keys, which are no longer written, until a run has been recorded. -- `server/modules/videoLocalStatus.js`: shared local download/file metadata lookup for search results and the local-status endpoint. Also sets `inArchive` on each result: the ID is in `config/complete.list` (one read per call through `archiveModule.filterArchivedVideoIds`, which returns an empty set when the file cannot be read) but has no Videos row, so yt-dlp would skip it. Channel-ignored videos are excluded, because ignoring writes the archive entry on purpose. The channel listing sets the same flag on the page it returns (`channelVideosService.applyArchiveFlag`, called from `buildChannelVideosResponse` so the date probe that precedes each listing never reads the archive). The channel page, video modal, and Find Videos count such videos as previously downloaded when opening the download dialog, which turns on "Allow re-downloading"; the channel page and Find Videos pass the flag into the modal, whose own local-status lookup then keeps it current. +- `server/modules/videoLocalStatus.js`: shared local download/file metadata lookup for search results and the local-status endpoint. Also sets `inArchive` on each result: the ID is in `config/complete.list` (one read per call through `archiveModule.filterArchivedVideoIds`, which returns an empty set when the file cannot be read) but has no Videos row, so yt-dlp would skip it. Channel-ignored videos are excluded, because ignoring writes the archive entry on purpose. That lookup is `modules/archiveOnlyIds.js`, shared with channel Download all and playlist download selection; it requires only the ChannelVideo model, not the models index, because `downloadModule.test.js` replaces model files with plain objects and the index's association setup throws on them when `playlistDownloadModule` loads it. The channel listing sets the same flag on the page it returns (`channelVideosService.applyArchiveFlag`, called from `buildChannelVideosResponse` so the date probe that precedes each listing never reads the archive). The channel page, video modal, and Find Videos count such videos as previously downloaded when opening the download dialog, which turns on "Allow re-downloading"; the channel page and Find Videos pass the flag into the modal, whose own local-status lookup then keeps it current. It also sets `channelSubscribed` (an enabled Channel row for the result's `channelId`) on results that carry a channel id, i.e. search results but not the local-status endpoint's, whose refresh therefore never clears the flag. +- `server/modules/videoSearchModule.js`: `POST /api/videos/search` (YouTube Data API when a key is set, else yt-dlp `ytsearch`). Results stay in the source's order, YouTube's relevance ranking, which the client cannot recompute; every other order is applied client-side (`FindVideos/sortResults.ts`). The yt-dlp path drops `live_status` `is_live`/`is_upcoming` entries, as the API path already did, because a download would record the stream; a VOD with a null duration is kept. - `server/modules/download/videoActivity.js`: transient per-video queued/downloading registry, duplicate admission claims, and revisioned snapshots. - `client/src/providers/VideoActivityProvider.tsx`: shared authenticated activity snapshots; `useLocalVideoStatus` refreshes persisted metadata independently of activity revisions. diff --git a/client/package-lock.json b/client/package-lock.json index 4df0539ab..ac7f36329 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -6873,9 +6873,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", diff --git a/client/src/components/ChannelPage/ChannelVideos.tsx b/client/src/components/ChannelPage/ChannelVideos.tsx index 457e48f94..ad24c430d 100644 --- a/client/src/components/ChannelPage/ChannelVideos.tsx +++ b/client/src/components/ChannelPage/ChannelVideos.tsx @@ -22,7 +22,7 @@ import useMediaQuery from '../../hooks/useMediaQuery'; import { DownloadSettings } from '../DownloadManager/ManualDownload/types'; import { useVideoDeletion } from '../shared/useVideoDeletion'; import { useVideoProtection } from '../shared/useVideoProtection'; -import { getVideoStatus } from '../../utils/videoStatus'; +import { getVideoStatus, isRemovedStatus } from '../../utils/videoStatus'; import VideoCard from './VideoCard'; import VideoListItem from './VideoListItem'; import VideoTableView from './VideoTableView'; @@ -135,6 +135,7 @@ function ChannelVideos({ const [maxRating, setMaxRating] = useState(''); const [protectedFilter, setProtectedFilter] = useState<ChipFilterMode>('off'); const [missingFilter, setMissingFilter] = useState<ChipFilterMode>('off'); + const [deletedFilter, setDeletedFilter] = useState<ChipFilterMode>('off'); const [ignoredFilter, setIgnoredFilter] = useState<ChipFilterMode>('off'); const [watchedFilter, setWatchedFilter] = useState<ChipFilterMode>('off'); @@ -291,6 +292,7 @@ function ChannelVideos({ filters.dateTo ? filters.dateTo.toISOString() : '', protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, useInfiniteScroll, @@ -309,6 +311,7 @@ function ChannelVideos({ filters.dateTo, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, useInfiniteScroll, @@ -342,6 +345,7 @@ function ChannelVideos({ resetKey, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, onFirstLoad: onVideosLoaded, @@ -397,6 +401,7 @@ function ChannelVideos({ maxRating, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, downloadedFilter, @@ -514,6 +519,7 @@ function ChannelVideos({ maxRating, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, downloadedFilter, @@ -534,7 +540,7 @@ function ChannelVideos({ const downloadable = paginatedVideos .filter((video) => { const status = getVideoStatus(video); - return status === 'never_downloaded' || status === 'missing' || status === 'ignored'; + return status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored'; }) .map((video) => video.youtube_id); setCheckedBoxes((prev) => { @@ -557,7 +563,7 @@ function ChannelVideos({ const ids = paginatedVideos .filter((video) => { const status = getVideoStatus(video); - return status === 'never_downloaded' || status === 'missing' || status === 'ignored'; + return status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored'; }) .map((video) => video.youtube_id); setCheckedBoxes((prev) => { @@ -872,6 +878,7 @@ function ChannelVideos({ { id: 'maxRating', value: maxRating, onChange: setMaxRating }, { id: 'protected', value: protectedFilter, onChange: setProtectedFilter }, { id: 'missing', value: missingFilter, onChange: setMissingFilter }, + { id: 'deleted', value: deletedFilter, onChange: setDeletedFilter }, { id: 'ignored', value: ignoredFilter, onChange: setIgnoredFilter }, { id: 'downloaded', value: downloadedFilter, onChange: setDownloadedFilter }, { id: 'watched', value: watchedFilter, onChange: setWatchedFilter }, @@ -891,6 +898,7 @@ function ChannelVideos({ maxRating, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, downloadedFilter, @@ -1070,7 +1078,7 @@ function ChannelVideos({ // The summary already gives the tab's size; the list count only adds // something while a search or filter narrows the list. const listIsFiltered = Boolean(listState.search) || hasDurationOrDateFilter || maxRating !== '' - || [downloadedFilter, protectedFilter, missingFilter, ignoredFilter, watchedFilter].some((mode) => mode !== 'off'); + || [downloadedFilter, protectedFilter, missingFilter, deletedFilter, ignoredFilter, watchedFilter].some((mode) => mode !== 'off'); let countChipLabel: string | null = null; if (totalCount > 0 && listIsFiltered) { countChipLabel = `${totalCount} ${totalCount === 1 ? 'match' : 'matches'}`; @@ -1252,7 +1260,7 @@ function ChannelVideos({ disabled={ paginatedVideos.filter((video) => { const status = getVideoStatus(video); - return status === 'never_downloaded' || status === 'missing' || status === 'ignored'; + return status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored'; }).length === 0 } className="intent-warning" diff --git a/client/src/components/ChannelPage/VideoCard.tsx b/client/src/components/ChannelPage/VideoCard.tsx index 5135eae5b..1c56a6cbb 100644 --- a/client/src/components/ChannelPage/VideoCard.tsx +++ b/client/src/components/ChannelPage/VideoCard.tsx @@ -12,7 +12,8 @@ import { CalendarToday as CalendarTodayIcon, Block as BlockIcon, CheckCircleOutl import { formatDuration } from '../../utils'; import { ChannelVideo } from '../../types/ChannelVideo'; import { decodeHtml, formatAddedDateTime } from '../../utils/formatters'; -import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle } from '../../utils/videoStatus'; +import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; +import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription'; import StillLiveDot from './StillLiveDot'; import RatingBadge from '../shared/RatingBadge'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; @@ -58,7 +59,7 @@ function VideoCard({ const status = getVideoStatus(video); // Check if video is still live (not "was_live" and not null/undefined) const isStillLive = video.live_status && video.live_status !== 'was_live'; - const isDownloadSelectable = (status === 'never_downloaded' || status === 'missing' || status === 'ignored') && !isStillLive; + const isDownloadSelectable = (status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored') && !isStillLive; const isDeleteSelectable = video.added && !video.removed && !isStillLive; const isDownloadAllowed = selectionMode !== 'delete'; const isDeleteAllowed = selectionMode !== 'download'; @@ -390,19 +391,25 @@ function VideoCard({ style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE, flexShrink: 0 }} /> <WatchedChip watchedBy={video.watchedBy || []} /> - <Chip - icon={getStatusIcon(status)} - label={statusLabel} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ - flex: '0 0 auto', - minWidth: 'fit-content', - ...SHARED_THEMED_CHIP_SMALL_STYLE, - ...getStatusChipStyle(status), - }} - /> + <Tooltip + title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''} + enterTouchDelay={0} + className={REMOVAL_TOOLTIP_CLASS} + > + <Chip + icon={getStatusIcon(status)} + label={statusLabel} + size="small" + color={getStatusColor(status)} + variant={getStatusChipVariant(status)} + style={{ + flex: '0 0 auto', + minWidth: 'fit-content', + ...SHARED_THEMED_CHIP_SMALL_STYLE, + ...getStatusChipStyle(status), + }} + /> + </Tooltip> </div> </div> </div> diff --git a/client/src/components/ChannelPage/VideoListItem.tsx b/client/src/components/ChannelPage/VideoListItem.tsx index 87bfa99ec..b7ceaf36b 100644 --- a/client/src/components/ChannelPage/VideoListItem.tsx +++ b/client/src/components/ChannelPage/VideoListItem.tsx @@ -14,7 +14,8 @@ import { ChannelVideo } from '../../types/ChannelVideo'; import { decodeHtml, formatAddedDate } from '../../utils/formatters'; import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_THEMED_CHIP_SMALL_STYLE } from '../shared/chipStyles'; import { getPublishedDateDisplay } from './publishedDateDisplay'; -import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle } from '../../utils/videoStatus'; +import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; +import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription'; import StillLiveDot from './StillLiveDot'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; import ProtectionShieldButton from '../shared/ProtectionShieldButton'; @@ -49,7 +50,7 @@ function VideoListItem({ const status = getVideoStatus(video); // Check if video is still live (not "was_live" and not null/undefined) const isStillLive = video.live_status && video.live_status !== 'was_live'; - const isDownloadSelectable = (status === 'never_downloaded' || status === 'missing' || status === 'ignored') && !isStillLive; + const isDownloadSelectable = (status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored') && !isStillLive; const isDeleteSelectable = video.added && !video.removed && !isStillLive; const isDownloadAllowed = selectionMode !== 'delete'; const isDeleteAllowed = selectionMode !== 'download'; @@ -370,18 +371,24 @@ function VideoListItem({ style={{ ...SHARED_STATUS_CHIP_SMALL_STYLE }} /> <WatchedChip watchedBy={video.watchedBy || []} /> - <Chip - icon={getStatusIcon(status)} - label={statusLabel} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ - flex: '0 0 auto', - ...SHARED_THEMED_CHIP_SMALL_STYLE, - ...getStatusChipStyle(status), - }} - /> + <Tooltip + title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''} + enterTouchDelay={0} + className={REMOVAL_TOOLTIP_CLASS} + > + <Chip + icon={getStatusIcon(status)} + label={statusLabel} + size="small" + color={getStatusColor(status)} + variant={getStatusChipVariant(status)} + style={{ + flex: '0 0 auto', + ...SHARED_THEMED_CHIP_SMALL_STYLE, + ...getStatusChipStyle(status), + }} + /> + </Tooltip> </div> </CardContent> </Card> diff --git a/client/src/components/ChannelPage/VideoTableView.tsx b/client/src/components/ChannelPage/VideoTableView.tsx index 3e732b43f..f78d55198 100644 --- a/client/src/components/ChannelPage/VideoTableView.tsx +++ b/client/src/components/ChannelPage/VideoTableView.tsx @@ -3,6 +3,7 @@ import { Checkbox, Typography, Chip, + Tooltip, } from '../ui'; import { ArrowUpward as ArrowUpwardIcon, ArrowDownward as ArrowDownwardIcon, Block as BlockIcon, CheckCircleOutline as CheckCircleOutlineIcon, Delete as DeleteIcon } from '../../lib/icons'; import ProtectionShieldButton from '../shared/ProtectionShieldButton'; @@ -10,7 +11,8 @@ import ThumbnailClickOverlay from '../shared/ThumbnailClickOverlay'; import { formatDuration } from '../../utils'; import { ChannelVideo } from '../../types/ChannelVideo'; import { decodeHtml, formatAddedDateParts } from '../../utils/formatters'; -import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle } from '../../utils/videoStatus'; +import { getVideoStatus, getStatusColor, getStatusIcon, getStatusLabel, getMediaTypeInfo, getStatusChipVariant, getStatusChipStyle, isRemovedStatus } from '../../utils/videoStatus'; +import RemovalDescription, { REMOVAL_TOOLTIP_CLASS } from '../shared/RemovalDescription'; import StillLiveDot from './StillLiveDot'; import RatingBadge from '../shared/RatingBadge'; import DownloadFormatIndicator from '../shared/DownloadFormatIndicator'; @@ -64,7 +66,7 @@ function VideoTableView({ if (isDeleteMode) { return video.added && !video.removed && !isStillLive; } - return (status === 'never_downloaded' || status === 'missing' || status === 'ignored') && !video.youtube_removed && !isStillLive; + return (status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored') && !video.youtube_removed && !isStillLive; }); return ( @@ -128,7 +130,7 @@ function VideoTableView({ const status = getVideoStatus(video); const statusLabel = status === 'downloaded' ? 'Available' : getStatusLabel(status); const isStillLive = video.live_status && video.live_status !== 'was_live'; - const isDownloadSelectable = (status === 'never_downloaded' || status === 'missing' || status === 'ignored') && !video.youtube_removed && !isStillLive; + const isDownloadSelectable = (status === 'never_downloaded' || isRemovedStatus(status) || status === 'ignored') && !video.youtube_removed && !isStillLive; const isDeleteSelectable = video.added && !video.removed && !isStillLive; const isDownloadAllowed = selectionMode !== 'delete'; const isDeleteAllowed = selectionMode !== 'download'; @@ -328,7 +330,7 @@ function VideoTableView({ /> </td> <td style={{ whiteSpace: 'nowrap', padding: '8px 4px' }}> - {(video.filePath || video.audioFilePath) ? ( + {!video.removed && (video.filePath || video.audioFilePath) ? ( <DownloadFormatIndicator filePath={video.filePath} audioFilePath={video.audioFilePath} @@ -352,14 +354,20 @@ function VideoTableView({ /> )} <WatchedChip watchedBy={video.watchedBy || []} /> - <Chip - icon={getStatusIcon(status)} - label={statusLabel} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ ...SHARED_THEMED_CHIP_SMALL_STYLE, ...getStatusChipStyle(status) }} - /> + <Tooltip + title={isRemovedStatus(status) ? <RemovalDescription row={video} /> : ''} + enterTouchDelay={0} + className={REMOVAL_TOOLTIP_CLASS} + > + <Chip + icon={getStatusIcon(status)} + label={statusLabel} + size="small" + color={getStatusColor(status)} + variant={getStatusChipVariant(status)} + style={{ ...SHARED_THEMED_CHIP_SMALL_STYLE, ...getStatusChipStyle(status) }} + /> + </Tooltip> </div> </td> </tr> diff --git a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx index 5c9c4ae66..f573abb91 100644 --- a/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoCard.test.tsx @@ -125,7 +125,7 @@ describe('VideoCard Component', () => { }); test('renders "Missing" status for removed video', () => { - const removedVideo = { ...mockVideo, added: true, removed: true }; + const removedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={removedVideo} />); expect(screen.getByText('Missing')).toBeInTheDocument(); }); @@ -242,7 +242,7 @@ describe('VideoCard Component', () => { }); test('renders checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={missingVideo} />); expect(screen.getByRole('checkbox')).toBeInTheDocument(); }); @@ -442,7 +442,7 @@ describe('VideoCard Component', () => { }); test('does not render delete checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={missingVideo} selectionMode="delete" />); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); @@ -475,7 +475,7 @@ describe('VideoCard Component', () => { }); test('renders ignore button for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={missingVideo} />); expect(screen.getByTestId('BlockIcon')).toBeInTheDocument(); }); @@ -849,7 +849,7 @@ describe('VideoCard Component', () => { }); test('renders CloudOffIcon for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoCard {...defaultProps} video={missingVideo} />); expect(screen.getByTestId('CloudOffIcon')).toBeInTheDocument(); }); diff --git a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx index cfb263f33..ec56c4137 100644 --- a/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoListItem.test.tsx @@ -106,7 +106,7 @@ describe('VideoListItem Component', () => { }); test('renders "Missing" status for removed video', () => { - const removedVideo = { ...mockVideo, added: true, removed: true }; + const removedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={removedVideo} />); expect(screen.getByText('Missing')).toBeInTheDocument(); }); @@ -238,7 +238,7 @@ describe('VideoListItem Component', () => { }); test('renders checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={missingVideo} />); expect(screen.getByRole('checkbox')).toBeInTheDocument(); }); @@ -476,7 +476,7 @@ describe('VideoListItem Component', () => { }); test('does not render delete checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={missingVideo} selectionMode="delete" />); expect(screen.queryByLabelText(/mark for deletion/i)).not.toBeInTheDocument(); }); @@ -511,7 +511,7 @@ describe('VideoListItem Component', () => { }); test('renders ignore button for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={missingVideo} />); expect(screen.getByTestId('BlockIcon')).toBeInTheDocument(); expect(screen.getByLabelText('Click to ignore')).toBeInTheDocument(); @@ -758,7 +758,7 @@ describe('VideoListItem Component', () => { }); test('renders CloudOffIcon for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoListItem {...defaultProps} video={missingVideo} />); expect(screen.getByTestId('CloudOffIcon')).toBeInTheDocument(); }); diff --git a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx index 5b3e34f7d..bb375de60 100644 --- a/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx +++ b/client/src/components/ChannelPage/__tests__/VideoTableView.test.tsx @@ -201,6 +201,12 @@ describe('VideoTableView Component', () => { expect(screen.getByTestId('VideoFormatIcon')).toBeInTheDocument(); }); + test('shows no format chips for a deleted video, whose stored path is only history', () => { + const deletedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'user_deleted' as const, filePath: '/path/to/video.mp4' }; + renderWithProviders(<VideoTableView {...defaultProps} videos={[deletedVideo]} />); + expect(screen.queryByTestId('VideoFormatIcon')).not.toBeInTheDocument(); + }); + test('renders dash when no file path exists', () => { const videoNoFile = { ...mockVideo, filePath: undefined }; renderWithProviders(<VideoTableView {...defaultProps} videos={[videoNoFile]} />); @@ -239,7 +245,7 @@ describe('VideoTableView Component', () => { }); test('renders "Missing" status for removed video', () => { - const removedVideo = { ...mockVideo, added: true, removed: true }; + const removedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[removedVideo]} />); expect(screen.getByText('Missing')).toBeInTheDocument(); expect(screen.getByText('Missing')).toBeVisible(); @@ -427,13 +433,30 @@ describe('VideoTableView Component', () => { }); test('renders checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[missingVideo]} />); const checkboxes = screen.getAllByRole('checkbox'); // Header + 1 video checkbox expect(checkboxes).toHaveLength(2); }); + test('renders checkbox for deleted videos, including ones removed before the reason was recorded', () => { + const deletedVideo = { ...mockVideo, added: true, removed: true, removed_reason: null }; + renderWithProviders(<VideoTableView {...defaultProps} videos={[deletedVideo]} />); + const checkboxes = screen.getAllByRole('checkbox'); + // Header + 1 video checkbox + expect(checkboxes).toHaveLength(2); + }); + + test('counts deleted videos as selectable for the header checkbox', () => { + const deletedVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'user_deleted' as const }; + renderWithProviders( + <VideoTableView {...defaultProps} videos={[deletedVideo]} checkedBoxes={[deletedVideo.youtube_id]} /> + ); + const headerCheckbox = screen.getAllByRole('checkbox')[0]; + expect(headerCheckbox).toBeChecked(); + }); + test('does not render checkbox for downloaded videos', () => { const downloadedVideo = { ...mockVideo, added: true, removed: false }; renderWithProviders(<VideoTableView {...defaultProps} videos={[downloadedVideo]} />); @@ -529,7 +552,7 @@ describe('VideoTableView Component', () => { }); test('does not render delete checkbox for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders( <VideoTableView {...defaultProps} videos={[missingVideo]} selectionMode="delete" /> ); @@ -569,7 +592,7 @@ describe('VideoTableView Component', () => { }); test('renders ignore button for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[missingVideo]} />); const buttons = screen.getAllByRole('button', { name: /ignore/i }); expect(buttons.length).toBeGreaterThan(0); @@ -710,7 +733,7 @@ describe('VideoTableView Component', () => { }); test('renders ignore button for missing video, not delete button', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[missingVideo]} />); // Should have ignore button @@ -936,7 +959,7 @@ describe('VideoTableView Component', () => { }); test('renders CloudOffIcon for missing videos', () => { - const missingVideo = { ...mockVideo, added: true, removed: true }; + const missingVideo = { ...mockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; renderWithProviders(<VideoTableView {...defaultProps} videos={[missingVideo]} />); expect(screen.getByTestId('CloudOffIcon')).toBeInTheDocument(); }); diff --git a/client/src/components/ChannelPage/hooks/useChannelVideos.ts b/client/src/components/ChannelPage/hooks/useChannelVideos.ts index 6f62f24da..3882e5d69 100644 --- a/client/src/components/ChannelPage/hooks/useChannelVideos.ts +++ b/client/src/components/ChannelPage/hooks/useChannelVideos.ts @@ -21,6 +21,7 @@ interface UseChannelVideosParams { dateTo?: Date | null; protectedFilter?: ChipFilterMode; missingFilter?: ChipFilterMode; + deletedFilter?: ChipFilterMode; ignoredFilter?: ChipFilterMode; watchedFilter?: ChipFilterMode; onFirstLoad?: (channelId: string) => void; @@ -56,6 +57,7 @@ export function useChannelVideos({ dateTo, protectedFilter, missingFilter, + deletedFilter, ignoredFilter, watchedFilter, onFirstLoad, @@ -128,6 +130,9 @@ export function useChannelVideos({ if (missingFilter && missingFilter !== 'off') { queryParams.append('missingFilter', missingFilter); } + if (deletedFilter && deletedFilter !== 'off') { + queryParams.append('deletedFilter', deletedFilter); + } if (ignoredFilter && ignoredFilter !== 'off') { queryParams.append('ignoredFilter', ignoredFilter); } @@ -201,7 +206,7 @@ export function useChannelVideos({ setLoading(false); } } - }, [channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, maxRating, token, append, resetKey, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter]); + }, [channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, maxRating, token, append, resetKey, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, deletedFilter, ignoredFilter, watchedFilter]); useEffect(() => { fetchVideos(); diff --git a/client/src/components/DownloadManager/DownloadHistory.tsx b/client/src/components/DownloadManager/DownloadHistory.tsx index 6071bdf10..d5ce99bf3 100644 --- a/client/src/components/DownloadManager/DownloadHistory.tsx +++ b/client/src/components/DownloadManager/DownloadHistory.tsx @@ -29,7 +29,9 @@ import PageControls from '../shared/PageControls'; import VideoModal from '../shared/VideoModal'; import { VideoModalData } from '../shared/VideoModal/types'; import VideoThumbnail from './VideoThumbnail'; -import MissingVideoChip from './MissingVideoChip'; +import AvailabilityChip from '../shared/AvailabilityChip'; +import { getRemovalState, removalFromRow, removedState } from '../../utils/videoRemoval'; +import RemovalDescription from '../shared/RemovalDescription'; import FailedVideoChip from './FailedVideoChip'; import FailedDownloadsDetail from './FailedDownloadsDetail'; import { jobTypeLabel } from '../../utils/jobTypeLabel'; @@ -68,11 +70,12 @@ function getDiagnosisTitles(job: Job): string[] { function jobVideoToModalData(video: VideoData): VideoModalData { const isDownloaded = Boolean(video.filePath || video.audioFilePath) && !video.removed; const status: VideoModalData['status'] = video.removed - ? 'missing' + ? removedState(video.removed_reason) : isDownloaded ? 'downloaded' : 'never_downloaded'; return { + removal: removalFromRow(video), youtubeId: video.youtubeId, title: video.youTubeVideoName, channelName: video.youTubeChannelName, @@ -268,7 +271,8 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ const titleText = singleVideo?.youTubeVideoName || (hasMultiple ? `Multiple (${videos.length})` : cleanJobTypeLabel(job.jobType)); const channelText = !hasMultiple ? singleVideo?.youTubeChannelName : undefined; const showThumbnail = !hasMultiple && !!singleVideo; - const missingCount = videos.filter((v: VideoData) => v.removed).length; + const missingCount = videos.filter((v: VideoData) => getRemovalState(v.removed, v.removed_reason) === 'missing').length; + const deletedCount = videos.filter((v: VideoData) => getRemovalState(v.removed, v.removed_reason) === 'deleted').length; const failedForJob = getDisplayableFailedVideos(job); const hasExpandable = hasMultiple || failedForJob.length > 0; @@ -298,11 +302,19 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ )} </Typography> {hasMultiple && missingCount > 0 && ( - <MissingVideoChip + <AvailabilityChip + state="missing" label={`${missingCount} missing`} tooltip={`${missingCount} of ${videos.length} video files not found on disk`} /> )} + {hasMultiple && deletedCount > 0 && ( + <AvailabilityChip + state="deleted" + label={`${deletedCount} deleted`} + tooltip={`${deletedCount} of ${videos.length} videos ${deletedCount === 1 ? 'was' : 'were'} deleted by Youtarr or marked as deleted`} + /> + )} {failedForJob.length > 0 && ( <FailedVideoChip count={failedForJob.length} @@ -390,7 +402,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ {video.youTubeChannelName} </Typography> <VideoActivityChip youtubeId={video.youtubeId} /> - {video.removed && <MissingVideoChip />} + {video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={<RemovalDescription row={video} />} />} </Box> </Box> ))} @@ -496,7 +508,8 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ const failedForJob = getDisplayableFailedVideos(job); if (videos.length > 1 || failedForJob.length > 0) { - const missingCount = videos.filter((v: VideoData) => v.removed).length; + const missingCount = videos.filter((v: VideoData) => getRemovalState(v.removed, v.removed_reason) === 'missing').length; + const deletedCount = videos.filter((v: VideoData) => getRemovalState(v.removed, v.removed_reason) === 'deleted').length; const summaryLabel = videos.length > 1 ? `Multiple (${videos.length})` : videos[0]?.youTubeVideoName || cleanJobTypeLabel(job.jobType); @@ -508,11 +521,19 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ <Box className="flex items-center gap-2 flex-wrap"> <span>{summaryLabel}</span> {missingCount > 0 && ( - <MissingVideoChip + <AvailabilityChip + state="missing" label={`${missingCount} missing`} tooltip={`${missingCount} of ${videos.length} video files not found on disk`} /> )} + {deletedCount > 0 && ( + <AvailabilityChip + state="deleted" + label={`${deletedCount} deleted`} + tooltip={`${deletedCount} of ${videos.length} videos ${deletedCount === 1 ? 'was' : 'were'} deleted by Youtarr or marked as deleted`} + /> + )} {failedForJob.length > 0 && ( <FailedVideoChip count={failedForJob.length} @@ -551,7 +572,7 @@ const DownloadHistory: React.FC<DownloadHistoryProps> = ({ {video.youTubeVideoName} </Link> <VideoActivityChip youtubeId={video.youtubeId} /> - {video.removed && <MissingVideoChip />} + {video.removed && <AvailabilityChip state={removedState(video.removed_reason)} tooltip={<RemovalDescription row={video} />} />} </Box> <Typography variant="caption" color="secondary" className="block">{video.youTubeChannelName}</Typography> </TableCell> diff --git a/client/src/components/DownloadManager/MissingVideoChip.tsx b/client/src/components/DownloadManager/MissingVideoChip.tsx deleted file mode 100644 index 8287bc6fe..000000000 --- a/client/src/components/DownloadManager/MissingVideoChip.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { Chip, Tooltip } from '../ui'; -import { AlertCircle } from 'lucide-react'; -import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../shared/chipStyles'; - -const DEFAULT_TOOLTIP = 'Video file not found on disk. It may have been deleted or moved.'; - -interface MissingVideoChipProps { - label?: string; - tooltip?: string; -} - -function MissingVideoChip({ label = 'Missing', tooltip = DEFAULT_TOOLTIP }: MissingVideoChipProps) { - return ( - <Tooltip title={tooltip} enterTouchDelay={0}> - <Chip - size="small" - icon={<AlertCircle size={12} />} - label={label} - color="error" - variant="filled" - style={SHARED_THEMED_CHIP_SMALL_STYLE} - /> - </Tooltip> - ); -} - -export default MissingVideoChip; diff --git a/client/src/components/DownloadManager/VideoThumbnail.tsx b/client/src/components/DownloadManager/VideoThumbnail.tsx index afb10c026..d4eebbfd0 100644 --- a/client/src/components/DownloadManager/VideoThumbnail.tsx +++ b/client/src/components/DownloadManager/VideoThumbnail.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { Box, Typography } from '../ui'; -import { AlertCircle as ErrorOutlineIcon } from 'lucide-react'; +import { AlertCircle as ErrorOutlineIcon, Trash2 as DeletedIcon } from 'lucide-react'; import { VideoData } from '../../types/VideoData'; +import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../utils/videoRemoval'; const MISSING_OVERLAY_BG = 'color-mix(in srgb, var(--destructive) 30%, transparent)'; @@ -24,7 +25,11 @@ function VideoThumbnail({ onError, iconSize, }: VideoThumbnailProps) { - const isMissing = Boolean(video.removed); + // Missing (file not found) keeps the red warning tint; deleted (removed by + // Youtarr) gets a neutral one, since nothing is wrong. + const removalState = getRemovalState(video.removed, video.removed_reason); + const isRemoved = removalState !== 'present'; + const isMissing = removalState === 'missing'; return ( <Box @@ -44,7 +49,7 @@ function VideoThumbnail({ variant="caption" className="text-center px-1" style={{ - filter: isMissing ? 'grayscale(100%) brightness(0.6)' : 'none', + filter: isRemoved ? 'grayscale(100%) brightness(0.6)' : 'none', }} > No thumbnail @@ -59,21 +64,29 @@ function VideoThumbnail({ width: '100%', height: '100%', objectFit: video.media_type === 'short' ? 'contain' : 'cover', - filter: isMissing ? 'grayscale(100%) brightness(0.6)' : 'none', + filter: isRemoved ? 'grayscale(100%) brightness(0.6)' : 'none', }} /> )} - {isMissing && ( + {isRemoved && ( <Box - data-testid="video-thumbnail-missing-overlay" + data-testid={isMissing ? 'video-thumbnail-missing-overlay' : 'video-thumbnail-deleted-overlay'} className="absolute inset-0 z-[1] flex items-center justify-center" - style={{ backgroundColor: MISSING_OVERLAY_BG }} + style={{ backgroundColor: isMissing ? MISSING_OVERLAY_BG : DELETED_OVERLAY_BACKGROUND }} > - <ErrorOutlineIcon - size={iconSize} - className="text-destructive" - style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }} - /> + {isMissing ? ( + <ErrorOutlineIcon + size={iconSize} + className="text-destructive" + style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }} + /> + ) : ( + <DeletedIcon + size={iconSize} + className="text-muted-foreground" + style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }} + /> + )} </Box> )} </Box> diff --git a/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx b/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx index f5577a767..82ecb511f 100644 --- a/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx +++ b/client/src/components/DownloadManager/__tests__/DownloadHistory.test.tsx @@ -727,7 +727,7 @@ describe('DownloadHistory', () => { test('desktop summary row shows "N missing" chip when at least one video is removed', () => { const job = makeMultiVideoJob('job-multi', [ makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Video A' }), - makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Video B', removed: true }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Video B', removed: true, removed_reason: 'not_found' }), makeVideo({ id: 3, youtubeId: 'v3', youTubeVideoName: 'Video C' }), ]); @@ -737,6 +737,34 @@ describe('DownloadHistory', () => { expect(screen.getByText('1 missing')).toBeInTheDocument(); }); + test('desktop summary row counts deleted videos separately from missing ones', () => { + const job = makeMultiVideoJob('job-multi-deleted', [ + makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Video A' }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Video B', removed: true, removed_reason: 'user_deleted' }), + makeVideo({ id: 3, youtubeId: 'v3', youTubeVideoName: 'Video C', removed: true, removed_reason: 'not_found' }), + ]); + + render(<DownloadHistory {...defaultProps} jobs={[job]} expanded={{ 'job-multi-deleted': true }} />); + + expect(screen.getByText('1 deleted')).toBeInTheDocument(); + expect(screen.getByText('1 missing')).toBeInTheDocument(); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.getByText('Missing')).toBeInTheDocument(); + }); + + test('explains a single deleted video in the singular', async () => { + const user = userEvent.setup(); + const job = makeMultiVideoJob('job-one-deleted', [ + makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Video A' }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Video B', removed: true, removed_reason: 'user_deleted' }), + ]); + + render(<DownloadHistory {...defaultProps} jobs={[job]} />); + await user.hover(screen.getByText('1 deleted')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('1 of 2 videos was deleted by Youtarr or marked as deleted'); + }); + test('desktop summary row hides the aggregate chip when no videos are removed', () => { const job = makeMultiVideoJob('job-multi-none', [ makeVideo({ id: 1, youtubeId: 'v1' }), @@ -752,7 +780,7 @@ describe('DownloadHistory', () => { test('desktop expanded row shows "Missing" chip next to removed videos only', () => { const job = makeMultiVideoJob('job-multi-expanded', [ makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Kept Video' }), - makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Gone Video', removed: true }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Gone Video', removed: true, removed_reason: 'not_found' }), ]); render( @@ -771,7 +799,7 @@ describe('DownloadHistory', () => { test('mobile summary card shows "N missing" chip when at least one video is removed', () => { const job = makeMultiVideoJob('job-mobile-multi', [ makeVideo({ id: 1, youtubeId: 'v1' }), - makeVideo({ id: 2, youtubeId: 'v2', removed: true }), + makeVideo({ id: 2, youtubeId: 'v2', removed: true, removed_reason: 'not_found' }), ]); render(<DownloadHistory {...defaultProps} jobs={[job]} isMobile />); @@ -795,7 +823,7 @@ describe('DownloadHistory', () => { test('mobile expanded card shows per-video "Missing" chip only for removed videos', () => { const job = makeMultiVideoJob('job-mobile-expanded', [ makeVideo({ id: 1, youtubeId: 'v1', youTubeVideoName: 'Present Video' }), - makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Removed Video', removed: true }), + makeVideo({ id: 2, youtubeId: 'v2', youTubeVideoName: 'Removed Video', removed: true, removed_reason: 'not_found' }), ]); render( diff --git a/client/src/components/DownloadManager/__tests__/MissingVideoChip.test.tsx b/client/src/components/DownloadManager/__tests__/MissingVideoChip.test.tsx deleted file mode 100644 index 86fbaef35..000000000 --- a/client/src/components/DownloadManager/__tests__/MissingVideoChip.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import '@testing-library/jest-dom'; -import MissingVideoChip from '../MissingVideoChip'; - -describe('MissingVideoChip', () => { - test('renders with the default "Missing" label', () => { - render(<MissingVideoChip />); - expect(screen.getByText('Missing')).toBeInTheDocument(); - }); - - test('renders a custom label when provided', () => { - render(<MissingVideoChip label="2 missing" />); - expect(screen.getByText('2 missing')).toBeInTheDocument(); - expect(screen.queryByText('Missing')).not.toBeInTheDocument(); - }); - - test('shows the default tooltip message on hover', async () => { - const user = userEvent.setup(); - render(<MissingVideoChip />); - - await user.hover(screen.getByText('Missing')); - - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent( - 'Video file not found on disk. It may have been deleted or moved.' - ); - }); - - test('shows a custom tooltip message on hover when the tooltip prop is provided', async () => { - const user = userEvent.setup(); - render( - <MissingVideoChip label="2 missing" tooltip="2 of 5 video files not found on disk" /> - ); - - await user.hover(screen.getByText('2 missing')); - - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toHaveTextContent('2 of 5 video files not found on disk'); - }); -}); diff --git a/client/src/components/DownloadManager/__tests__/VideoThumbnail.test.tsx b/client/src/components/DownloadManager/__tests__/VideoThumbnail.test.tsx index 12690c7ea..61c32370d 100644 --- a/client/src/components/DownloadManager/__tests__/VideoThumbnail.test.tsx +++ b/client/src/components/DownloadManager/__tests__/VideoThumbnail.test.tsx @@ -74,14 +74,28 @@ describe('VideoThumbnail', () => { expect(screen.queryByTestId('video-thumbnail-missing-overlay')).not.toBeInTheDocument(); }); - test('renders the missing overlay when video.removed is true', () => { - renderThumbnail({ video: { ...baseVideo, removed: true } }); + test('renders the missing overlay when the file was not found', () => { + renderThumbnail({ video: { ...baseVideo, removed: true, removed_reason: 'not_found' } }); expect(screen.getByTestId('video-thumbnail-missing-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('video-thumbnail-deleted-overlay')).not.toBeInTheDocument(); + }); + + test('renders the neutral deleted overlay when Youtarr removed the file', () => { + renderThumbnail({ video: { ...baseVideo, removed: true, removed_reason: 'user_deleted' } }); + + expect(screen.getByTestId('video-thumbnail-deleted-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('video-thumbnail-missing-overlay')).not.toBeInTheDocument(); + }); + + test('treats a video removed before the reason was recorded as deleted', () => { + renderThumbnail({ video: { ...baseVideo, removed: true } }); + + expect(screen.getByTestId('video-thumbnail-deleted-overlay')).toBeInTheDocument(); }); test('shows missing overlay on top of the "No thumbnail" fallback for a removed video', () => { - renderThumbnail({ hasError: true, video: { ...baseVideo, removed: true } }); + renderThumbnail({ hasError: true, video: { ...baseVideo, removed: true, removed_reason: 'not_found' } }); expect(screen.getByText('No thumbnail')).toBeInTheDocument(); expect(screen.getByTestId('video-thumbnail-missing-overlay')).toBeInTheDocument(); diff --git a/client/src/components/FindChannels/components/ChannelCard.tsx b/client/src/components/FindChannels/components/ChannelCard.tsx index 1eb5a3001..c5cb28792 100644 --- a/client/src/components/FindChannels/components/ChannelCard.tsx +++ b/client/src/components/FindChannels/components/ChannelCard.tsx @@ -2,18 +2,13 @@ import React from 'react'; import { Avatar, Box, Card, CardActionArea, Chip, Typography } from '../../ui'; import { ChannelSearchResult } from '../types'; import OpenInYouTubeLink from '../../shared/OpenInYouTubeLink'; +import { formatCompactCount as formatCount } from '../../../utils/formatters'; interface ChannelCardProps { result: ChannelSearchResult; onClick: () => void; } -function formatCount(count: number): string { - if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; - if (count >= 1_000) return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K`; - return count.toLocaleString(); -} - export default function ChannelCard({ result, onClick }: ChannelCardProps) { return ( <Card className="overflow-hidden hover:shadow-md transition-shadow flex flex-col"> diff --git a/client/src/components/FindVideos/__tests__/FindVideos.test.tsx b/client/src/components/FindVideos/__tests__/FindVideos.test.tsx index 61f6f286b..57a084b87 100644 --- a/client/src/components/FindVideos/__tests__/FindVideos.test.tsx +++ b/client/src/components/FindVideos/__tests__/FindVideos.test.tsx @@ -1,9 +1,12 @@ // Local metadata refresh is covered by the hook tests; isolate action/search requests here. -jest.mock('../../../hooks/useLocalVideoStatus', () => ({ useLocalVideoStatus: () => ({}) })); +const mockLocalStatus = jest.fn((): Record<string, unknown> => ({})); +jest.mock('../../../hooks/useLocalVideoStatus', () => ({ + useLocalVideoStatus: () => mockLocalStatus(), +})); import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; jest.mock('axios', () => ({ post: jest.fn(), @@ -22,6 +25,15 @@ jest.mock('../../shared/VideoModal', () => ({ }, })); +jest.mock('../../shared/AddChannelDialog', () => ({ + __esModule: true, + default: function MockAddChannelDialog(props: { open: boolean; channelName: string }) { + const React = require('react'); + if (!props.open) return null; + return React.createElement('div', { 'data-testid': 'mock-add-channel-dialog' }, props.channelName); + }, +})); + jest.mock('../../../hooks/useConfig', () => ({ useConfig: () => ({ config: { preferredResolution: '1080' } }), })); @@ -60,8 +72,18 @@ jest.mock('../../DownloadManager/ManualDownload/DownloadSettingsDialog', () => ( const axios = require('axios'); const FindVideos = require('../index').default; -function renderWithRouter(ui: React.ReactElement) { - return render(<MemoryRouter>{ui}</MemoryRouter>); +function LocationProbe() { + const location = useLocation(); + return <div data-testid="location-search">{location.search}</div>; +} + +function renderWithRouter(ui: React.ReactElement, initialEntries: string[] = ['/videos/find']) { + return render( + <MemoryRouter initialEntries={initialEntries}> + {ui} + <LocationProbe /> + </MemoryRouter> + ); } function mockMatchMedia(matches: boolean) { @@ -80,62 +102,128 @@ function mockMatchMedia(matches: boolean) { }); } +function result(overrides: Record<string, unknown> = {}) { + return { + youtubeId: 'abc12345678', title: 'Hello World', channelName: 'Chan', + channelId: null, duration: 120, thumbnailUrl: null, publishedAt: null, + viewCount: null, status: 'never_downloaded', + ...overrides, + }; +} + +async function searchFor(query: string, resultsData: Record<string, unknown>[]) { + axios.post.mockResolvedValueOnce({ data: { results: resultsData } }); + fireEvent.change(screen.getByRole('textbox'), { target: { value: query } }); + fireEvent.click(screen.getByRole('button', { name: /^search$/i })); + await screen.findByText(String(resultsData[0].title)); +} + +const rowTitles = () => + screen.getAllByRole('row', { name: /^open /i }).map((row) => row.getAttribute('aria-label')!.replace('Open ', '')); + describe('FindVideos page', () => { beforeEach(() => { jest.clearAllMocks(); mockTriggerDownloads.mockResolvedValue(true); + mockLocalStatus.mockReturnValue({}); mockMatchMedia(false); window.localStorage.clear(); + window.sessionStorage.clear(); }); test('submitting a search renders results', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [{ - youtubeId: 'abc12345678', title: 'Hello World', channelName: 'Chan', - channelId: null, duration: 120, thumbnailUrl: null, publishedAt: null, - viewCount: null, status: 'never_downloaded', - }] }, - }); + renderWithRouter(<FindVideos token="t" />); + await searchFor('hello', [result({ title: 'Hello World' })]); + expect(screen.getByText('Hello World')).toBeInTheDocument(); + }); + test('results open in a table by default on desktop; a row click opens the modal', async () => { renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'hello' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); + await searchFor('x', [result({ title: 'Row Pick' })]); - expect(await screen.findByText('Hello World')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('row', { name: /open row pick/i })); + expect(screen.getByTestId('mock-video-modal')).toHaveTextContent('Row Pick'); }); - test('clicking a result opens the VideoModal', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [{ - youtubeId: 'abc12345678', title: 'Pick me', channelName: 'Chan', - channelId: null, duration: 120, thumbnailUrl: null, publishedAt: null, - viewCount: null, status: 'never_downloaded', - }] }, - }); + test('Grid View switches to cards and the choice is remembered', async () => { + renderWithRouter(<FindVideos token="t" />); + await searchFor('x', [result({ title: 'Card Pick' })]); + fireEvent.click(screen.getByRole('button', { name: /grid view/i })); + expect(screen.queryByRole('row', { name: /open card pick/i })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /open card pick/i })); + expect(screen.getByTestId('mock-video-modal')).toHaveTextContent('Card Pick'); + expect(window.localStorage.getItem('findVideos.viewMode')).toBe('grid'); + }); + + test('puts the query in the URL once a search runs', async () => { renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - expect(await screen.findByText('Pick me')).toBeInTheDocument(); + await searchFor('hello world', [result()]); + expect(screen.getByTestId('location-search')).toHaveTextContent('?q=hello+world'); + }); - fireEvent.click(screen.getByRole('button', { name: /open pick me/i })); - expect(screen.getByTestId('mock-video-modal')).toHaveTextContent('Pick me'); + test('restores the remembered results when opened with their query in the URL', async () => { + window.sessionStorage.setItem('findVideos.lastSearch', JSON.stringify({ + query: 'minecraft', count: 50, results: [result({ title: 'Cached result' })], + })); + renderWithRouter(<FindVideos token="t" />, ['/videos/find?q=minecraft']); + + expect(await screen.findByText('Cached result')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('minecraft'); + expect(axios.post).not.toHaveBeenCalled(); + }); + + test('runs the search from the URL when nothing is remembered for it', async () => { + axios.post.mockResolvedValueOnce({ data: { results: [result({ title: 'Fresh result' })] } }); + renderWithRouter(<FindVideos token="t" />, ['/videos/find?q=terraria']); + + expect(await screen.findByText('Fresh result')).toBeInTheDocument(); + expect(axios.post).toHaveBeenCalledWith('/api/videos/search', { query: 'terraria', count: 25 }, expect.anything()); + expect(screen.getByRole('textbox')).toHaveValue('terraria'); + }); + + test('clicking a column header sorts, clicking again flips, a third click restores relevance', async () => { + renderWithRouter(<FindVideos token="t" />); + await searchFor('x', [ + result({ youtubeId: 'few00000001', title: 'Few views', viewCount: 10 }), + result({ youtubeId: 'many0000001', title: 'Many views', viewCount: 1000 }), + ]); + expect(rowTitles()).toEqual(['Few views', 'Many views']); + + const viewsHeader = () => screen.getByRole('button', { name: /^views$/i }); + fireEvent.click(viewsHeader()); + expect(rowTitles()).toEqual(['Many views', 'Few views']); + fireEvent.click(viewsHeader()); + expect(rowTitles()).toEqual(['Few views', 'Many views']); + expect(screen.getByRole('columnheader', { name: /views/i })).toHaveAttribute('aria-sort', 'ascending'); + fireEvent.click(viewsHeader()); + expect(screen.getByRole('columnheader', { name: /views/i })).not.toHaveAttribute('aria-sort'); + expect(JSON.parse(window.localStorage.getItem('findVideos.sort') ?? '')).toEqual({ key: 'relevance', direction: 'desc' }); + }); + + test('the sort menu orders cards in grid view', async () => { + window.localStorage.setItem('findVideos.viewMode', 'grid'); + renderWithRouter(<FindVideos token="t" />); + await searchFor('x', [ + result({ youtubeId: 'old00000001', title: 'Older', publishedAt: '2020-01-01T00:00:00Z' }), + result({ youtubeId: 'new00000001', title: 'Newer', publishedAt: '2025-01-01T00:00:00Z' }), + ]); + + fireEvent.click(screen.getByTestId('video-list-sort-button')); + fireEvent.click(await screen.findByRole('menuitem', { name: /published/i })); + + const cards = screen.getAllByRole('button', { name: /^open /i }); + expect(cards.map((c) => c.getAttribute('aria-label'))).toEqual(['Open Newer', 'Open Older']); }); test('applies persisted minimum duration filter and shows "X of Y" count', async () => { window.localStorage.setItem('findVideos.minDuration', '60'); - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'short000001', title: 'Tiny Short', channelName: 'C', channelId: null, duration: 30, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'long0000001', title: 'Long Video', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }); - renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); + await searchFor('q', [ + result({ youtubeId: 'long0000001', title: 'Long Video', duration: 600 }), + result({ youtubeId: 'short000001', title: 'Tiny Short', duration: 30 }), + ]); - expect(await screen.findByText('Long Video')).toBeInTheDocument(); expect(screen.queryByText('Tiny Short')).not.toBeInTheDocument(); expect(screen.getByText(/showing 1 of 2 results/i)).toBeInTheDocument(); }); @@ -144,8 +232,8 @@ describe('FindVideos page', () => { window.localStorage.setItem('findVideos.minDuration', '1200'); axios.post.mockResolvedValueOnce({ data: { results: [ - { youtubeId: 'short000001', title: 'Short A', channelName: 'C', channelId: null, duration: 30, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'short000002', title: 'Short B', channelName: 'C', channelId: null, duration: 45, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, + result({ youtubeId: 'short000001', title: 'Short A', duration: 30 }), + result({ youtubeId: 'short000002', title: 'Short B', duration: 45 }), ] }, }); @@ -160,55 +248,50 @@ describe('FindVideos page', () => { test('keeps results with null duration even when filter is active', async () => { window.localStorage.setItem('findVideos.minDuration', '60'); - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'unknownmeta', title: 'Unknown duration', channelName: 'C', channelId: null, duration: null, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'short000001', title: 'Tiny', channelName: 'C', channelId: null, duration: 10, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }); - renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - - expect(await screen.findByText('Unknown duration')).toBeInTheDocument(); + await searchFor('q', [ + result({ youtubeId: 'unknownmeta', title: 'Unknown duration', duration: null }), + result({ youtubeId: 'short000001', title: 'Tiny', duration: 10 }), + ]); expect(screen.queryByText('Tiny')).not.toBeInTheDocument(); }); test('eligible videos render a select checkbox; downloaded videos do not', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'missing0001', title: 'Missing', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'missing' }, - { youtubeId: 'downloaded1', title: 'Done', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'downloaded' }, - ] }, - }); + renderWithRouter(<FindVideos token="t" />); + await searchFor('q', [ + result({ youtubeId: 'never000001', title: 'Never', duration: 600 }), + result({ youtubeId: 'missing0001', title: 'Missing', duration: 600, status: 'missing' }), + result({ youtubeId: 'downloaded1', title: 'Done', duration: 600, status: 'downloaded' }), + ]); + + expect(screen.getByTestId('select-row-never000001')).toBeInTheDocument(); + expect(screen.getByTestId('select-row-missing0001')).toBeInTheDocument(); + expect(screen.queryByTestId('select-row-downloaded1')).not.toBeInTheDocument(); + }); + test('a video Youtarr deleted is selectable and counts as previously downloaded', async () => { renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - expect(await screen.findByText('Never')).toBeInTheDocument(); + await searchFor('q', [ + result({ youtubeId: 'deleted0001', title: 'Gone video', duration: 600, status: 'deleted', removedReason: 'user_deleted' }), + ]); - expect(screen.getByTestId('select-never000001')).toBeInTheDocument(); - expect(screen.getByTestId('select-missing0001')).toBeInTheDocument(); - expect(screen.queryByTestId('select-downloaded1')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('select-row-deleted0001')); + fireEvent.click(screen.getByTestId('video-list-selection-pill')); + fireEvent.click(await screen.findByTestId('selection-menu-download')); + + expect(await screen.findByTestId('mock-download-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-missing-count')).toHaveTextContent('1'); }); test('selecting eligible videos and confirming dialog calls triggerDownloads with URLs', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - { youtubeId: 'missing0001', title: 'Missing', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'missing' }, - ] }, - }); - renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Never'); + await searchFor('q', [ + result({ youtubeId: 'never000001', title: 'Never', duration: 600 }), + result({ youtubeId: 'missing0001', title: 'Missing', duration: 600, status: 'missing' }), + ]); - fireEvent.click(screen.getByTestId('select-never000001')); - fireEvent.click(screen.getByTestId('select-missing0001')); + fireEvent.click(screen.getByTestId('select-row-never000001')); + fireEvent.click(screen.getByTestId('select-row-missing0001')); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); @@ -229,20 +312,36 @@ describe('FindVideos page', () => { }); }); - test('failed download queue keeps selection and shows an error', async () => { - mockTriggerDownloads.mockResolvedValueOnce(false); - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, + test('the row download button queues that one video and leaves the selection alone', async () => { + renderWithRouter(<FindVideos token="t" />); + await searchFor('q', [ + result({ youtubeId: 'first000001', title: 'First', duration: 600 }), + result({ youtubeId: 'second00001', title: 'Second', duration: 600 }), + result({ youtubeId: 'third000001', title: 'Third', duration: 600 }), + ]); + + fireEvent.click(screen.getByTestId('select-row-first000001')); + fireEvent.click(screen.getByTestId('select-row-second00001')); + fireEvent.click(screen.getByRole('button', { name: 'Download Third' })); + + expect(await screen.findByTestId('mock-download-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('dialog-video-count')).toHaveTextContent('1'); + fireEvent.click(screen.getByTestId('dialog-confirm')); + + await waitFor(() => expect(mockTriggerDownloads).toHaveBeenCalledTimes(1)); + expect(mockTriggerDownloads).toHaveBeenCalledWith({ + urls: ['https://www.youtube.com/watch?v=third000001'], + overrideSettings: undefined, }); + expect(screen.getByTestId('video-list-selection-pill')).toBeInTheDocument(); + }); + test('failed download queue keeps selection and shows an error', async () => { + mockTriggerDownloads.mockResolvedValueOnce(false); renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Never'); + await searchFor('q', [result({ youtubeId: 'never000001', title: 'Never', duration: 600 })]); - fireEvent.click(screen.getByTestId('select-never000001')); + fireEvent.click(screen.getByTestId('select-row-never000001')); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); fireEvent.click(await screen.findByTestId('dialog-confirm')); @@ -252,23 +351,17 @@ describe('FindVideos page', () => { }); test('selected hidden missing videos still count as missing in the download dialog', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'missing0001', title: 'Missing', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'missing' }, - { youtubeId: 'long0000001', title: 'Long', channelName: 'C', channelId: null, duration: 1800, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }); - renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByTestId('select-missing0001'); + await searchFor('q', [ + result({ youtubeId: 'missing0001', title: 'Gone from disk', duration: 600, status: 'missing' }), + result({ youtubeId: 'long0000001', title: 'Long', duration: 1800 }), + ]); - fireEvent.click(screen.getByTestId('select-missing0001')); + fireEvent.click(screen.getByTestId('select-row-missing0001')); fireEvent.mouseDown(screen.getByText('Any length')); fireEvent.click(await screen.findByText('20+ min')); - expect(screen.queryByTestId('select-missing0001')).not.toBeInTheDocument(); + expect(screen.queryByTestId('select-row-missing0001')).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); @@ -277,20 +370,14 @@ describe('FindVideos page', () => { }); test('a selected never-downloaded video in the download archive counts as missing in the download dialog', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'archived001', title: 'Archived', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded', inArchive: true }, - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded', inArchive: false }, - ] }, - }); - renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Archived'); + await searchFor('q', [ + result({ youtubeId: 'archived001', title: 'Archived', duration: 600, inArchive: true }), + result({ youtubeId: 'never000001', title: 'Never', duration: 600, inArchive: false }), + ]); - fireEvent.click(screen.getByTestId('select-archived001')); - fireEvent.click(screen.getByTestId('select-never000001')); + fireEvent.click(screen.getByTestId('select-row-archived001')); + fireEvent.click(screen.getByTestId('select-row-never000001')); fireEvent.click(screen.getByTestId('video-list-selection-pill')); fireEvent.click(await screen.findByTestId('selection-menu-download')); @@ -298,18 +385,31 @@ describe('FindVideos page', () => { expect(screen.getByTestId('dialog-missing-count')).toHaveTextContent('1'); }); - test('mobile selection action opens the download dialog', async () => { - mockMatchMedia(true); - axios.post.mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'never000001', title: 'Never', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, + test('keeps the Subscribed marker from the search when the local status refresh answers without it', async () => { + mockLocalStatus.mockReturnValue({ + abc12345678: { youtubeId: 'abc12345678', status: 'never_downloaded', inArchive: false }, }); + renderWithRouter(<FindVideos token="t" />); + await searchFor('q', [result({ title: 'Subscribed video', channelId: 'UC1', channelSubscribed: true })]); + const row = screen.getByRole('row', { name: /open subscribed video/i }); + expect(within(row).getByText('Subscribed')).toBeInTheDocument(); + expect(within(row).getByRole('link', { name: 'Chan' })).toHaveAttribute('href', '/channel/UC1'); + }); + + test('clicking an unsubscribed channel name offers to add the channel', async () => { renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'q' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Never'); + await searchFor('q', [result({ title: 'New channel video', channelId: 'UC2', channelSubscribed: false })]); + + fireEvent.click(screen.getByRole('button', { name: 'Chan' })); + expect(screen.getByTestId('mock-add-channel-dialog')).toHaveTextContent('Chan'); + expect(screen.queryByTestId('mock-video-modal')).not.toBeInTheDocument(); + }); + + test('mobile selection action opens the download dialog', async () => { + mockMatchMedia(true); + renderWithRouter(<FindVideos token="t" />); + await searchFor('q', [result({ youtubeId: 'never000001', title: 'Never', duration: 600 })]); fireEvent.click(screen.getByTestId('select-mobile-never000001')); fireEvent.click(screen.getByTestId('selection-action-download')); @@ -318,50 +418,20 @@ describe('FindVideos page', () => { expect(screen.getByTestId('dialog-video-count')).toHaveTextContent('1'); }); - test('starting a new search clears prior selection so stale ids cannot be downloaded', async () => { - axios.post - .mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'first0000001', title: 'First', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }) - .mockResolvedValueOnce({ - data: { results: [ - { youtubeId: 'second000001', title: 'Second', channelName: 'C', channelId: null, duration: 600, thumbnailUrl: null, publishedAt: null, viewCount: null, status: 'never_downloaded' }, - ] }, - }); + test('mobile shows the sort menu even in the list layout', async () => { + mockMatchMedia(true); + renderWithRouter(<FindVideos token="t" />); + expect(screen.getByTestId('video-list-sort-button')).toBeInTheDocument(); + }); + test('starting a new search clears prior selection so stale ids cannot be downloaded', async () => { renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('First'); - fireEvent.click(screen.getByTestId('select-first0000001')); + await searchFor('one', [result({ youtubeId: 'first0000001', title: 'First', duration: 600 })]); + fireEvent.click(screen.getByTestId('select-row-first0000001')); expect(screen.getByTestId('video-list-selection-pill')).toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'two' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - await screen.findByText('Second'); + await searchFor('two', [result({ youtubeId: 'second000001', title: 'Second', duration: 600 })]); expect(screen.queryByTestId('video-list-selection-pill')).not.toBeInTheDocument(); }); - - test('toggling to Table View switches results to a table and row click opens modal', async () => { - axios.post.mockResolvedValueOnce({ - data: { results: [{ - youtubeId: 'abc12345678', title: 'Row Pick', channelName: 'Chan', - channelId: null, duration: 120, thumbnailUrl: null, publishedAt: null, - viewCount: null, status: 'never_downloaded', - }] }, - }); - - renderWithRouter(<FindVideos token="t" />); - fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x' } }); - fireEvent.click(screen.getByRole('button', { name: /^search$/i })); - expect(await screen.findByText('Row Pick')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: /table view/i })); - const row = screen.getByRole('row', { name: /open row pick/i }); - fireEvent.click(row); - expect(screen.getByTestId('mock-video-modal')).toHaveTextContent('Row Pick'); - }); }); diff --git a/client/src/components/FindVideos/__tests__/sortResults.test.ts b/client/src/components/FindVideos/__tests__/sortResults.test.ts new file mode 100644 index 000000000..c2cdb0314 --- /dev/null +++ b/client/src/components/FindVideos/__tests__/sortResults.test.ts @@ -0,0 +1,76 @@ +import { SearchResult } from '../types'; +import { DEFAULT_SORT, SORT_OPTIONS, defaultDirectionFor, sortResults } from '../sortResults'; + +function makeResult(overrides: Partial<SearchResult> & { youtubeId: string }): SearchResult { + return { + title: 'Title', + channelName: 'Channel', + channelId: null, + duration: null, + thumbnailUrl: null, + publishedAt: null, + viewCount: null, + status: 'never_downloaded', + ...overrides, + }; +} + +const ids = (results: SearchResult[]) => results.map((r) => r.youtubeId); + +describe('sortResults', () => { + const results = [ + makeResult({ youtubeId: 'b', title: 'Bravo', channelName: 'Zed', duration: 300, publishedAt: '2024-01-01T00:00:00Z', viewCount: 10 }), + makeResult({ youtubeId: 'n', title: 'Null', channelName: 'Mid' }), + makeResult({ youtubeId: 'a', title: 'alpha', channelName: 'Alpha', duration: 30, publishedAt: '2026-01-01T00:00:00Z', viewCount: 1000 }), + ]; + + test('relevance keeps the input order in both directions', () => { + expect(ids(sortResults(results, 'relevance', 'desc'))).toEqual(['b', 'n', 'a']); + expect(ids(sortResults(results, 'relevance', 'asc'))).toEqual(['b', 'n', 'a']); + }); + + test('does not mutate the input', () => { + sortResults(results, 'title', 'asc'); + expect(ids(results)).toEqual(['b', 'n', 'a']); + }); + + test('sorts titles case-insensitively', () => { + expect(ids(sortResults(results, 'title', 'asc'))).toEqual(['a', 'b', 'n']); + expect(ids(sortResults(results, 'title', 'desc'))).toEqual(['n', 'b', 'a']); + }); + + test('sorts by channel name', () => { + expect(ids(sortResults(results, 'channel', 'asc'))).toEqual(['a', 'n', 'b']); + }); + + test('puts a missing date, duration, or view count last in either direction', () => { + expect(ids(sortResults(results, 'published', 'desc'))).toEqual(['a', 'b', 'n']); + expect(ids(sortResults(results, 'published', 'asc'))).toEqual(['b', 'a', 'n']); + expect(ids(sortResults(results, 'duration', 'desc'))).toEqual(['b', 'a', 'n']); + expect(ids(sortResults(results, 'views', 'asc'))).toEqual(['b', 'a', 'n']); + }); + + test('is stable for equal keys', () => { + const tied = [ + makeResult({ youtubeId: 'one', viewCount: 5 }), + makeResult({ youtubeId: 'two', viewCount: 5 }), + makeResult({ youtubeId: 'three', viewCount: 5 }), + ]; + expect(ids(sortResults(tied, 'views', 'desc'))).toEqual(['one', 'two', 'three']); + }); +}); + +describe('sort defaults', () => { + test('text columns start ascending, numeric and date columns descending', () => { + expect(defaultDirectionFor('title')).toBe('asc'); + expect(defaultDirectionFor('channel')).toBe('asc'); + expect(defaultDirectionFor('published')).toBe('desc'); + expect(defaultDirectionFor('duration')).toBe('desc'); + expect(defaultDirectionFor('views')).toBe('desc'); + }); + + test('relevance is the default and the only undirected option', () => { + expect(DEFAULT_SORT).toEqual({ key: 'relevance', direction: 'desc' }); + expect(SORT_OPTIONS.filter((o) => o.undirected).map((o) => o.key)).toEqual(['relevance']); + }); +}); diff --git a/client/src/components/FindVideos/components/ResultCard.tsx b/client/src/components/FindVideos/components/ResultCard.tsx index 3b982286f..f19a1198b 100644 --- a/client/src/components/FindVideos/components/ResultCard.tsx +++ b/client/src/components/FindVideos/components/ResultCard.tsx @@ -1,32 +1,41 @@ import React from 'react'; import { Box, Card, CardActionArea, Checkbox, Typography, Chip } from '../../ui'; -import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; -import { - getStatusColor, - getStatusIcon, - getStatusLabel, - getStatusChipVariant, - getStatusChipStyle, - VideoStatus, -} from '../../../utils/videoStatus'; -import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; import { formatDurationClock } from '../../../utils'; import { isSelectableForDownload, ResultSelection, SearchResult } from '../types'; +import { + AddChannelHandler, + DownloadButton, + PublishedDate, + ResultChannel, + ResultStatusChip, + ShortBadge, + ViewCount, +} from './resultParts'; interface ResultCardProps { result: SearchResult; onClick: () => void; selection?: ResultSelection; + onAddChannel?: AddChannelHandler; + onDownloadOne?: (result: SearchResult) => void; } -export default function ResultCard({ result, onClick, selection }: ResultCardProps) { - const status: VideoStatus = result.status; +const THUMB_OVERLAY_STYLE: React.CSSProperties = { + position: 'absolute', + top: 4, + padding: 4, + borderRadius: 'var(--radius-ui)', + backgroundColor: 'var(--media-overlay-background)', + zIndex: 3, +}; + +export default function ResultCard({ result, onClick, selection, onAddChannel, onDownloadOne }: ResultCardProps) { const durationLabel = formatDurationClock(result.duration); const selectable = Boolean(selection) && isSelectableForDownload(result.status); const checked = selectable && selection!.isChecked(result.youtubeId); return ( - <Card className="cursor-pointer overflow-hidden hover:shadow-md transition-shadow"> + <Card className="relative cursor-pointer overflow-hidden hover:shadow-md transition-shadow"> <CardActionArea onClick={onClick} aria-label={`Open ${result.title}`} @@ -43,15 +52,7 @@ export default function ResultCard({ result, onClick, selection }: ResultCardPro {selectable && ( <Box onClick={(e) => e.stopPropagation()} - style={{ - position: 'absolute', - top: 4, - left: 4, - padding: 4, - borderRadius: 'var(--radius-ui)', - backgroundColor: 'var(--media-overlay-background)', - zIndex: 3, - }} + style={{ ...THUMB_OVERLAY_STYLE, left: 4 }} > <Checkbox checked={checked} @@ -81,33 +82,26 @@ export default function ResultCard({ result, onClick, selection }: ResultCardPro </Box> <Box className="p-2 flex flex-col gap-2"> <Typography variant="body2" className="font-semibold line-clamp-2">{result.title}</Typography> - <Typography variant="caption" className="text-muted-foreground">{result.channelName}</Typography> - <Box className="flex items-center gap-2 flex-wrap"> - {result.publishedAt && ( - <Typography - variant="caption" - className="text-muted-foreground inline-flex items-center gap-1" - > - <CalendarTodayIcon size={12} /> - {new Date(result.publishedAt).toLocaleDateString()} - </Typography> - )} - <Chip - icon={getStatusIcon(status)} - label={getStatusLabel(status)} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ - flex: '0 0 auto', - minWidth: 'fit-content', - ...SHARED_THEMED_CHIP_SMALL_STYLE, - ...getStatusChipStyle(status), - }} - /> + <ResultChannel + result={result} + variant="caption" + className="text-muted-foreground truncate" + onAddChannel={onAddChannel} + /> + <Box className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground"> + <PublishedDate publishedAt={result.publishedAt} /> + <ViewCount viewCount={result.viewCount} /> + <ShortBadge duration={result.duration} /> + <ResultStatusChip status={result.status} /> </Box> </Box> </CardActionArea> + {/* Outside the action area so the button is not nested in the card's own button role. */} + {onDownloadOne && isSelectableForDownload(result.status) && ( + <Box style={{ ...THUMB_OVERLAY_STYLE, right: 4 }}> + <DownloadButton result={result} onDownload={onDownloadOne} className="text-[var(--media-overlay-foreground)]" /> + </Box> + )} </Card> ); } diff --git a/client/src/components/FindVideos/components/ResultsGrid.tsx b/client/src/components/FindVideos/components/ResultsGrid.tsx index cb32ca4fa..15593eed6 100644 --- a/client/src/components/FindVideos/components/ResultsGrid.tsx +++ b/client/src/components/FindVideos/components/ResultsGrid.tsx @@ -1,10 +1,16 @@ import React from 'react'; import { Box, Typography, Alert, Button, Skeleton } from '../../ui'; import { useMediaQuery } from '../../../hooks/useMediaQuery'; -import { ResultSelection, SearchResult, PageSize, ViewMode } from '../types'; +import { ResultSort, SortKey } from '../sortResults'; +import { ResultSelection, SearchResult, PageSize, ViewMode, THUMB_WIDTH, THUMB_HEIGHT } from '../types'; import ResultCard from './ResultCard'; import ResultsTable from './ResultsTable'; import ResultsListMobile from './ResultsListMobile'; +import { AddChannelHandler } from './resultParts'; + +// Placeholder rows beyond the first screen are never seen; the grid keeps +// its one-per-result shape because cards are short enough to fill it. +const ROW_SKELETON_MAX = 10; interface ResultsGridProps { results: SearchResult[]; @@ -17,25 +23,61 @@ interface ResultsGridProps { onResultClick: (result: SearchResult) => void; onRetry: () => void; selection?: ResultSelection; + sort?: ResultSort; + onSortChange?: (key: SortKey) => void; + onAddChannel?: AddChannelHandler; + onDownloadOne?: (result: SearchResult) => void; +} + +function GridSkeleton({ count }: { count: number }) { + return ( + <Box className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> + {Array.from({ length: count }).map((_, i) => ( + <Box key={i} className="space-y-2"> + <Skeleton className="aspect-video w-full" /> + <Skeleton className="h-4 w-3/4" /> + <Skeleton className="h-3 w-1/2" /> + </Box> + ))} + </Box> + ); +} + +function RowSkeleton({ count, wide, testId }: { count: number; wide: boolean; testId: string }) { + return ( + <Box data-testid={testId} className="flex flex-col"> + {Array.from({ length: count }).map((_, i) => ( + <Box key={i} className="flex items-center gap-3 py-2 px-1 border-b border-border"> + <Skeleton className="shrink-0 rounded-[var(--radius-thumb)]" style={{ width: THUMB_WIDTH, height: THUMB_HEIGHT }} /> + <Box className="flex-1 min-w-0 space-y-2"> + <Skeleton className="h-4 w-3/4" /> + <Skeleton className="h-3 w-1/3" /> + </Box> + {wide && ( + <> + <Skeleton className="h-3 w-20" /> + <Skeleton className="h-3 w-12" /> + <Skeleton className="h-3 w-12" /> + </> + )} + </Box> + ))} + </Box> + ); } export default function ResultsGrid({ - results, loading, error, hasSearched, lastQuery, pageSize, viewMode, onResultClick, onRetry, selection, + results, loading, error, hasSearched, lastQuery, pageSize, viewMode, + onResultClick, onRetry, selection, sort, onSortChange, onAddChannel, onDownloadOne, }: ResultsGridProps) { const isMobile = useMediaQuery('(max-width: 767px)'); if (loading) { - return ( - <Box className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"> - {Array.from({ length: pageSize }).map((_, i) => ( - <Box key={i} className="space-y-2"> - <Skeleton className="aspect-video w-full" /> - <Skeleton className="h-4 w-3/4" /> - <Skeleton className="h-3 w-1/2" /> - </Box> - ))} - </Box> - ); + if (viewMode === 'grid') return <GridSkeleton count={pageSize} />; + const rows = Math.min(pageSize, ROW_SKELETON_MAX); + return isMobile + ? <RowSkeleton count={rows} wide={false} testId="results-skeleton-list" /> + : <RowSkeleton count={rows} wide testId="results-skeleton-table" />; } if (error) { @@ -67,8 +109,26 @@ export default function ResultsGrid({ if (viewMode === 'table') { return isMobile - ? <ResultsListMobile results={results} onResultClick={onResultClick} selection={selection} /> - : <ResultsTable results={results} onResultClick={onResultClick} selection={selection} />; + ? ( + <ResultsListMobile + results={results} + onResultClick={onResultClick} + selection={selection} + onAddChannel={onAddChannel} + onDownloadOne={onDownloadOne} + /> + ) + : ( + <ResultsTable + results={results} + onResultClick={onResultClick} + selection={selection} + sort={sort} + onSortChange={onSortChange} + onAddChannel={onAddChannel} + onDownloadOne={onDownloadOne} + /> + ); } return ( @@ -79,6 +139,8 @@ export default function ResultsGrid({ result={r} onClick={() => onResultClick(r)} selection={selection} + onAddChannel={onAddChannel} + onDownloadOne={onDownloadOne} /> ))} </Box> diff --git a/client/src/components/FindVideos/components/ResultsListMobile.tsx b/client/src/components/FindVideos/components/ResultsListMobile.tsx index f1a234f9b..76fbaf4a0 100644 --- a/client/src/components/FindVideos/components/ResultsListMobile.tsx +++ b/client/src/components/FindVideos/components/ResultsListMobile.tsx @@ -1,15 +1,5 @@ import React from 'react'; import { Checkbox, Typography, Chip } from '../../ui'; -import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; -import { - getStatusColor, - getStatusIcon, - getStatusLabel, - getStatusChipVariant, - getStatusChipStyle, - VideoStatus, -} from '../../../utils/videoStatus'; -import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; import { formatDurationClock } from '../../../utils'; import { isSelectableForDownload, @@ -18,44 +8,33 @@ import { THUMB_WIDTH, THUMB_HEIGHT, } from '../types'; - -function StatusChip({ status }: { status: VideoStatus }) { - return ( - <Chip - icon={getStatusIcon(status)} - label={getStatusLabel(status)} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ - ...SHARED_THEMED_CHIP_SMALL_STYLE, - ...getStatusChipStyle(status), - }} - /> - ); -} +import { + AddChannelHandler, + DownloadButton, + PublishedDate, + ResultChannel, + ResultStatusChip, + ShortBadge, + ViewCount, +} from './resultParts'; interface ResultsListMobileProps { results: SearchResult[]; onResultClick: (result: SearchResult) => void; selection?: ResultSelection; + onAddChannel?: AddChannelHandler; + onDownloadOne?: (result: SearchResult) => void; } -export default function ResultsListMobile({ results, onResultClick, selection }: ResultsListMobileProps) { +export default function ResultsListMobile({ + results, onResultClick, selection, onAddChannel, onDownloadOne, +}: ResultsListMobileProps) { return ( <div className="flex flex-col"> {results.map((result) => { - const status: VideoStatus = result.status; const durationLabel = formatDurationClock(result.duration); const selectable = Boolean(selection) && isSelectableForDownload(result.status); const checked = selectable && selection!.isChecked(result.youtubeId); - const published = result.publishedAt - ? new Date(result.publishedAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: '2-digit', - }) - : null; return ( <div key={result.youtubeId} @@ -122,25 +101,24 @@ export default function ResultsListMobile({ results, onResultClick, selection }: > {result.title} </Typography> - <Typography + <ResultChannel + result={result} variant="caption" className="text-muted-foreground block truncate text-xs" - > - {result.channelName} - </Typography> - <div className="flex items-center gap-2 flex-wrap mt-0.5"> - {published && ( - <Typography - variant="caption" - className="text-muted-foreground inline-flex items-center gap-0.5 text-[0.7rem]" - > - <CalendarTodayIcon size={11} /> - {published} - </Typography> - )} - <StatusChip status={status} /> + onAddChannel={onAddChannel} + /> + <div className="flex items-center gap-2 flex-wrap mt-0.5 text-[0.7rem] text-muted-foreground"> + <PublishedDate publishedAt={result.publishedAt} iconSize={11} /> + <ViewCount viewCount={result.viewCount} iconSize={11} /> + <ShortBadge duration={result.duration} /> + <ResultStatusChip status={result.status} /> </div> </div> + {onDownloadOne && ( + <div className="shrink-0 self-center"> + <DownloadButton result={result} onDownload={onDownloadOne} /> + </div> + )} </div> ); })} diff --git a/client/src/components/FindVideos/components/ResultsTable.tsx b/client/src/components/FindVideos/components/ResultsTable.tsx index 010abd5ed..a63a31783 100644 --- a/client/src/components/FindVideos/components/ResultsTable.tsx +++ b/client/src/components/FindVideos/components/ResultsTable.tsx @@ -2,25 +2,16 @@ import React from 'react'; import { Checkbox, Typography, - Chip, TableContainer, Table, TableHead, TableBody, TableRow, TableCell, + TableSortLabel, } from '../../ui'; -import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; -import { - getStatusColor, - getStatusIcon, - getStatusLabel, - getStatusChipVariant, - getStatusChipStyle, - VideoStatus, -} from '../../../utils/videoStatus'; -import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; import { formatDurationClock } from '../../../utils'; +import { defaultDirectionFor, ResultSort, SortKey } from '../sortResults'; import { isSelectableForDownload, ResultSelection, @@ -28,47 +19,79 @@ import { THUMB_WIDTH, THUMB_HEIGHT, } from '../types'; +import { + AddChannelHandler, + DownloadButton, + PublishedDate, + ResultChannel, + ResultStatusChip, + ShortBadge, + ViewCount, +} from './resultParts'; -function StatusChip({ status }: { status: VideoStatus }) { - return ( - <Chip - icon={getStatusIcon(status)} - label={getStatusLabel(status)} - size="small" - color={getStatusColor(status)} - variant={getStatusChipVariant(status)} - style={{ - ...SHARED_THEMED_CHIP_SMALL_STYLE, - ...getStatusChipStyle(status), - }} - /> - ); -} +type ColumnKey = Exclude<SortKey, 'relevance'>; interface ResultsTableProps { results: SearchResult[]; onResultClick: (result: SearchResult) => void; selection?: ResultSelection; + sort?: ResultSort; + onSortChange?: (key: SortKey) => void; + onAddChannel?: AddChannelHandler; + onDownloadOne?: (result: SearchResult) => void; +} + +interface SortableHeaderProps { + column: ColumnKey; + label: string; + className: string; + sort?: ResultSort; + onSortChange?: (key: SortKey) => void; +} + +function SortableHeader({ column, label, className, sort, onSortChange }: SortableHeaderProps) { + const active = sort?.key === column; + const direction = active ? sort.direction : defaultDirectionFor(column); + return ( + <TableCell + component="th" + className={className} + aria-sort={active ? (direction === 'asc' ? 'ascending' : 'descending') : undefined} + > + {onSortChange ? ( + <TableSortLabel active={active} direction={direction} onClick={() => onSortChange(column)}> + {label} + </TableSortLabel> + ) : ( + label + )} + </TableCell> + ); } -export default function ResultsTable({ results, onResultClick, selection }: ResultsTableProps) { +export default function ResultsTable({ + results, onResultClick, selection, sort, onSortChange, onAddChannel, onDownloadOne, +}: ResultsTableProps) { + const headerProps = { sort, onSortChange }; return ( <TableContainer> - <Table size="small" className="table-fixed"> + {/* Minimum widths keep the title readable at tablet widths; the container scrolls instead. */} + <Table size="small" className="table-auto min-w-[760px]"> <TableHead> <TableRow> {selection && <TableCell component="th" className="w-[40px]" aria-label="Select" />} <TableCell component="th" className="w-[140px]">Thumbnail</TableCell> - <TableCell component="th" className="w-[40%]">Title</TableCell> - <TableCell component="th" className="w-[22%]">Channel</TableCell> - <TableCell component="th" className="w-[110px] whitespace-nowrap">Published</TableCell> - <TableCell component="th" className="w-[90px] whitespace-nowrap">Duration</TableCell> - <TableCell component="th" className="w-[160px] whitespace-nowrap">Status</TableCell> + <SortableHeader column="title" label="Title" className="min-w-[240px] w-[40%]" {...headerProps} /> + <SortableHeader column="channel" label="Channel" className="min-w-[160px] w-[20%]" {...headerProps} /> + <SortableHeader column="published" label="Published" className="w-[120px] whitespace-nowrap" {...headerProps} /> + <SortableHeader column="duration" label="Duration" className="w-[110px] whitespace-nowrap" {...headerProps} /> + <SortableHeader column="views" label="Views" className="w-[80px] whitespace-nowrap" {...headerProps} /> + <TableCell component="th" className="w-[150px] whitespace-nowrap">Status</TableCell> + {onDownloadOne && <TableCell component="th" className="w-[48px]" aria-label="Actions" />} </TableRow> </TableHead> <TableBody> {results.map((result) => { - const status: VideoStatus = result.status; const selectable = Boolean(selection) && isSelectableForDownload(result.status); const checked = selectable && selection!.isChecked(result.youtubeId); return ( @@ -115,26 +138,33 @@ export default function ResultsTable({ results, onResultClick, selection }: Resu </Typography> </TableCell> <TableCell> - <Typography variant="body2" className="text-muted-foreground"> - {result.channelName} - </Typography> + <ResultChannel + result={result} + variant="body2" + className="text-muted-foreground truncate" + onAddChannel={onAddChannel} + /> </TableCell> <TableCell className="whitespace-nowrap"> - {result.publishedAt ? ( - <span className="inline-flex items-center gap-1"> - <CalendarTodayIcon size={12} /> - {new Date(result.publishedAt).toLocaleDateString()} - </span> - ) : ( - 'N/A' - )} + <PublishedDate publishedAt={result.publishedAt} fallback="N/A" /> </TableCell> <TableCell className="whitespace-nowrap"> - {formatDurationClock(result.duration) || '-'} + <span className="inline-flex items-center gap-1.5"> + {formatDurationClock(result.duration) || '-'} + <ShortBadge duration={result.duration} /> + </span> + </TableCell> + <TableCell className="whitespace-nowrap"> + {result.viewCount === null ? '-' : <ViewCount viewCount={result.viewCount} icon={false} />} </TableCell> <TableCell> - <StatusChip status={status} /> + <ResultStatusChip status={result.status} /> </TableCell> + {onDownloadOne && ( + <TableCell> + <DownloadButton result={result} onDownload={onDownloadOne} /> + </TableCell> + )} </TableRow> ); })} diff --git a/client/src/components/FindVideos/components/SearchBar.tsx b/client/src/components/FindVideos/components/SearchBar.tsx index fdf21f513..a2a7e4677 100644 --- a/client/src/components/FindVideos/components/SearchBar.tsx +++ b/client/src/components/FindVideos/components/SearchBar.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Box, Button, TextField, Select, MenuItem } from '../../ui'; import { Loader2, LayoutGrid as ViewModuleIcon, Rows as TableChartIcon } from '../../../lib/icons'; +import VideoListSortButton from '../../shared/VideoList/VideoListSortButton'; +import { SortConfig } from '../../shared/VideoList/types'; import { PAGE_SIZES, PageSize, @@ -16,6 +18,7 @@ interface SearchBarProps { minDuration: MinDuration; loading: boolean; viewMode: ViewMode; + sort?: SortConfig; onQueryChange: (q: string) => void; onPageSizeChange: (s: PageSize) => void; onMinDurationChange: (d: MinDuration) => void; @@ -24,15 +27,46 @@ interface SearchBarProps { onCancel: () => void; } +// Phones: the field and Search share the first row, the two selects the second, +// view toggle and sort the third. Wider screens: one wrapping row, with Search +// after the selects. Flex order does the rearranging so nothing renders twice. +const HALF_ROW = 'basis-[calc(50%-0.25rem)] sm:basis-auto'; + +function ViewToggle({ viewMode, onViewModeChange }: Pick<SearchBarProps, 'viewMode' | 'onViewModeChange'>) { + return ( + <Box className="flex shrink-0 self-center rounded-[var(--radius-ui)] overflow-hidden border border-border"> + {(['table', 'grid'] as const).map((mode) => { + const isActive = viewMode === mode; + const Icon = mode === 'table' ? TableChartIcon : ViewModuleIcon; + const label = mode === 'table' ? 'Table View' : 'Grid View'; + return ( + <Button + key={mode} + variant={isActive ? 'contained' : 'text'} + size="sm" + onClick={() => onViewModeChange(mode)} + title={label} + aria-label={label} + aria-pressed={isActive} + className="rounded-none" + > + <Icon size={16} /> + </Button> + ); + })} + </Box> + ); +} + export default function SearchBar({ - query, pageSize, minDuration, loading, viewMode, + query, pageSize, minDuration, loading, viewMode, sort, onQueryChange, onPageSizeChange, onMinDurationChange, onViewModeChange, onSearch, onCancel, }: SearchBarProps) { const trimmed = query.trim(); const canSearch = !loading && trimmed.length > 0; return ( - <Box className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center"> + <Box className="flex flex-wrap items-center gap-2 sm:gap-3"> <TextField aria-label="Search YouTube" placeholder="Search YouTube (e.g. Minecraft)" @@ -42,56 +76,47 @@ export default function SearchBar({ if (e.key === 'Enter' && canSearch) onSearch(); }} disabled={loading} - className="w-full sm:w-[32rem]" + className="order-1 flex-1 min-w-0 sm:flex-none sm:w-[32rem]" /> - <Select - value={String(pageSize)} - onValueChange={(v) => onPageSizeChange(Number(v) as PageSize)} - disabled={loading} - aria-label="Number of results" - > - {PAGE_SIZES.map((n) => ( - <MenuItem key={n} value={String(n)}>{n} results</MenuItem> - ))} - </Select> - <Select - value={String(minDuration)} - onValueChange={(v) => onMinDurationChange(Number(v) as MinDuration)} - disabled={loading} - aria-label="Minimum duration" - > - {MIN_DURATIONS.map((d) => ( - <MenuItem key={d} value={String(d)}>{MIN_DURATION_LABELS[d]}</MenuItem> - ))} - </Select> - {loading ? ( - <Box className="flex items-center gap-2"> - <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> - <Button variant="outlined" onClick={onCancel}>Cancel</Button> - </Box> - ) : ( - <Button variant="contained" onClick={onSearch} disabled={!canSearch}>Search</Button> - )} - <Box className="flex shrink-0 self-center rounded-[var(--radius-ui)] overflow-hidden border border-border"> - {(['table', 'grid'] as const).map((mode) => { - const isActive = viewMode === mode; - const Icon = mode === 'table' ? TableChartIcon : ViewModuleIcon; - const label = mode === 'table' ? 'Table View' : 'Grid View'; - return ( - <Button - key={mode} - variant={isActive ? 'contained' : 'text'} - size="sm" - onClick={() => onViewModeChange(mode)} - title={label} - aria-label={label} - aria-pressed={isActive} - className="rounded-none" - > - <Icon size={16} /> - </Button> - ); - })} + <Box className="order-2 sm:order-4 shrink-0"> + {loading ? ( + <Box className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> + <Button variant="outlined" onClick={onCancel}>Cancel</Button> + </Box> + ) : ( + <Button variant="contained" onClick={onSearch} disabled={!canSearch}>Search</Button> + )} + </Box> + <Box className={`order-3 sm:order-2 ${HALF_ROW}`}> + <Select + value={String(pageSize)} + onValueChange={(v) => onPageSizeChange(Number(v) as PageSize)} + disabled={loading} + aria-label="Number of results" + fullWidth + > + {PAGE_SIZES.map((n) => ( + <MenuItem key={n} value={String(n)}>{n} results</MenuItem> + ))} + </Select> + </Box> + <Box className={`order-4 sm:order-3 ${HALF_ROW}`}> + <Select + value={String(minDuration)} + onValueChange={(v) => onMinDurationChange(Number(v) as MinDuration)} + disabled={loading} + aria-label="Minimum duration" + fullWidth + > + {MIN_DURATIONS.map((d) => ( + <MenuItem key={d} value={String(d)}>{MIN_DURATION_LABELS[d]}</MenuItem> + ))} + </Select> + </Box> + <Box className="order-5 flex items-center gap-2"> + <ViewToggle viewMode={viewMode} onViewModeChange={onViewModeChange} /> + {sort && <VideoListSortButton sort={sort} />} </Box> </Box> ); diff --git a/client/src/components/FindVideos/components/__tests__/ResultCard.test.tsx b/client/src/components/FindVideos/components/__tests__/ResultCard.test.tsx new file mode 100644 index 000000000..02f205970 --- /dev/null +++ b/client/src/components/FindVideos/components/__tests__/ResultCard.test.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import ResultCard from '../ResultCard'; +import { SearchResult } from '../../types'; + +function makeResult(overrides: Partial<SearchResult> = {}): SearchResult { + return { + youtubeId: 'abc12345678', + title: 'Default Title', + channelName: 'Default Channel', + channelId: null, + duration: null, + thumbnailUrl: null, + publishedAt: null, + viewCount: null, + status: 'never_downloaded', + ...overrides, + }; +} + +function renderCard(result: SearchResult, props: Partial<React.ComponentProps<typeof ResultCard>> = {}) { + return render( + <MemoryRouter> + <ResultCard result={result} onClick={() => {}} {...props} /> + </MemoryRouter> + ); +} + +describe('ResultCard', () => { + test('clicking the card opens it', () => { + const onClick = jest.fn(); + renderCard(makeResult({ title: 'Pick me' }), { onClick }); + fireEvent.click(screen.getByRole('button', { name: /open pick me/i })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + test('shows the relative date, view count, and Short badge', () => { + const publishedAt = new Date(Date.now() - 400 * 86_400_000).toISOString(); + renderCard(makeResult({ publishedAt, viewCount: 850, duration: 59 })); + expect(screen.getByText('1 year ago')).toBeInTheDocument(); + expect(screen.getByText('850')).toBeInTheDocument(); + expect(screen.getByText('Short')).toBeInTheDocument(); + }); + + test('shows no status chip when never downloaded, and one when downloaded', () => { + const { rerender } = renderCard(makeResult()); + expect(screen.queryByText('Not Downloaded')).not.toBeInTheDocument(); + rerender( + <MemoryRouter> + <ResultCard result={makeResult({ status: 'downloaded' })} onClick={() => {}} /> + </MemoryRouter> + ); + expect(screen.getByText('Downloaded')).toBeInTheDocument(); + }); + + test('the download button queues the video without opening the card', () => { + const onClick = jest.fn(); + const onDownloadOne = jest.fn(); + const result = makeResult({ title: 'Alpha' }); + renderCard(result, { onClick, onDownloadOne }); + fireEvent.click(screen.getByRole('button', { name: 'Download Alpha' })); + expect(onDownloadOne).toHaveBeenCalledWith(result); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/FindVideos/components/__tests__/ResultsGrid.test.tsx b/client/src/components/FindVideos/components/__tests__/ResultsGrid.test.tsx index 1a06e4bfd..7c5ba396b 100644 --- a/client/src/components/FindVideos/components/__tests__/ResultsGrid.test.tsx +++ b/client/src/components/FindVideos/components/__tests__/ResultsGrid.test.tsx @@ -85,6 +85,12 @@ describe('ResultsGrid', () => { expect(screen.getAllByTestId('skeleton')).toHaveLength(30); }); + test('loading in table mode on desktop renders a table-shaped skeleton', () => { + render(<ResultsGrid {...baseProps} loading viewMode="table" pageSize={10} />); + expect(screen.getByTestId('results-skeleton-table')).toBeInTheDocument(); + expect(screen.queryByTestId('results-skeleton-list')).not.toBeInTheDocument(); + }); + test('error: renders message and Try again triggers onRetry', () => { const onRetry = jest.fn(); render(<ResultsGrid {...baseProps} error="Search failed" onRetry={onRetry} />); @@ -172,6 +178,12 @@ describe('ResultsGrid', () => { }); }); + test('loading in table mode renders a list-shaped skeleton', () => { + render(<ResultsGrid {...baseProps} loading viewMode="table" pageSize={10} />); + expect(screen.getByTestId('results-skeleton-list')).toBeInTheDocument(); + expect(screen.queryByTestId('results-skeleton-table')).not.toBeInTheDocument(); + }); + test('viewMode=table renders ResultsListMobile instead of ResultsTable', () => { const onResultClick = jest.fn(); const results = [ diff --git a/client/src/components/FindVideos/components/__tests__/ResultsListMobile.test.tsx b/client/src/components/FindVideos/components/__tests__/ResultsListMobile.test.tsx index 223586341..f2c2ab262 100644 --- a/client/src/components/FindVideos/components/__tests__/ResultsListMobile.test.tsx +++ b/client/src/components/FindVideos/components/__tests__/ResultsListMobile.test.tsx @@ -75,4 +75,29 @@ describe('ResultsListMobile', () => { const card = screen.getByRole('button', { name: /open alpha/i }); expect(within(card).queryByText('-')).not.toBeInTheDocument(); }); + + test('shows the relative date, view count, and Short badge in the meta line', () => { + const publishedAt = new Date(Date.now() - 14 * 86_400_000).toISOString(); + const results = [makeResult({ youtubeId: 'a', title: 'Alpha', publishedAt, viewCount: 2_000_000, duration: 30 })]; + render(<ResultsListMobile results={results} onResultClick={() => {}} />); + const card = screen.getByRole('button', { name: /open alpha/i }); + expect(within(card).getByText('2 weeks ago')).toBeInTheDocument(); + expect(within(card).getByText('2M')).toBeInTheDocument(); + expect(within(card).getByText('Short')).toBeInTheDocument(); + }); + + test('shows no Not Downloaded chip', () => { + render(<ResultsListMobile results={[makeResult()]} onResultClick={() => {}} />); + expect(screen.queryByText('Not Downloaded')).not.toBeInTheDocument(); + }); + + test('the download button queues the video without opening it', () => { + const onDownloadOne = jest.fn(); + const onResultClick = jest.fn(); + const results = [makeResult({ youtubeId: 'a', title: 'Alpha' })]; + render(<ResultsListMobile results={results} onResultClick={onResultClick} onDownloadOne={onDownloadOne} />); + fireEvent.click(screen.getByRole('button', { name: 'Download Alpha' })); + expect(onDownloadOne).toHaveBeenCalledWith(results[0]); + expect(onResultClick).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/FindVideos/components/__tests__/ResultsTable.test.tsx b/client/src/components/FindVideos/components/__tests__/ResultsTable.test.tsx index cd199e339..848754195 100644 --- a/client/src/components/FindVideos/components/__tests__/ResultsTable.test.tsx +++ b/client/src/components/FindVideos/components/__tests__/ResultsTable.test.tsx @@ -43,12 +43,77 @@ describe('ResultsTable', () => { expect(onResultClick).toHaveBeenCalledWith(results[0]); }); - test('shows N/A when publishedAt is null and - when duration is null', () => { + test('shows N/A when publishedAt is null and - when duration and views are null', () => { const results = [makeResult({ youtubeId: 'a', title: 'Alpha' })]; render(<ResultsTable results={results} onResultClick={() => {}} />); const row = screen.getByRole('row', { name: /open alpha/i }); expect(within(row).getByText('N/A')).toBeInTheDocument(); - expect(within(row).getByText('-')).toBeInTheDocument(); + expect(within(row).getAllByText('-')).toHaveLength(2); + }); + + test('shows the published date relative to today and the view count abbreviated', () => { + const publishedAt = new Date(Date.now() - 3 * 86_400_000).toISOString(); + const results = [makeResult({ youtubeId: 'a', title: 'Alpha', publishedAt, viewCount: 12_500 })]; + render(<ResultsTable results={results} onResultClick={() => {}} />); + const row = screen.getByRole('row', { name: /open alpha/i }); + expect(within(row).getByText('3 days ago')).toBeInTheDocument(); + expect(within(row).getByText('12.5K')).toBeInTheDocument(); + }); + + test('clicking a column header asks to sort by that column', () => { + const onSortChange = jest.fn(); + render(<ResultsTable results={[makeResult()]} onResultClick={() => {}} onSortChange={onSortChange} />); + fireEvent.click(screen.getByRole('button', { name: /^views$/i })); + expect(onSortChange).toHaveBeenCalledWith('views'); + }); + + test('marks the active column with its direction', () => { + render( + <ResultsTable + results={[makeResult()]} + onResultClick={() => {}} + sort={{ key: 'published', direction: 'asc' }} + onSortChange={() => {}} + /> + ); + expect(screen.getByRole('columnheader', { name: /published/i })).toHaveAttribute('aria-sort', 'ascending'); + expect(screen.getByRole('columnheader', { name: /views/i })).not.toHaveAttribute('aria-sort'); + }); + + test('shows no status chip for a never-downloaded row but does for a downloaded one', () => { + const results = [ + makeResult({ youtubeId: 'new', title: 'New', status: 'never_downloaded' }), + makeResult({ youtubeId: 'have', title: 'Have', status: 'downloaded' }), + ]; + render(<ResultsTable results={results} onResultClick={() => {}} />); + expect(screen.queryByText('Not Downloaded')).not.toBeInTheDocument(); + expect(within(screen.getByRole('row', { name: /open have/i })).getByText('Downloaded')).toBeInTheDocument(); + }); + + test('the row download button queues that video without opening the row', () => { + const onDownloadOne = jest.fn(); + const onResultClick = jest.fn(); + const results = [makeResult({ youtubeId: 'a', title: 'Alpha' })]; + render(<ResultsTable results={results} onResultClick={onResultClick} onDownloadOne={onDownloadOne} />); + fireEvent.click(screen.getByRole('button', { name: 'Download Alpha' })); + expect(onDownloadOne).toHaveBeenCalledWith(results[0]); + expect(onResultClick).not.toHaveBeenCalled(); + }); + + test('offers no download button for a video already downloaded', () => { + render( + <ResultsTable + results={[makeResult({ title: 'Have', status: 'downloaded' })]} + onResultClick={() => {}} + onDownloadOne={jest.fn()} + /> + ); + expect(screen.queryByRole('button', { name: 'Download Have' })).not.toBeInTheDocument(); + }); + + test('marks a Short', () => { + render(<ResultsTable results={[makeResult({ title: 'Tiny', duration: 45 })]} onResultClick={() => {}} />); + expect(within(screen.getByRole('row', { name: /open tiny/i })).getByText('Short')).toBeInTheDocument(); }); test('formats duration as m:ss when under an hour and h:mm:ss otherwise', () => { diff --git a/client/src/components/FindVideos/components/__tests__/SearchBar.test.tsx b/client/src/components/FindVideos/components/__tests__/SearchBar.test.tsx index 0c5add9ff..5e9e1f020 100644 --- a/client/src/components/FindVideos/components/__tests__/SearchBar.test.tsx +++ b/client/src/components/FindVideos/components/__tests__/SearchBar.test.tsx @@ -60,4 +60,17 @@ describe('SearchBar', () => { fireEvent.click(screen.getByRole('button', { name: /table view/i })); expect(onViewModeChange).toHaveBeenCalledWith('table'); }); + + test('renders the sort menu only when a sort is given', () => { + const sort = { + options: [{ key: 'relevance', label: 'Relevance', undirected: true }], + activeKey: 'relevance', + direction: 'desc' as const, + onChange: jest.fn(), + }; + const { rerender } = render(<SearchBar {...baseProps} sort={sort} />); + expect(screen.getByTestId('video-list-sort-button')).toHaveTextContent('Sort: Relevance'); + rerender(<SearchBar {...baseProps} />); + expect(screen.queryByTestId('video-list-sort-button')).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/FindVideos/components/__tests__/resultParts.test.tsx b/client/src/components/FindVideos/components/__tests__/resultParts.test.tsx new file mode 100644 index 000000000..3f175f46d --- /dev/null +++ b/client/src/components/FindVideos/components/__tests__/resultParts.test.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { + DownloadButton, + PublishedDate, + ResultChannel, + ResultStatusChip, + ShortBadge, + ViewCount, +} from '../resultParts'; +import { SearchResult } from '../../types'; + +function makeResult(overrides: Partial<SearchResult> = {}): SearchResult { + return { + youtubeId: 'abc12345678', + title: 'Default Title', + channelName: 'Chan', + channelId: null, + duration: null, + thumbnailUrl: null, + publishedAt: null, + viewCount: null, + status: 'never_downloaded', + ...overrides, + }; +} + +const daysAgo = (days: number) => new Date(Date.now() - days * 86_400_000).toISOString(); + +describe('ResultStatusChip', () => { + test('renders nothing for a video that was never downloaded', () => { + const { container } = render(<ResultStatusChip status="never_downloaded" />); + expect(container).toBeEmptyDOMElement(); + }); + + test('labels every other state', () => { + render(<ResultStatusChip status="downloaded" />); + expect(screen.getByText('Downloaded')).toBeInTheDocument(); + }); +}); + +describe('ResultChannel', () => { + test('links a subscribed channel to its page and marks it Subscribed', () => { + render( + <MemoryRouter> + <ResultChannel result={makeResult({ channelId: 'UC1', channelSubscribed: true })} variant="body2" onAddChannel={jest.fn()} /> + </MemoryRouter> + ); + expect(screen.getByRole('link', { name: 'Chan' })).toHaveAttribute('href', '/channel/UC1'); + expect(screen.getByText('Subscribed')).toBeInTheDocument(); + }); + + test('offers to add an unsubscribed channel', () => { + const onAddChannel = jest.fn(); + render( + <MemoryRouter> + <ResultChannel result={makeResult({ channelId: 'UC1', channelSubscribed: false })} variant="body2" onAddChannel={onAddChannel} /> + </MemoryRouter> + ); + fireEvent.click(screen.getByRole('button', { name: /chan/i })); + expect(onAddChannel).toHaveBeenCalledWith('Chan', 'https://www.youtube.com/channel/UC1'); + expect(screen.queryByText('Subscribed')).not.toBeInTheDocument(); + }); + + test('shows a plain name when the channel id is unknown', () => { + render( + <MemoryRouter> + <ResultChannel result={makeResult({ channelId: null })} variant="body2" onAddChannel={jest.fn()} /> + </MemoryRouter> + ); + expect(screen.getByText('Chan')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); + +describe('PublishedDate', () => { + test('shows the date relative to today with the exact date as its tooltip', () => { + const publishedAt = daysAgo(3); + render(<PublishedDate publishedAt={publishedAt} />); + const label = screen.getByText('3 days ago'); + expect(label).toHaveAttribute('title', new Date(publishedAt).toLocaleDateString()); + }); + + test('shows the fallback when the date is unknown', () => { + render(<PublishedDate publishedAt={null} fallback="N/A" />); + expect(screen.getByText('N/A')).toBeInTheDocument(); + }); +}); + +describe('ViewCount', () => { + test('abbreviates the count', () => { + render(<ViewCount viewCount={554_299} />); + expect(screen.getByText('554.3K')).toBeInTheDocument(); + expect(screen.getByTitle('554,299 views')).toBeInTheDocument(); + }); + + test('renders nothing when the count is unknown', () => { + const { container } = render(<ViewCount viewCount={null} />); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('ShortBadge', () => { + test('marks videos of a minute or less as Shorts', () => { + render(<ShortBadge duration={60} />); + expect(screen.getByText('Short')).toBeInTheDocument(); + }); + + test('renders nothing for longer videos or an unknown length', () => { + const { container, rerender } = render(<ShortBadge duration={61} />); + expect(container).toBeEmptyDOMElement(); + rerender(<ShortBadge duration={null} />); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('DownloadButton', () => { + test('asks to download the video without opening it', () => { + const onDownload = jest.fn(); + const onOpen = jest.fn(); + const result = makeResult({ title: 'Pick me' }); + render( + <div onClick={onOpen}> + <DownloadButton result={result} onDownload={onDownload} /> + </div> + ); + fireEvent.click(screen.getByRole('button', { name: 'Download Pick me' })); + expect(onDownload).toHaveBeenCalledWith(result); + expect(onOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/FindVideos/components/resultParts.tsx b/client/src/components/FindVideos/components/resultParts.tsx new file mode 100644 index 000000000..5043f98e1 --- /dev/null +++ b/client/src/components/FindVideos/components/resultParts.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { Chip, IconButton } from '../../ui'; +import { + CalendarToday as CalendarTodayIcon, + Download as DownloadIcon, + Eye as EyeIcon, +} from '../../../lib/icons'; +import ChannelNameDisplay from '../../shared/ChannelNameDisplay'; +import { + getStatusColor, + getStatusIcon, + getStatusLabel, + getStatusChipVariant, + getStatusChipStyle, + VideoStatus, +} from '../../../utils/videoStatus'; +import { SHARED_THEMED_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; +import { formatCompactCount, formatRelativeDate } from '../../../utils/formatters'; +import { isSelectableForDownload, isShort, SearchResult } from '../types'; + +export type AddChannelHandler = (channelName: string, channelUrl: string) => void; + +const noopAddChannel: AddChannelHandler = () => {}; + +// Nearly every search result is new, so "Not Downloaded" is the silent default +// and only the states worth noticing get a chip. +export function ResultStatusChip({ status }: { status: VideoStatus }) { + if (status === 'never_downloaded') return null; + return ( + <Chip + icon={getStatusIcon(status)} + label={getStatusLabel(status)} + size="small" + color={getStatusColor(status)} + variant={getStatusChipVariant(status)} + className="shrink-0" + style={{ ...SHARED_THEMED_CHIP_SMALL_STYLE, ...getStatusChipStyle(status) }} + /> + ); +} + +interface ResultChannelProps { + result: SearchResult; + variant: 'caption' | 'body2'; + className?: string; + style?: React.CSSProperties; + onAddChannel?: AddChannelHandler; +} + +export function ResultChannel({ result, variant, className, style, onAddChannel }: ResultChannelProps) { + const subscribed = Boolean(result.channelSubscribed && result.channelId); + return ( + <span className="flex items-center gap-1.5 min-w-0"> + <ChannelNameDisplay + channelName={result.channelName} + enabledChannelId={subscribed ? result.channelId : null} + videoChannelId={onAddChannel ? result.channelId : null} + variant={variant} + className={className} + style={style} + onAddChannel={onAddChannel ?? noopAddChannel} + /> + {subscribed && ( + <Chip + label="Subscribed" + size="small" + variant="outlined" + className="shrink-0" + style={SHARED_THEMED_CHIP_SMALL_STYLE} + /> + )} + </span> + ); +} + +interface PublishedDateProps { + publishedAt: string | null; + iconSize?: number; + fallback?: React.ReactNode; + className?: string; +} + +// Relative, like YouTube: the yt-dlp path's dates come from "5 years ago" +// text, so an exact-looking date would claim precision the data lacks. +export function PublishedDate({ publishedAt, iconSize = 12, fallback = null, className = '' }: PublishedDateProps) { + const relative = formatRelativeDate(publishedAt); + if (!relative || !publishedAt) return <>{fallback}</>; + return ( + <span + className={`inline-flex items-center gap-1 whitespace-nowrap ${className}`} + title={new Date(publishedAt).toLocaleDateString()} + > + <CalendarTodayIcon size={iconSize} aria-hidden /> + {relative} + </span> + ); +} + +interface ViewCountProps { + viewCount: number | null | undefined; + iconSize?: number; + icon?: boolean; + className?: string; +} + +export function ViewCount({ viewCount, iconSize = 12, icon = true, className = '' }: ViewCountProps) { + if (viewCount === null || viewCount === undefined) return null; + return ( + <span + className={`inline-flex items-center gap-1 whitespace-nowrap ${className}`} + title={`${viewCount.toLocaleString()} views`} + > + {icon && <EyeIcon size={iconSize} aria-hidden />} + {formatCompactCount(viewCount)} + </span> + ); +} + +export function ShortBadge({ duration }: { duration: number | null }) { + if (!isShort(duration)) return null; + return ( + <Chip + label="Short" + size="small" + variant="outlined" + className="shrink-0" + title="A minute or less" + style={SHARED_THEMED_CHIP_SMALL_STYLE} + /> + ); +} + +interface DownloadButtonProps { + result: SearchResult; + onDownload: (result: SearchResult) => void; + className?: string; +} + +export function DownloadButton({ result, onDownload, className }: DownloadButtonProps) { + if (!isSelectableForDownload(result.status)) return null; + return ( + <IconButton + size="sm" + aria-label={`Download ${result.title}`} + title="Download" + className={className} + onClick={(e) => { + e.stopPropagation(); + onDownload(result); + }} + > + <DownloadIcon size={16} /> + </IconButton> + ); +} diff --git a/client/src/components/FindVideos/hooks/__tests__/useFindVideosPreferences.test.ts b/client/src/components/FindVideos/hooks/__tests__/useFindVideosPreferences.test.ts new file mode 100644 index 000000000..98f9e369e --- /dev/null +++ b/client/src/components/FindVideos/hooks/__tests__/useFindVideosPreferences.test.ts @@ -0,0 +1,47 @@ +import { act, renderHook } from '@testing-library/react'; +import { useFindVideosPreferences } from '../useFindVideosPreferences'; + +describe('useFindVideosPreferences', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + test('defaults to the table view, relevance order, and no minimum duration', () => { + const { result } = renderHook(() => useFindVideosPreferences()); + expect(result.current.viewMode).toBe('table'); + expect(result.current.sort).toEqual({ key: 'relevance', direction: 'desc' }); + expect(result.current.minDuration).toBe(0); + }); + + test('restores stored choices', () => { + window.localStorage.setItem('findVideos.viewMode', 'grid'); + window.localStorage.setItem('findVideos.sort', JSON.stringify({ key: 'views', direction: 'asc' })); + window.localStorage.setItem('findVideos.minDuration', '300'); + const { result } = renderHook(() => useFindVideosPreferences()); + expect(result.current.viewMode).toBe('grid'); + expect(result.current.sort).toEqual({ key: 'views', direction: 'asc' }); + expect(result.current.minDuration).toBe(300); + }); + + test('falls back to defaults for values it does not recognise', () => { + window.localStorage.setItem('findVideos.viewMode', 'carousel'); + window.localStorage.setItem('findVideos.sort', '{"key":"colour","direction":"up"}'); + window.localStorage.setItem('findVideos.minDuration', '42'); + const { result } = renderHook(() => useFindVideosPreferences()); + expect(result.current.viewMode).toBe('table'); + expect(result.current.sort).toEqual({ key: 'relevance', direction: 'desc' }); + expect(result.current.minDuration).toBe(0); + }); + + test('persists each change', () => { + const { result } = renderHook(() => useFindVideosPreferences()); + act(() => { + result.current.setViewMode('grid'); + result.current.setSort({ key: 'published', direction: 'desc' }); + result.current.setMinDuration(600); + }); + expect(window.localStorage.getItem('findVideos.viewMode')).toBe('grid'); + expect(JSON.parse(window.localStorage.getItem('findVideos.sort') ?? '')).toEqual({ key: 'published', direction: 'desc' }); + expect(window.localStorage.getItem('findVideos.minDuration')).toBe('600'); + }); +}); diff --git a/client/src/components/FindVideos/hooks/__tests__/useResultDownload.test.ts b/client/src/components/FindVideos/hooks/__tests__/useResultDownload.test.ts new file mode 100644 index 000000000..546e49e82 --- /dev/null +++ b/client/src/components/FindVideos/hooks/__tests__/useResultDownload.test.ts @@ -0,0 +1,141 @@ +import { act, renderHook } from '@testing-library/react'; +import { SearchResult } from '../../types'; +import { VideoSelectionState } from '../../../shared/VideoList/hooks/useVideoSelection'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const mockTriggerDownloads = jest.fn(); +jest.mock('../../../../hooks/useTriggerDownloads', () => ({ + useTriggerDownloads: () => ({ triggerDownloads: mockTriggerDownloads, loading: false, error: null }), +})); + +const { useResultDownload } = require('../useResultDownload'); + +function makeResult(overrides: Partial<SearchResult> & { youtubeId: string }): SearchResult { + return { + title: overrides.youtubeId, + channelName: 'C', + channelId: null, + duration: null, + thumbnailUrl: null, + publishedAt: null, + viewCount: null, + status: 'never_downloaded', + ...overrides, + }; +} + +function makeSelection(selectedIds: string[]): VideoSelectionState<string> & { clear: jest.Mock } { + return { + selectedIds, + isSelected: (id) => selectedIds.includes(id), + toggle: jest.fn(), + set: jest.fn(), + add: jest.fn(), + selectAll: jest.fn(), + clear: jest.fn(), + hasSelection: selectedIds.length > 0, + count: selectedIds.length, + actions: [], + menuAnchor: null, + openMenu: jest.fn(), + closeMenu: jest.fn(), + }; +} + +const results = [ + makeResult({ youtubeId: 'aaaaaaaaaaa' }), + makeResult({ youtubeId: 'bbbbbbbbbbb', status: 'missing' }), + makeResult({ youtubeId: 'ccccccccccc', inArchive: true }), +]; + +describe('useResultDownload', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockTriggerDownloads.mockResolvedValue(true); + }); + + test('opening for one video targets just that video', () => { + const selection = makeSelection(['aaaaaaaaaaa']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForOne(results[1]); }); + + expect(result.current.dialogOpen).toBe(true); + expect(result.current.targetIds).toEqual(['bbbbbbbbbbb']); + }); + + test('counts removed and archive-only targets as previously downloaded', () => { + const selection = makeSelection(['aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForSelection(); }); + + expect(result.current.targetIds).toEqual(['aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc']); + expect(result.current.missingVideoCount).toBe(2); + }); + + test('confirming a selection queues its URLs, clears the selection, and opens the activity page', async () => { + const selection = makeSelection(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForSelection(); }); + await act(async () => { await result.current.confirm(null); }); + + expect(mockTriggerDownloads).toHaveBeenCalledWith({ + urls: ['https://www.youtube.com/watch?v=aaaaaaaaaaa', 'https://www.youtube.com/watch?v=bbbbbbbbbbb'], + overrideSettings: undefined, + }); + expect(selection.clear).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/downloads/activity'); + expect(result.current.dialogOpen).toBe(false); + }); + + test('confirming a single video leaves the selection alone', async () => { + const selection = makeSelection(['aaaaaaaaaaa']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForOne(results[1]); }); + await act(async () => { await result.current.confirm(null); }); + + expect(mockTriggerDownloads).toHaveBeenCalledWith({ + urls: ['https://www.youtube.com/watch?v=bbbbbbbbbbb'], + overrideSettings: undefined, + }); + expect(selection.clear).not.toHaveBeenCalled(); + }); + + test('skips targets that are already queued or downloading', async () => { + const selection = makeSelection(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + const { result } = renderHook(() => useResultDownload({ + token: 't', results, selection, activeVideos: { aaaaaaaaaaa: { state: 'queued' } }, + })); + + act(() => { result.current.openForSelection(); }); + await act(async () => { await result.current.confirm(null); }); + + expect(mockTriggerDownloads).toHaveBeenCalledWith(expect.objectContaining({ + urls: ['https://www.youtube.com/watch?v=bbbbbbbbbbb'], + })); + }); + + test('a failed queue keeps the selection and reports an error', async () => { + mockTriggerDownloads.mockResolvedValueOnce(false); + const selection = makeSelection(['aaaaaaaaaaa']); + const { result } = renderHook(() => useResultDownload({ token: 't', results, selection, activeVideos: {} })); + + act(() => { result.current.openForSelection(); }); + await act(async () => { await result.current.confirm(null); }); + + expect(result.current.error).toMatch(/failed to queue/i); + expect(selection.clear).not.toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + + act(() => { result.current.clearError(); }); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/client/src/components/FindVideos/hooks/__tests__/useVideoSearch.test.ts b/client/src/components/FindVideos/hooks/__tests__/useVideoSearch.test.ts index 8b78c9588..a86eae753 100644 --- a/client/src/components/FindVideos/hooks/__tests__/useVideoSearch.test.ts +++ b/client/src/components/FindVideos/hooks/__tests__/useVideoSearch.test.ts @@ -122,4 +122,56 @@ describe('useVideoSearch', () => { unmount(); expect(signalRef?.aborted).toBe(true); }); + + describe('last search cache', () => { + beforeEach(() => { window.sessionStorage.clear(); }); + + test('remembers a successful search for the session', async () => { + axios.post.mockResolvedValueOnce({ data: { results: [{ youtubeId: 'a', title: 'A' }] } }); + const { result } = renderHook(() => useVideoSearch('token')); + await act(async () => { await result.current.search('minecraft', 50); }); + + expect(JSON.parse(window.sessionStorage.getItem('findVideos.lastSearch') ?? '')).toEqual({ + query: 'minecraft', count: 50, results: [{ youtubeId: 'a', title: 'A' }], + }); + }); + + test('restores the remembered results without a request when asked for the same query', () => { + window.sessionStorage.setItem('findVideos.lastSearch', JSON.stringify({ + query: 'minecraft', count: 10, results: [{ youtubeId: 'a', title: 'A' }], + })); + const { result } = renderHook(() => useVideoSearch('token', { restoreQuery: 'minecraft' })); + + expect(result.current.results).toEqual([{ youtubeId: 'a', title: 'A' }]); + expect(result.current.restored).toEqual({ query: 'minecraft', count: 10 }); + expect(axios.post).not.toHaveBeenCalled(); + }); + + test('ignores the remembered results for a different query', () => { + window.sessionStorage.setItem('findVideos.lastSearch', JSON.stringify({ + query: 'minecraft', count: 10, results: [{ youtubeId: 'a', title: 'A' }], + })); + const { result } = renderHook(() => useVideoSearch('token', { restoreQuery: 'terraria' })); + + expect(result.current.results).toEqual([]); + expect(result.current.restored).toBeNull(); + }); + + test('ignores the remembered results when no query is asked for', () => { + window.sessionStorage.setItem('findVideos.lastSearch', JSON.stringify({ + query: 'minecraft', count: 10, results: [{ youtubeId: 'a', title: 'A' }], + })); + const { result } = renderHook(() => useVideoSearch('token')); + + expect(result.current.results).toEqual([]); + expect(result.current.restored).toBeNull(); + }); + + test('ignores a remembered entry it cannot read', () => { + window.sessionStorage.setItem('findVideos.lastSearch', '{not json'); + const { result } = renderHook(() => useVideoSearch('token', { restoreQuery: 'minecraft' })); + expect(result.current.results).toEqual([]); + expect(result.current.restored).toBeNull(); + }); + }); }); diff --git a/client/src/components/FindVideos/hooks/useFindVideosPreferences.ts b/client/src/components/FindVideos/hooks/useFindVideosPreferences.ts new file mode 100644 index 000000000..d84ac30a4 --- /dev/null +++ b/client/src/components/FindVideos/hooks/useFindVideosPreferences.ts @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { DEFAULT_SORT, ResultSort, isSortKey } from '../sortResults'; +import { + DEFAULT_MIN_DURATION, + MIN_DURATIONS, + MIN_DURATION_STORAGE_KEY, + MinDuration, + ViewMode, +} from '../types'; + +export const VIEW_MODE_STORAGE_KEY = 'findVideos.viewMode'; +export const SORT_STORAGE_KEY = 'findVideos.sort'; +const DEFAULT_VIEW_MODE: ViewMode = 'table'; + +function readStored<T>(key: string, parse: (raw: string) => T | null, fallback: T): T { + try { + const raw = window.localStorage.getItem(key); + if (raw === null) return fallback; + return parse(raw) ?? fallback; + } catch { + return fallback; + } +} + +function writeStored(key: string, value: string) { + try { + window.localStorage.setItem(key, value); + } catch { + // localStorage unavailable (private mode, quota); the choice still holds in-session. + } +} + +function parseViewMode(raw: string): ViewMode | null { + return raw === 'table' || raw === 'grid' ? raw : null; +} + +function parseSort(raw: string): ResultSort | null { + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + const { key, direction } = parsed as { key?: unknown; direction?: unknown }; + if (!isSortKey(key)) return null; + if (direction !== 'asc' && direction !== 'desc') return null; + return { key, direction }; + } catch { + return null; + } +} + +function parseMinDuration(raw: string): MinDuration | null { + const parsed = Number(raw) as MinDuration; + return MIN_DURATIONS.includes(parsed) ? parsed : null; +} + +export interface FindVideosPreferences { + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + sort: ResultSort; + setSort: (sort: ResultSort) => void; + minDuration: MinDuration; + setMinDuration: (minDuration: MinDuration) => void; +} + +// The page's remembered choices: view, sort, and minimum duration survive reloads. +export function useFindVideosPreferences(): FindVideosPreferences { + const [viewMode, setViewMode] = useState<ViewMode>( + () => readStored(VIEW_MODE_STORAGE_KEY, parseViewMode, DEFAULT_VIEW_MODE) + ); + const [sort, setSort] = useState<ResultSort>(() => readStored(SORT_STORAGE_KEY, parseSort, DEFAULT_SORT)); + const [minDuration, setMinDuration] = useState<MinDuration>( + () => readStored(MIN_DURATION_STORAGE_KEY, parseMinDuration, DEFAULT_MIN_DURATION) + ); + + useEffect(() => { writeStored(VIEW_MODE_STORAGE_KEY, viewMode); }, [viewMode]); + useEffect(() => { writeStored(SORT_STORAGE_KEY, JSON.stringify(sort)); }, [sort]); + useEffect(() => { writeStored(MIN_DURATION_STORAGE_KEY, String(minDuration)); }, [minDuration]); + + return { viewMode, setViewMode, sort, setSort, minDuration, setMinDuration }; +} diff --git a/client/src/components/FindVideos/hooks/useResultDownload.ts b/client/src/components/FindVideos/hooks/useResultDownload.ts new file mode 100644 index 000000000..de98e5c9b --- /dev/null +++ b/client/src/components/FindVideos/hooks/useResultDownload.ts @@ -0,0 +1,104 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTriggerDownloads } from '../../../hooks/useTriggerDownloads'; +import { isRemovedStatus } from '../../../utils/videoStatus'; +import { DownloadSettings } from '../../DownloadManager/ManualDownload/types'; +import { VideoSelectionState } from '../../shared/VideoList/hooks/useVideoSelection'; +import { SearchResult } from '../types'; + +const QUEUE_FAILED_MESSAGE = 'Failed to queue selected videos. Please try again.'; + +type ActiveVideos = Record<string, { state?: string | null } | undefined>; + +// Which videos the dialog is for: the multi-selection, or one row's button. +type Target = { kind: 'selection' } | { kind: 'one'; youtubeId: string }; + +interface UseResultDownloadOptions { + token: string | null; + results: SearchResult[]; + selection: VideoSelectionState<string>; + activeVideos: ActiveVideos; +} + +export interface ResultDownload { + dialogOpen: boolean; + targetIds: string[]; + missingVideoCount: number; + error: string | null; + clearError: () => void; + openForSelection: () => void; + openForOne: (result: SearchResult) => void; + close: () => void; + confirm: (settings: DownloadSettings | null) => Promise<void>; +} + +function toOverrideSettings(settings: DownloadSettings | null) { + if (!settings) return undefined; + return { + resolution: settings.resolution, + allowRedownload: settings.allowRedownload, + subfolder: settings.subfolder, + audioFormat: settings.audioFormat, + rating: settings.rating, + skipVideoFolder: settings.skipVideoFolder, + }; +} + +export function useResultDownload({ token, results, selection, activeVideos }: UseResultDownloadOptions): ResultDownload { + const navigate = useNavigate(); + const { triggerDownloads } = useTriggerDownloads(token); + const [target, setTarget] = useState<Target | null>(null); + const [error, setError] = useState<string | null>(null); + + const targetIds = useMemo(() => { + if (!target) return []; + return target.kind === 'one' ? [target.youtubeId] : selection.selectedIds; + }, [target, selection.selectedIds]); + + // Removed or archive-only videos need "Allow re-downloading" or yt-dlp skips them. + const missingVideoCount = useMemo(() => { + const targets = new Set(targetIds); + return results.filter((r) => targets.has(r.youtubeId) && (isRemovedStatus(r.status) || r.inArchive)).length; + }, [results, targetIds]); + + const openForSelection = useCallback(() => { + setError(null); + setTarget({ kind: 'selection' }); + }, []); + + const openForOne = useCallback((result: SearchResult) => { + setError(null); + setTarget({ kind: 'one', youtubeId: result.youtubeId }); + }, []); + + const close = useCallback(() => setTarget(null), []); + + const confirm = useCallback(async (settings: DownloadSettings | null) => { + const current = target; + setTarget(null); + if (!current) return; + const ids = targetIds.filter((id) => !activeVideos[id]?.state); + if (ids.length === 0) return; + const urls = ids.map((id) => `https://www.youtube.com/watch?v=${id}`); + const success = await triggerDownloads({ urls, overrideSettings: toOverrideSettings(settings) }); + if (success === null) return; + if (!success) { + setError(QUEUE_FAILED_MESSAGE); + return; + } + if (current.kind === 'selection') selection.clear(); + navigate('/downloads/activity'); + }, [target, targetIds, activeVideos, triggerDownloads, selection, navigate]); + + return { + dialogOpen: target !== null, + targetIds, + missingVideoCount, + error, + clearError: () => setError(null), + openForSelection, + openForOne, + close, + confirm, + }; +} diff --git a/client/src/components/FindVideos/hooks/useVideoSearch.ts b/client/src/components/FindVideos/hooks/useVideoSearch.ts index 6d82c3475..6ac68e2f5 100644 --- a/client/src/components/FindVideos/hooks/useVideoSearch.ts +++ b/client/src/components/FindVideos/hooks/useVideoSearch.ts @@ -1,21 +1,67 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import axios from 'axios'; import { decodeHtml } from '../../../utils/formatters'; -import { SearchResult, PageSize } from '../types'; +import { SearchResult, PageSize, PAGE_SIZES } from '../types'; + +export const LAST_SEARCH_STORAGE_KEY = 'findVideos.lastSearch'; + +interface LastSearch { + query: string; + count: PageSize; + results: SearchResult[]; +} + +export interface RestoredSearch { + query: string; + count: PageSize; +} + +interface UseVideoSearchOptions { + // The query the page was opened with (`?q=`): when it matches the remembered + // search, its results are shown again without spending another request. + restoreQuery?: string | null; +} interface UseVideoSearchResult { results: SearchResult[]; loading: boolean; error: string | null; + restored: RestoredSearch | null; search: (query: string, count: PageSize) => Promise<void>; cancel: () => void; } -export function useVideoSearch(token: string | null): UseVideoSearchResult { - const [results, setResults] = useState<SearchResult[]>([]); +function readLastSearch(query: string | null | undefined): LastSearch | null { + if (!query) return null; + try { + const raw = window.sessionStorage.getItem(LAST_SEARCH_STORAGE_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + const { query: storedQuery, count, results } = parsed as Partial<LastSearch>; + if (storedQuery !== query || !Array.isArray(results)) return null; + if (!PAGE_SIZES.includes(count as PageSize)) return null; + return { query: storedQuery, count: count as PageSize, results }; + } catch { + return null; + } +} + +function writeLastSearch(entry: LastSearch) { + try { + window.sessionStorage.setItem(LAST_SEARCH_STORAGE_KEY, JSON.stringify(entry)); + } catch { + // sessionStorage unavailable or full; the results are still on screen. + } +} + +export function useVideoSearch(token: string | null, options: UseVideoSearchOptions = {}): UseVideoSearchResult { + const [restoredSearch] = useState<LastSearch | null>(() => readLastSearch(options.restoreQuery)); + const [results, setResults] = useState<SearchResult[]>(() => restoredSearch?.results ?? []); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const controllerRef = useRef<AbortController | null>(null); + const restored = restoredSearch ? { query: restoredSearch.query, count: restoredSearch.count } : null; useEffect(() => { return () => { controllerRef.current?.abort(); }; @@ -53,6 +99,7 @@ export function useVideoSearch(token: string | null): UseVideoSearchResult { channelName: r.channelName ? decodeHtml(r.channelName) : r.channelName, })); setResults(decoded); + writeLastSearch({ query, count, results: decoded }); } catch (err: unknown) { if (axios.isCancel(err)) { // user canceled or superseded; do not surface as error @@ -73,5 +120,5 @@ export function useVideoSearch(token: string | null): UseVideoSearchResult { controllerRef.current?.abort(); }, []); - return { results, loading, error, search, cancel }; + return { results, loading, error, restored, search, cancel }; } diff --git a/client/src/components/FindVideos/index.tsx b/client/src/components/FindVideos/index.tsx index 356062993..f6a18da26 100644 --- a/client/src/components/FindVideos/index.tsx +++ b/client/src/components/FindVideos/index.tsx @@ -1,45 +1,34 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useVideoActivity } from '../../providers/VideoActivityProvider'; import { useLocalVideoStatus } from '../../hooks/useLocalVideoStatus'; -import { useNavigate } from 'react-router-dom'; +import { removedState } from '../../utils/videoRemoval'; import { Alert, Box, Typography } from '../ui'; import { Download as DownloadIcon } from '../../lib/icons'; import VideoModal from '../shared/VideoModal'; import { VideoModalData } from '../shared/VideoModal/types'; +import AddChannelDialog from '../shared/AddChannelDialog'; import { useMediaQuery } from '../../hooks/useMediaQuery'; import { useConfig } from '../../hooks/useConfig'; -import { useTriggerDownloads } from '../../hooks/useTriggerDownloads'; import { useVideoSelection } from '../shared/VideoList/hooks/useVideoSelection'; import VideoListSelectionPill from '../shared/VideoList/VideoListSelectionPill'; -import { SelectionAction } from '../shared/VideoList/types'; +import { SelectionAction, SortConfig } from '../shared/VideoList/types'; import DownloadSettingsDialog from '../DownloadManager/ManualDownload/DownloadSettingsDialog'; -import { DownloadSettings } from '../DownloadManager/ManualDownload/types'; import SearchBar from './components/SearchBar'; import ResultsGrid from './components/ResultsGrid'; import { useVideoSearch } from './hooks/useVideoSearch'; +import { useFindVideosPreferences } from './hooks/useFindVideosPreferences'; +import { useResultDownload } from './hooks/useResultDownload'; +import { DEFAULT_SORT, defaultDirectionFor, isSortKey, SORT_OPTIONS, SortKey, sortResults } from './sortResults'; import { - DEFAULT_MIN_DURATION, DEFAULT_PAGE_SIZE, isSelectableForDownload, - MIN_DURATIONS, - MIN_DURATION_STORAGE_KEY, - MinDuration, PageSize, ResultSelection, SearchResult, - ViewMode, } from './types'; -function readStoredMinDuration(): MinDuration { - try { - const raw = window.localStorage.getItem(MIN_DURATION_STORAGE_KEY); - if (raw === null) return DEFAULT_MIN_DURATION; - const parsed = Number(raw) as MinDuration; - return MIN_DURATIONS.includes(parsed) ? parsed : DEFAULT_MIN_DURATION; - } catch { - return DEFAULT_MIN_DURATION; - } -} +const QUERY_PARAM = 'q'; interface FindVideosProps { token: string | null; @@ -56,9 +45,10 @@ function toModalData(r: SearchResult): VideoModalData { addedAt: r.addedAt ?? null, mediaType: 'video', status: r.status === 'queued' || r.status === 'downloading' - ? (r.isDownloaded ? 'downloaded' : r.databaseId ? 'missing' : 'never_downloaded') + ? (r.isDownloaded ? 'downloaded' : r.databaseId ? removedState(r.removedReason) : 'never_downloaded') : r.status, inArchive: r.inArchive, + removal: { reason: r.removedReason ?? null, at: r.removedAt ?? null, detail: r.removedDetail ?? null }, isDownloaded: r.isDownloaded ?? r.status === 'downloaded', filePath: r.filePath ?? null, fileSize: r.fileSize ?? null, @@ -75,19 +65,18 @@ function toModalData(r: SearchResult): VideoModalData { export default function FindVideos({ token }: FindVideosProps) { const isMobile = useMediaQuery('(max-width: 767px)'); - const navigate = useNavigate(); - const [query, setQuery] = useState(''); - const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE); - const [minDuration, setMinDuration] = useState<MinDuration>(readStoredMinDuration); - const [hasSearched, setHasSearched] = useState(false); - const [viewMode, setViewMode] = useState<ViewMode>(isMobile ? 'table' : 'grid'); + const [searchParams, setSearchParams] = useSearchParams(); + const urlQuery = searchParams.get(QUERY_PARAM); + const prefs = useFindVideosPreferences(); + const { results: searchResults, loading, error, restored, search, cancel } = useVideoSearch(token, { restoreQuery: urlQuery }); + const [query, setQuery] = useState(restored?.query ?? urlQuery ?? ''); + const [pageSize, setPageSize] = useState<PageSize>(restored?.count ?? DEFAULT_PAGE_SIZE); + const [hasSearched, setHasSearched] = useState(Boolean(restored)); const [modalVideo, setModalVideo] = useState<VideoModalData | null>(null); - const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); - const [downloadError, setDownloadError] = useState<string | null>(null); - const lastQueryRef = useRef(''); - const lastPageSizeRef = useRef<PageSize>(DEFAULT_PAGE_SIZE); + const [addChannel, setAddChannel] = useState<{ name: string; url: string } | null>(null); + const lastQueryRef = useRef(restored?.query ?? ''); + const lastPageSizeRef = useRef<PageSize>(restored?.count ?? DEFAULT_PAGE_SIZE); - const { results: searchResults, loading, error, search, cancel } = useVideoSearch(token); const { snapshot } = useVideoActivity(); const localStatuses = useLocalVideoStatus(searchResults.map(r => r.youtubeId), token); const results = useMemo(() => searchResults.map(result => ({ @@ -97,41 +86,54 @@ export default function FindVideos({ token }: FindVideosProps) { status: snapshot.videos[result.youtubeId]?.state || localStatuses[result.youtubeId]?.status || result.status, })), [searchResults, localStatuses, snapshot]); const { config } = useConfig(token); - const { triggerDownloads } = useTriggerDownloads(token); - useEffect(() => { - try { - window.localStorage.setItem(MIN_DURATION_STORAGE_KEY, String(minDuration)); - } catch { - // localStorage unavailable (private mode, quota); filter still works in-session. - } - }, [minDuration]); + const runSearch = useCallback((trimmed: string, count: PageSize) => { + lastQueryRef.current = trimmed; + lastPageSizeRef.current = count; + setHasSearched(true); + setSearchParams({ [QUERY_PARAM]: trimmed }, { replace: true }); + search(trimmed, count); + }, [search, setSearchParams]); - const filteredResults = useMemo(() => { - if (minDuration === 0) return results; - return results.filter((r) => r.duration === null || r.duration >= minDuration); - }, [results, minDuration]); + // Opened with ?q= and nothing remembered for it (a fresh tab, a shared link): + // run that search once. Only the query the page was opened with counts; the + // ones it writes to the URL itself must not trigger a second search. + const openedWithQuery = useRef(urlQuery); + useEffect(() => { + const initial = openedWithQuery.current?.trim(); + if (restored || !initial) return; + runSearch(initial, pageSize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const doSearch = useCallback(() => { const trimmed = query.trim(); if (!trimmed) return; - setDownloadError(null); - lastQueryRef.current = trimmed; - lastPageSizeRef.current = pageSize; - setHasSearched(true); - search(trimmed, pageSize); - }, [query, pageSize, search]); + runSearch(trimmed, pageSize); + }, [query, pageSize, runSearch]); const onRetry = useCallback(() => { if (!lastQueryRef.current) return; search(lastQueryRef.current, lastPageSizeRef.current); }, [search]); + const filteredResults = useMemo(() => { + if (prefs.minDuration === 0) return results; + return results.filter((r) => r.duration === null || r.duration >= prefs.minDuration); + }, [results, prefs.minDuration]); + const sortedResults = useMemo( + () => sortResults(filteredResults, prefs.sort.key, prefs.sort.direction), + [filteredResults, prefs.sort] + ); + const showFilterCount = - hasSearched && !loading && !error && minDuration !== 0 && results.length > 0; + hasSearched && !loading && !error && prefs.minDuration !== 0 && results.length > 0; const allFilteredOut = showFilterCount && results.length > 0 && filteredResults.length === 0; + // The selection's Download action opens the dialog, and the dialog hook needs + // the selection: the ref breaks the loop. + const openForSelectionRef = useRef<() => void>(() => {}); const downloadActions: SelectionAction<string>[] = useMemo( () => [ { @@ -139,16 +141,15 @@ export default function FindVideos({ token }: FindVideosProps) { label: 'Download', icon: <DownloadIcon size={14} />, intent: 'success', - onClick: () => { - setDownloadError(null); - setDownloadDialogOpen(true); - }, + onClick: () => openForSelectionRef.current(), }, ], [] ); const selection = useVideoSelection<string>({ actions: downloadActions }); + const download = useResultDownload({ token, results, selection, activeVideos: snapshot.videos }); + openForSelectionRef.current = download.openForSelection; // Drop selected ids that are no longer eligible (e.g. their status changed, // or a new search wiped them from the results list). @@ -169,40 +170,31 @@ export default function FindVideos({ token }: FindVideosProps) { [selection.isSelected, selection.toggle] ); - const handleDownloadConfirm = useCallback( - async (settings: DownloadSettings | null) => { - setDownloadDialogOpen(false); - const ids = selection.selectedIds.filter(id => !snapshot.videos[id]?.state); - if (ids.length === 0) return; - const urls = ids.map((id) => `https://www.youtube.com/watch?v=${id}`); - const overrideSettings = settings - ? { - resolution: settings.resolution, - allowRedownload: settings.allowRedownload, - subfolder: settings.subfolder, - audioFormat: settings.audioFormat, - rating: settings.rating, - skipVideoFolder: settings.skipVideoFolder, - } - : undefined; - const success = await triggerDownloads({ urls, overrideSettings }); - if (success === null) return; - if (!success) { - setDownloadError('Failed to queue selected videos. Please try again.'); - return; - } - selection.clear(); - navigate('/downloads/activity'); - }, - [selection, triggerDownloads, navigate, snapshot] - ); + // Column headers cycle: the column's natural direction, the other, then back to relevance. + const handleHeaderSort = useCallback((key: SortKey) => { + const { sort, setSort } = prefs; + if (sort.key !== key) { + setSort({ key, direction: defaultDirectionFor(key) }); + } else if (sort.direction === defaultDirectionFor(key)) { + setSort({ key, direction: sort.direction === 'asc' ? 'desc' : 'asc' }); + } else { + setSort(DEFAULT_SORT); + } + }, [prefs]); + + // The desktop table sorts through its headers; every other layout gets the menu. + const showSortMenu = isMobile || prefs.viewMode === 'grid'; + const sortConfig: SortConfig | undefined = showSortMenu + ? { + options: SORT_OPTIONS, + activeKey: prefs.sort.key, + direction: prefs.sort.direction, + onChange: (key, direction) => { + if (isSortKey(key)) prefs.setSort({ key, direction }); + }, + } + : undefined; - const missingVideoCount = useMemo( - () => results.filter( - (r) => selection.isSelected(r.youtubeId) && (r.status === 'missing' || r.inArchive) - ).length, - [results, selection.isSelected] - ); const defaultResolution = config.preferredResolution || '1080'; return ( @@ -211,13 +203,14 @@ export default function FindVideos({ token }: FindVideosProps) { <SearchBar query={query} pageSize={pageSize} - minDuration={minDuration} + minDuration={prefs.minDuration} loading={loading} - viewMode={viewMode} + viewMode={prefs.viewMode} + sort={sortConfig} onQueryChange={setQuery} onPageSizeChange={setPageSize} - onMinDurationChange={setMinDuration} - onViewModeChange={setViewMode} + onMinDurationChange={prefs.setMinDuration} + onViewModeChange={prefs.setViewMode} onSearch={doSearch} onCancel={cancel} /> @@ -227,9 +220,9 @@ export default function FindVideos({ token }: FindVideosProps) { {results.length - filteredResults.length} hidden by minimum duration filter) </Typography> )} - {downloadError && ( - <Alert severity="error" onClose={() => setDownloadError(null)}> - {downloadError} + {download.error && ( + <Alert severity="error" onClose={download.clearError}> + {download.error} </Alert> )} {allFilteredOut ? ( @@ -239,30 +232,42 @@ export default function FindVideos({ token }: FindVideosProps) { </Typography> ) : ( <ResultsGrid - results={filteredResults} + results={sortedResults} loading={loading} error={error} hasSearched={hasSearched} lastQuery={lastQueryRef.current} pageSize={pageSize} - viewMode={viewMode} + viewMode={prefs.viewMode} onResultClick={(r) => setModalVideo(toModalData(r))} onRetry={onRetry} selection={resultSelection} + sort={prefs.sort} + onSortChange={handleHeaderSort} + onAddChannel={(name, url) => setAddChannel({ name, url })} + onDownloadOne={download.openForOne} /> )} <VideoListSelectionPill selection={selection} isMobile={isMobile} /> <DownloadSettingsDialog - open={downloadDialogOpen} - onClose={() => setDownloadDialogOpen(false)} - onConfirm={handleDownloadConfirm} - videoCount={selection.selectedIds.length} - missingVideoCount={missingVideoCount} + open={download.dialogOpen} + onClose={download.close} + onConfirm={download.confirm} + videoCount={download.targetIds.length} + missingVideoCount={download.missingVideoCount} defaultResolution={defaultResolution} defaultResolutionSource="global" mode="manual" token={token} /> + {addChannel && ( + <AddChannelDialog + open + onClose={() => setAddChannel(null)} + channelName={addChannel.name} + channelUrl={addChannel.url} + /> + )} {modalVideo && ( <VideoModal open diff --git a/client/src/components/FindVideos/sortResults.ts b/client/src/components/FindVideos/sortResults.ts new file mode 100644 index 000000000..935ebf6ce --- /dev/null +++ b/client/src/components/FindVideos/sortResults.ts @@ -0,0 +1,69 @@ +import type { SortOption } from '../shared/VideoList/types'; +import type { SearchResult } from './types'; + +export type SortKey = 'relevance' | 'title' | 'channel' | 'published' | 'duration' | 'views'; +export type SortDirection = 'asc' | 'desc'; + +export interface ResultSort { + key: SortKey; + direction: SortDirection; +} + +export const SORT_KEYS: SortKey[] = ['relevance', 'title', 'channel', 'published', 'duration', 'views']; + +// Relevance is YouTube's own ranking, which the server preserves; it has no direction. +export const SORT_OPTIONS: (SortOption & { key: SortKey })[] = [ + { key: 'relevance', label: 'Relevance', undirected: true }, + { key: 'published', label: 'Published' }, + { key: 'views', label: 'Views' }, + { key: 'duration', label: 'Duration' }, + { key: 'title', label: 'Title' }, + { key: 'channel', label: 'Channel' }, +]; + +export const DEFAULT_SORT: ResultSort = { key: 'relevance', direction: 'desc' }; + +export function isSortKey(value: unknown): value is SortKey { + return typeof value === 'string' && (SORT_KEYS as string[]).includes(value); +} + +export function defaultDirectionFor(key: SortKey): SortDirection { + return key === 'title' || key === 'channel' ? 'asc' : 'desc'; +} + +type Comparable = string | number | null; + +function valueFor(result: SearchResult, key: Exclude<SortKey, 'relevance'>): Comparable { + switch (key) { + case 'title': + return result.title.toLocaleLowerCase(); + case 'channel': + return result.channelName.toLocaleLowerCase(); + case 'published': + return result.publishedAt ? Date.parse(result.publishedAt) : null; + case 'duration': + return result.duration; + case 'views': + return result.viewCount; + } +} + +function compare(a: Comparable, b: Comparable): number { + if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b); + return (a as number) - (b as number); +} + +// Stable; a missing value sorts last whichever way the rest is ordered. +export function sortResults<T extends SearchResult>(results: T[], key: SortKey, direction: SortDirection): T[] { + if (key === 'relevance') return results.slice(); + const sign = direction === 'asc' ? 1 : -1; + return results + .map((result, index) => ({ result, index, value: valueFor(result, key) })) + .sort((a, b) => { + if (a.value === null && b.value === null) return a.index - b.index; + if (a.value === null) return 1; + if (b.value === null) return -1; + return sign * compare(a.value, b.value) || a.index - b.index; + }) + .map((entry) => entry.result); +} diff --git a/client/src/components/FindVideos/types.ts b/client/src/components/FindVideos/types.ts index 1ad60bdfa..ec57d2d1a 100644 --- a/client/src/components/FindVideos/types.ts +++ b/client/src/components/FindVideos/types.ts @@ -1,4 +1,6 @@ -export type SearchResultStatus = 'downloaded' | 'missing' | 'never_downloaded' | 'queued' | 'downloading'; +import type { RemovalReason } from '../../utils/videoRemoval'; + +export type SearchResultStatus = 'downloaded' | 'deleted' | 'missing' | 'never_downloaded' | 'queued' | 'downloading'; export interface SearchResult { youtubeId: string; @@ -10,9 +12,16 @@ export interface SearchResult { publishedAt: string | null; viewCount: number | null; status: SearchResultStatus; + // The video's channel is an enabled subscription (set by the search, not by + // the local-status refresh, which has no channel id to answer with). + channelSubscribed?: boolean; // Listed in the download archive without a database record, so a download // skips it unless re-downloading is allowed. inArchive?: boolean; + // Why the file is gone, for a 'deleted' or 'missing' status (see utils/videoRemoval). + removedReason?: RemovalReason | null; + removedAt?: string | null; + removedDetail?: string | null; isDownloaded?: boolean; databaseId?: number | null; filePath?: string | null; @@ -38,7 +47,7 @@ export const MIN_DURATIONS: MinDuration[] = [0, 60, 180, 300, 600, 900, 1200]; export const DEFAULT_MIN_DURATION: MinDuration = 0; export const MIN_DURATION_LABELS: Record<MinDuration, string> = { 0: 'Any length', - 60: '1+ min', + 60: '1+ min (hides Shorts)', 180: '3+ min', 300: '5+ min', 600: '10+ min', @@ -48,6 +57,14 @@ export const MIN_DURATION_LABELS: Record<MinDuration, string> = { export const MIN_DURATION_STORAGE_KEY = 'findVideos.minDuration'; +// YouTube's original Shorts limit; search results carry no vertical-video flag, +// so length is the only signal. +export const SHORT_MAX_SECONDS = 60; + +export function isShort(duration: number | null): boolean { + return duration !== null && duration <= SHORT_MAX_SECONDS; +} + export const THUMB_WIDTH = 120; export const THUMB_HEIGHT = 67; @@ -56,9 +73,9 @@ export interface ResultSelection { toggle: (id: string) => void; } -// Eligible to multi-select for download. Includes 'missing' so users can bulk -// re-download videos whose files are gone; the DownloadSettingsDialog already -// surfaces the allow-redownload toggle for that case. +// Eligible to multi-select for download. Includes 'missing' and 'deleted' so +// users can bulk re-download videos whose files are gone; the +// DownloadSettingsDialog already surfaces the allow-redownload toggle for that case. export function isSelectableForDownload(status: SearchResultStatus): boolean { - return status === 'never_downloaded' || status === 'missing'; + return status === 'never_downloaded' || status === 'missing' || status === 'deleted'; } diff --git a/client/src/components/PlaylistPage.tsx b/client/src/components/PlaylistPage.tsx index 984a9b032..26c9896a8 100644 --- a/client/src/components/PlaylistPage.tsx +++ b/client/src/components/PlaylistPage.tsx @@ -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 } 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'; @@ -50,33 +52,6 @@ interface SnackbarState { severity: 'success' | 'error' | 'info' | 'warning'; } -function toModalData(v: PlaylistVideo): VideoModalData { - const status = v.youtube_removed || v.previously_downloaded - ? 'missing' - : v.downloaded - ? 'downloaded' - : 'never_downloaded'; - return { - youtubeId: v.youtube_id, - title: v.title || v.youtube_id, - channelName: v.channel_name || '', - thumbnailUrl: v.thumbnail || `https://i.ytimg.com/vi/${v.youtube_id}/hqdefault.jpg`, - duration: v.duration, - publishedAt: v.published_at, - addedAt: v.downloaded_at ?? null, - mediaType: 'video', - status, - isDownloaded: v.downloaded, - ...toDownloadFileProps(v), - isProtected: false, - isIgnored: v.ignored, - normalizedRating: null, - ratingSource: null, - databaseId: v.video_id, - channelId: v.channel_id, - }; -} - function PlaylistPage({ token }: PlaylistPageProps) { const { id: playlistIdParam } = useParams<{ id: string }>(); const playlistId = playlistIdParam || null; @@ -187,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) => { @@ -231,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); @@ -343,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); @@ -574,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/LibraryDownloadsGroup.tsx b/client/src/components/PlaylistPage/components/LibraryDownloadsGroup.tsx index 560ce46c7..257424245 100644 --- a/client/src/components/PlaylistPage/components/LibraryDownloadsGroup.tsx +++ b/client/src/components/PlaylistPage/components/LibraryDownloadsGroup.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Alert, Box, Button, FormControlLabel, Switch, Typography } from '../../ui'; import { Refresh as RefreshIcon, Download as DownloadIcon } from '../../../lib/icons'; -import InfoHint from './InfoHint'; +import InfoHint from '../../shared/InfoHint'; import { Playlist } from '../../../types/playlist'; import { MAX_PLAYLIST_VIDEOS } from '../playlistConstants'; diff --git a/client/src/components/PlaylistPage/components/MediaServerSyncGroup.tsx b/client/src/components/PlaylistPage/components/MediaServerSyncGroup.tsx index db717fc72..539511a2f 100644 --- a/client/src/components/PlaylistPage/components/MediaServerSyncGroup.tsx +++ b/client/src/components/PlaylistPage/components/MediaServerSyncGroup.tsx @@ -11,7 +11,7 @@ import { } from '../../ui'; import { Refresh as RefreshIcon, MoreHorizontal } from '../../../lib/icons'; import { MediaServerStatus, MediaServerType, Playlist } from '../../../types/playlist'; -import InfoHint from './InfoHint'; +import InfoHint from '../../shared/InfoHint'; import PlaylistSyncChips from './PlaylistSyncChips'; interface MediaServerSyncGroupProps { diff --git a/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx b/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx index c76a8548b..90e3b63c1 100644 --- a/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx +++ b/client/src/components/PlaylistPage/components/PlaylistVideoCard.tsx @@ -3,9 +3,10 @@ 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 { REMOVAL_TOOLTIP_CLASS } from '../../shared/RemovalDescription'; import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from '../../shared/chipStyles'; const compactStatusChipStyle: React.CSSProperties = { @@ -35,7 +36,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 ( @@ -88,6 +89,10 @@ const PlaylistVideoCard: React.FC<PlaylistVideoCardProps> = ({ <Chip label={status.label} color={status.color} size="small" style={compactStatusChipStyle} /> )} </> + ) : status.tooltip ? ( + <Tooltip title={status.tooltip} enterTouchDelay={0} className={REMOVAL_TOOLTIP_CLASS}> + <Chip label={status.label} color={status.color} size="small" style={compactStatusChipStyle} /> + </Tooltip> ) : ( <Chip label={status.label} color={status.color} size="small" style={compactStatusChipStyle} /> )} diff --git a/client/src/components/PlaylistPage/components/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 1df2914d6..6dfffed4b 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,9 +14,11 @@ 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'; +import { REMOVAL_TOOLTIP_CLASS } from '../../shared/RemovalDescription'; const THUMB_WIDTH = 120; const THUMB_HEIGHT = 67; @@ -46,56 +48,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 +150,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> @@ -139,33 +171,16 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({ <Chip label={status.label} color={status.color} size="small" /> )} </> + ) : status.tooltip ? ( + <Tooltip title={status.tooltip} enterTouchDelay={0} className={REMOVAL_TOOLTIP_CLASS}> + <Chip label={status.label} color={status.color} size="small" /> + </Tooltip> ) : ( <Chip label={status.label} color={status.color} size="small" /> )} <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 cc247159e..c397a099a 100644 --- a/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx +++ b/client/src/components/PlaylistPage/components/__tests__/playlistVideoHelpers.test.tsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { isDownloadable, statusLabel, PublishedDate, toDownloadFileProps } from '../playlistVideoHelpers'; +import { 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'); @@ -60,12 +90,29 @@ describe('statusLabel', () => { color: 'error', }); }); - test('returns Missing for a previously-downloaded video whose file is gone', () => { - expect(statusLabel(makeVideo({ previously_downloaded: true }))).toEqual({ + test('returns Missing for a previously-downloaded video whose file was not found', () => { + expect(statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'not_found', file_path: '/d/v.mp4' }))).toMatchObject({ label: 'Missing', color: 'error', }); }); + test('explains a missing video with the path Youtarr looked at', () => { + const { tooltip } = statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'not_found', file_path: '/d/v.mp4' })); + render(<>{tooltip}</>); + expect(screen.getByRole('code')).toHaveTextContent('/d/v.mp4'); + }); + test('returns a neutral Deleted for a previously-downloaded video Youtarr removed, explaining it', () => { + const status = statusLabel(makeVideo({ previously_downloaded: true, removed_reason: 'auto_removal', removed_detail: 'older than 30 days' })); + expect(status).toMatchObject({ label: 'Deleted', color: 'default' }); + const { container } = render(<>{status.tooltip}</>); + expect(container).toHaveTextContent('Deleted by automatic cleanup (older than 30 days). It can be downloaded again.'); + }); + test('treats a previously-downloaded video without a recorded reason as Deleted', () => { + const status = statusLabel(makeVideo({ previously_downloaded: true })); + expect(status).toMatchObject({ label: 'Deleted', color: 'default' }); + const { container } = render(<>{status.tooltip}</>); + expect(container).toHaveTextContent('Removed before Youtarr recorded why. It can be downloaded again.'); + }); test('returns Tracked for a plain tracked video', () => { expect(statusLabel(makeVideo())).toEqual({ label: 'Tracked', color: 'default' }); }); @@ -106,6 +153,31 @@ describe('toDownloadFileProps', () => { }); }); +describe('toModalData', () => { + test('shows a present file as downloaded even when YouTube removed the video', () => { + const data = toModalData(makeVideo({ downloaded: true, youtube_removed: true, file_path: '/d/Chan/v [v1].mp4' })); + expect(data.status).toBe('downloaded'); + expect(data.isDownloaded).toBe(true); + }); + + test('shows a previously downloaded video whose file was not found as missing, YouTube-removed or not', () => { + const data = toModalData(makeVideo({ previously_downloaded: true, youtube_removed: true, removed_reason: 'not_found' })); + expect(data.status).toBe('missing'); + }); + + test('shows a previously downloaded video Youtarr removed as deleted', () => { + expect(toModalData(makeVideo({ previously_downloaded: true, removed_reason: 'user_deleted' })).status).toBe('deleted'); + }); + + test('shows a never downloaded video that YouTube removed as missing', () => { + expect(toModalData(makeVideo({ youtube_removed: true })).status).toBe('missing'); + }); + + test('shows a tracked video as never downloaded', () => { + expect(toModalData(makeVideo()).status).toBe('never_downloaded'); + }); +}); + describe('PublishedDate', () => { test('renders an italic Unknown when there is no date', () => { render(<PublishedDate value={null} />); diff --git a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx index 6c34c032a..35e86ec3a 100644 --- a/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx +++ b/client/src/components/PlaylistPage/components/playlistVideoHelpers.tsx @@ -2,15 +2,34 @@ import React from 'react'; import { CalendarToday as CalendarTodayIcon } from '../../../lib/icons'; import { PlaylistSortOrder } from '../../../hooks/usePlaylistDetail'; import { PlaylistVideo } from '../../../types/playlist'; +import { VideoModalData } from '../../shared/VideoModal/types'; +import { removalFromRow, removedState } from '../../../utils/videoRemoval'; +import RemovalDescription from '../../shared/RemovalDescription'; export type PlaylistVideoStatusColor = 'success' | 'default' | 'warning' | 'error' | 'info'; +export interface PlaylistVideoStatus { + label: string; + color: PlaylistVideoStatusColor; + // Plain-language explanation for a removed video, shown on hover or tap. + tooltip?: React.ReactNode; +} + // Ignored videos stay downloadable: explicitly selecting one is an intentional // override of the ignore. 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; @@ -28,14 +47,64 @@ export function toDownloadFileProps(v: PlaylistVideo): { }; } -export function statusLabel(v: PlaylistVideo): { label: string; color: PlaylistVideoStatusColor } { +// The modal's status follows the file first: a present file is downloaded +// whatever YouTube did since, a previously downloaded one reads its removal +// state, and only a never-downloaded video that YouTube removed is missing. +export function toModalData(v: PlaylistVideo): VideoModalData { + const status: VideoModalData['status'] = v.downloaded + ? 'downloaded' + : v.previously_downloaded + ? removedState(v.removed_reason) + : v.youtube_removed + ? 'missing' + : 'never_downloaded'; + return { + removal: removalFromRow(v), + youtubeId: v.youtube_id, + title: v.title || v.youtube_id, + channelName: v.channel_name || '', + thumbnailUrl: v.thumbnail || `https://i.ytimg.com/vi/${v.youtube_id}/hqdefault.jpg`, + duration: v.duration, + publishedAt: v.published_at, + addedAt: v.downloaded_at ?? null, + mediaType: 'video', + status, + isDownloaded: v.downloaded, + ...toDownloadFileProps(v), + isProtected: false, + isIgnored: v.ignored, + normalizedRating: null, + ratingSource: null, + databaseId: v.video_id, + channelId: v.channel_id, + }; +} + +export function statusLabel(v: PlaylistVideo): PlaylistVideoStatus { if (v.activity) return { label: v.activity === 'queued' ? 'Queued…' : 'Downloading…', color: 'info' }; // "Excluded" is the user-facing name for the per-playlist `ignored` flag: // left out of this playlist's downloads, server sync, and M3U files. if (v.ignored) return { label: 'Excluded', color: 'warning' }; if (v.youtube_removed) return { label: 'Removed on YT', color: 'error' }; if (v.downloaded) return { label: 'Downloaded', color: 'success' }; - if (v.previously_downloaded) return { label: 'Missing', color: 'error' }; + if (v.previously_downloaded) { + // Missing (file not found) is a warning; deleted (removed by Youtarr) is not. + const state = removedState(v.removed_reason); + const tooltip = ( + <RemovalDescription + row={{ + removed: true, + removed_reason: v.removed_reason, + removed_at: v.removed_at, + removed_detail: v.removed_detail, + filePath: v.file_path, + }} + /> + ); + return state === 'missing' + ? { label: 'Missing', color: 'error', tooltip } + : { label: 'Deleted', color: 'default', tooltip }; + } return { label: 'Tracked', color: 'default' }; } 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/Settings/MaintenanceSection.tsx b/client/src/components/Settings/MaintenanceSection.tsx index 4a327a3c0..ca5b99c7f 100644 --- a/client/src/components/Settings/MaintenanceSection.tsx +++ b/client/src/components/Settings/MaintenanceSection.tsx @@ -1,10 +1,13 @@ import { ScheduleSummary } from '../Configuration/sections/components/ScheduleSummary'; import { ConfigState } from '../Configuration/types'; -import React from 'react'; +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; import { Alert, Button, CircularProgress, Typography } from '../ui'; import { ConfigurationCard } from '../Configuration/common/ConfigurationCard'; import { useRescanStatus } from '../../hooks/useRescanStatus'; import { formatDateTime } from '../../utils/formatters'; +import { REMOVED_VIDEOS_ANCHOR } from '../../utils/videoRemoval'; +import RemovedVideosCard from './components/RemovedVideosCard'; interface MaintenanceSectionProps { token: string | null; @@ -15,6 +18,13 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { const { running, lastRun, loading, error, triggerRescan } = useRescanStatus(token); const persistentError = !running && lastRun?.status === 'error' ? lastRun.errorMessage : null; const transientError = error && error !== persistentError ? error : null; + const { hash } = useLocation(); + + // Links from the Videos page land on the deleted and missing videos card. + useEffect(() => { + if (hash !== `#${REMOVED_VIDEOS_ANCHOR}`) return; + document.getElementById(REMOVED_VIDEOS_ANCHOR)?.scrollIntoView?.({ block: 'start' }); + }, [hash]); let statusLine: React.ReactNode; if (running) { @@ -32,6 +42,7 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { Last rescan: {formatDateTime(lastRun.completedAt)} ({lastRun.trigger}). Updated{' '} {lastRun.videosUpdated} videos, marked {lastRun.videosMarkedMissing} missing. {lastRun.status === 'timed-out' && ' (timed out; click to continue)'} + {lastRun.libraryCheckSkipped && ' The downloads folder check was skipped.'} </Typography> ); } else { @@ -43,6 +54,7 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { } return ( + <div className="flex flex-col gap-6"> <ConfigurationCard title="Rescan files on disk"> <div className="flex flex-col gap-4"> <Typography variant="body2" color="text.secondary"> @@ -76,6 +88,15 @@ export function MaintenanceSection({ token, config }: MaintenanceSectionProps) { )} </div> </ConfigurationCard> + + <section id={REMOVED_VIDEOS_ANCHOR} aria-label="Deleted and missing videos" className="scroll-mt-24"> + <RemovedVideosCard + token={token} + onRescanAnyway={() => triggerRescan({ ignoreLibraryCheck: true })} + rescanRunning={running} + /> + </section> + </div> ); } diff --git a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx index 06c54cb59..06bca2915 100644 --- a/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx +++ b/client/src/components/Settings/__tests__/MaintenanceSection.test.tsx @@ -5,17 +5,29 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MaintenanceSection } from '../MaintenanceSection'; import { UseRescanStatusReturn, RescanLastRun } from '../../../hooks/useRescanStatus'; +import type { RemovedVideosLibraryCheck } from '../hooks/useRemovedVideos'; jest.mock('../../../hooks/useRescanStatus', () => ({ useRescanStatus: jest.fn() })); +jest.mock('../hooks/useRemovedVideos', () => ({ + useRemovedVideos: jest.fn() +})); + const { useRescanStatus } = require('../../../hooks/useRescanStatus') as { useRescanStatus: jest.Mock<UseRescanStatusReturn, [string | null]>; }; +const { useRemovedVideos } = require('../hooks/useRemovedVideos') as { + useRemovedVideos: jest.Mock; +}; -function setup(overrides: Partial<UseRescanStatusReturn> = {}) { - const triggerRescan = jest.fn().mockResolvedValue(undefined); +function setup( + overrides: Partial<UseRescanStatusReturn> = {}, + libraryCheck: RemovedVideosLibraryCheck = { ok: true, reason: null, path: null, message: null }, + route = '/settings/maintenance' +) { + const triggerRescan = jest.fn().mockResolvedValue({ started: true }); useRescanStatus.mockReturnValue({ running: false, lastRun: null, @@ -24,7 +36,17 @@ function setup(overrides: Partial<UseRescanStatusReturn> = {}) { triggerRescan, ...overrides }); - renderWithProviders(<MaintenanceSection token="tok" config={DEFAULT_CONFIG} />); + useRemovedVideos.mockReturnValue({ + summary: { deleted: 2, missing: 1, legacy: 0, libraryCheck }, + loading: false, + error: null, + refetch: jest.fn(), + markAllMissingDeleted: jest.fn(), + emptyTrash: jest.fn(), + actionPending: false, + actionError: null, + }); + renderWithProviders(<MaintenanceSection token="tok" config={DEFAULT_CONFIG} />, { initialEntries: [route] }); return { triggerRescan }; } @@ -37,6 +59,56 @@ describe('MaintenanceSection', () => { expect(screen.getByText(/no rescan has run yet/i)).toBeInTheDocument(); }); + test('hosts the deleted and missing videos card below the rescan card', () => { + setup(); + expect(screen.getByText('Deleted and missing videos')).toBeInTheDocument(); + expect(screen.getByText('Deleted: 2. Missing: 1.')).toBeInTheDocument(); + }); + + describe('linked from the Videos page', () => { + const original = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView'); + let scrolledTo: Element[]; + + beforeEach(() => { + scrolledTo = []; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + writable: true, + value: function scrollIntoView(this: Element) { + scrolledTo.push(this); + }, + }); + }); + + afterEach(() => { + if (original) { + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', original); + } else { + delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView; + } + }); + + test('scrolls to the deleted and missing videos card', () => { + setup({}, undefined, '/settings/maintenance#deleted-and-missing'); + expect(scrolledTo).toEqual([screen.getByRole('region', { name: 'Deleted and missing videos' })]); + }); + + test('stays at the top of the page without the link', () => { + setup(); + expect(scrolledTo).toEqual([]); + }); + }); + + test('wires Rescan anyway to a rescan that skips the downloads folder check', async () => { + const user = userEvent.setup(); + const { triggerRescan } = setup({}, { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder is empty.' }); + + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + + expect(triggerRescan).toHaveBeenCalledWith({ ignoreLibraryCheck: true }); + }); + test('renders idle with last-run summary', () => { const lastRun: RescanLastRun = { startedAt: '2026-05-04T15:00:00.000Z', @@ -54,6 +126,23 @@ describe('MaintenanceSection', () => { expect(screen.getByText(/marked 2 missing/i)).toBeInTheDocument(); }); + test('says when the last rescan skipped the downloads folder check', () => { + const lastRun: RescanLastRun = { + startedAt: '2026-05-04T15:00:00.000Z', + completedAt: '2026-05-04T15:01:00.000Z', + trigger: 'manual', + status: 'completed', + videosUpdated: 0, + videosMarkedMissing: 3, + videosScanned: 100, + filesFoundOnDisk: 97, + errorMessage: null, + libraryCheckSkipped: true + }; + setup({ lastRun }); + expect(screen.getByText(/The downloads folder check was skipped\./)).toBeInTheDocument(); + }); + test('disables button while running', () => { setup({ running: true }); expect(screen.getByRole('button', { name: /rescan files on disk/i })).toBeDisabled(); diff --git a/client/src/components/Settings/components/EmptyTrashDialog.tsx b/client/src/components/Settings/components/EmptyTrashDialog.tsx new file mode 100644 index 000000000..1084aca79 --- /dev/null +++ b/client/src/components/Settings/components/EmptyTrashDialog.tsx @@ -0,0 +1,208 @@ +import React, { useEffect, useId, useRef, useState } from 'react'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '../../ui'; +import { Checkbox, FormControlLabel, RadioGroup, RadioGroupItem } from '../../ui/form'; +import { Trash2 as TrashIcon } from '../../../lib/icons'; +import type { EmptyTrashOptions, TrashState } from '../hooks/useRemovedVideos'; + +type WhichChoice = TrashState | 'both'; +type ArchiveChoice = 'keep' | 'remove'; + +interface EmptyTrashDialogProps { + open: boolean; + onClose: () => void; + onConfirm: (options: EmptyTrashOptions) => void; + counts: { deleted: number; missing: number }; + // Why the missing choices are unavailable (the downloads folder looks unmounted), or null. + missingDisabledReason: string | null; + // Why archive entries cannot be removed right now (a download is running), or null. + archiveRemovalDisabledReason: string | null; + pending: boolean; +} + +// The first choice that is both available and non-empty, so the dialog never +// opens on a choice the user cannot confirm. +function defaultChoice(counts: { deleted: number; missing: number }, missingDisabled: boolean): WhichChoice { + return counts.deleted === 0 && !missingDisabled ? 'missing' : 'deleted'; +} + +const STATES_FOR: Record<WhichChoice, TrashState[]> = { + deleted: ['deleted'], + missing: ['missing'], + both: ['deleted', 'missing'], +}; + +const videos = (n: number) => (n === 1 ? 'video' : 'videos'); + +interface ChoiceProps { + id: string; + value: string; + label: string; + help: string; + disabled?: boolean; +} + +// One radio with its label and a line of help beneath; the help stays out of +// the label so the radio's accessible name is just the choice. +function Choice({ id, value, label, help, disabled = false }: ChoiceProps) { + return ( + <div className="flex items-start gap-2"> + <RadioGroupItem id={id} value={value} disabled={disabled} className="mt-1 shrink-0" /> + <div className="min-w-0"> + <label htmlFor={id} className={`text-sm font-medium ${disabled ? 'text-muted-foreground' : 'cursor-pointer'}`}> + {label} + </label> + <Typography component="p" variant="caption" color="text.secondary"> + {help} + </Typography> + </div> + </div> + ); +} + +// Confirms a purge: which videos, what happens to the download archive, what +// it does, and that it cannot be undone. +const EmptyTrashDialog: React.FC<EmptyTrashDialogProps> = ({ + open, + onClose, + onConfirm, + counts, + missingDisabledReason, + archiveRemovalDisabledReason, + pending, +}) => { + const id = useId(); + const missingDisabled = missingDisabledReason !== null || counts.missing === 0; + const [which, setWhich] = useState<WhichChoice>(() => defaultChoice(counts, missingDisabled)); + const [archive, setArchive] = useState<ArchiveChoice>('keep'); + const [acknowledged, setAcknowledged] = useState(false); + + // Every opening starts from the safe defaults. Only an opening resets, from + // the counts current at that moment: the ref is refreshed after each render + // (never during one), so a parent re-render cannot wipe the user's choices. + const openingChoice = useRef(which); + useEffect(() => { + openingChoice.current = defaultChoice(counts, missingDisabled); + }); + useEffect(() => { + if (open) { + setWhich(openingChoice.current); + setArchive('keep'); + setAcknowledged(false); + } + }, [open]); + + // Archive removal is refused while a download runs; the choice falls back to keep. + const archiveRemovalDisabled = archiveRemovalDisabledReason !== null; + const effectiveArchive: ArchiveChoice = archiveRemovalDisabled ? 'keep' : archive; + const total = counts.deleted + counts.missing; + const count = which === 'deleted' ? counts.deleted : which === 'missing' ? counts.missing : total; + + return ( + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> + <DialogTitle> + <TrashIcon size={20} className="shrink-0" /> + Empty trash + </DialogTitle> + + <DialogContent> + <div className="space-y-5"> + <fieldset className="space-y-3"> + <legend className="text-sm font-semibold mb-2">Which videos</legend> + <RadioGroup value={which} onValueChange={(value) => setWhich(value as WhichChoice)} className="space-y-3"> + <Choice + id={`${id}-deleted`} + value="deleted" + label={`Deleted (${counts.deleted})`} + help="Removed by Youtarr or marked as deleted." + /> + <Choice + id={`${id}-missing`} + value="missing" + label={`Missing (${counts.missing})`} + help={missingDisabledReason ?? 'Expected on disk but not found.'} + disabled={missingDisabled} + /> + <Choice + id={`${id}-both`} + value="both" + label={`Both (${total})`} + help="Every video whose file is gone." + disabled={missingDisabled} + /> + </RadioGroup> + </fieldset> + + <fieldset className="space-y-3"> + <legend className="text-sm font-semibold mb-2">Download archive</legend> + <RadioGroup value={effectiveArchive} onValueChange={(value) => setArchive(value as ArchiveChoice)} className="space-y-3"> + <Choice + id={`${id}-keep`} + value="keep" + label="Keep them in the download archive (recommended)" + help="Automatic channel and playlist downloads keep skipping them, as they do now." + /> + <Choice + id={`${id}-remove`} + value="remove" + label="Remove them from the download archive" + help={archiveRemovalDisabledReason ?? 'Automatic downloads treat them as new and may download them again at the next run.'} + disabled={archiveRemovalDisabled} + /> + </RadioGroup> + </fieldset> + + <div> + <Typography variant="body2" className="font-semibold mb-1"> + What happens + </Typography> + <ul className="list-disc pl-5 space-y-1"> + <Typography component="li" variant="body2" color="text.secondary"> + They disappear from the Videos page and from Download History. + </Typography> + <Typography component="li" variant="body2" color="text.secondary"> + Their channel and playlist pages show them as not downloaded. + </Typography> + <Typography component="li" variant="body2" color="text.secondary"> + Watch history Youtarr synced for them from your media servers is removed. + </Typography> + <Typography component="li" variant="body2" color="text.secondary"> + No files in your downloads folder are touched. If a missing video's file turns up later, Youtarr will not know about it; download it again instead. + </Typography> + </ul> + </div> + + <Alert severity="error">This cannot be undone.</Alert> + + <FormControlLabel + control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />} + label="I understand this cannot be undone" + /> + </div> + </DialogContent> + + <DialogActions> + <Button onClick={onClose} variant="contained" color="primary" autoFocus> + Cancel + </Button> + <Button + onClick={() => onConfirm({ states: STATES_FOR[which], removeFromArchive: effectiveArchive === 'remove' })} + variant="outlined" + color="error" + disabled={!acknowledged || count === 0 || pending} + > + Empty trash ({count} {videos(count)}) + </Button> + </DialogActions> + </Dialog> + ); +}; + +export default EmptyTrashDialog; diff --git a/client/src/components/Settings/components/RemovedVideosCard.tsx b/client/src/components/Settings/components/RemovedVideosCard.tsx new file mode 100644 index 000000000..045dfcdec --- /dev/null +++ b/client/src/components/Settings/components/RemovedVideosCard.tsx @@ -0,0 +1,252 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '../../ui'; +import { Checkbox, FormControlLabel } from '../../ui/form'; +import { ConfigurationCard } from '../../Configuration/common/ConfigurationCard'; +import { useRemovedVideos, type EmptyTrashOptions } from '../hooks/useRemovedVideos'; +import EmptyTrashDialog from './EmptyTrashDialog'; +import RescanAnywayDialog from './RescanAnywayDialog'; + +interface RemovedVideosCardProps { + token: string | null; + // Starts a rescan that skips the unmounted-drive check; Maintenance wires it + // in. Resolves started: false when the server refused or the request failed. + onRescanAnyway?: () => Promise<{ started: boolean }>; + rescanRunning?: boolean; +} + +const RESCAN_NOT_STARTED = 'The rescan did not start. The rescan card above shows why.'; +const RESCAN_STARTED = 'Rescan started. When it finishes, the missing videos can be marked as deleted here.'; +const RESCAN_FINISHED = 'Rescan finished. Videos it could not find now count as missing and can be marked as deleted.'; +const EMPTIED_MYSELF = 'I removed these files myself; they are not on an unmounted drive'; + +const videos = (n: number) => (n === 1 ? 'video' : 'videos'); + +// Check results that can also mean the user emptied the folder on purpose, +// the one case where rescanning anyway, or marking every missing video as +// deleted despite the check, makes sense. +const EMPTY_FOLDER_REASONS = ['empty', 'subfolder-empty']; + +// Settings > Maintenance: what Deleted and Missing mean, how many there are, +// and the two ways to clear them out. +function RemovedVideosCard({ token, onRescanAnyway, rescanRunning = false }: RemovedVideosCardProps) { + const { summary, loading, error, refetch, markAllMissingDeleted, emptyTrash, actionPending, actionError } = useRemovedVideos(token); + const [markAllOpen, setMarkAllOpen] = useState(false); + const [emptyTrashOpen, setEmptyTrashOpen] = useState(false); + const [rescanAnywayOpen, setRescanAnywayOpen] = useState(false); + const [rescanAnywayError, setRescanAnywayError] = useState<string | null>(null); + const [successMessage, setSuccessMessage] = useState<string | null>(null); + const [emptiedAcknowledged, setEmptiedAcknowledged] = useState(false); + // A rescan anyway was asked for and has not finished yet. + const awaitingRescanAnyway = useRef(false); + + const deleted = summary?.deleted ?? 0; + const missing = summary?.missing ?? 0; + const legacy = summary?.legacy ?? 0; + const libraryWarning = summary && !summary.libraryCheck.ok ? summary.libraryCheck.message : null; + // The failing folder holds missing videos, so they are in doubt. + const missingBlocked = summary?.missingActionsBlocked ?? false; + // Missing videos cannot be purged while that holds, so with no deleted ones + // there would be nothing the dialog could offer. + const nothingToPurge = deleted === 0 && (missing === 0 || missingBlocked); + const archiveRemovalDisabledReason = summary?.downloadsActive ? 'Unavailable while a download is running.' : null; + // The folder may simply have been emptied on purpose: the user can say so. + const folderLooksEmpty = libraryWarning !== null && EMPTY_FOLDER_REASONS.includes(summary?.libraryCheck.reason ?? ''); + const canRescanAnyway = Boolean(onRescanAnyway) && folderLooksEmpty; + const needsEmptiedAcknowledgement = missingBlocked && folderLooksEmpty; + + // A rescan that just finished may have marked videos missing: reload the counts. + const wasRescanRunning = useRef(rescanRunning); + useEffect(() => { + if (wasRescanRunning.current && !rescanRunning) { + void refetch(); + if (awaitingRescanAnyway.current) { + awaitingRescanAnyway.current = false; + setSuccessMessage(RESCAN_FINISHED); + } + } + wasRescanRunning.current = rescanRunning; + }, [rescanRunning, refetch]); + + const handleRescanAnyway = async () => { + if (!onRescanAnyway) return; + setSuccessMessage(null); + setRescanAnywayError(null); + awaitingRescanAnyway.current = true; + const { started } = await onRescanAnyway(); + setRescanAnywayOpen(false); + if (!started) { + awaitingRescanAnyway.current = false; + // A refused start flips the rescan's running state on and off, which + // may already have reported it finished. + setSuccessMessage(null); + setRescanAnywayError(RESCAN_NOT_STARTED); + } else if (awaitingRescanAnyway.current) { + // Unless a quick rescan already finished and said so. + setSuccessMessage(RESCAN_STARTED); + } + }; + + const openMarkAll = () => { + setEmptiedAcknowledged(false); + setMarkAllOpen(true); + }; + + const handleMarkAll = async () => { + setSuccessMessage(null); + setRescanAnywayError(null); + // This result is the news now; a rescan finishing later must not replace it. + awaitingRescanAnyway.current = false; + const result = await markAllMissingDeleted({ ignoreLibraryCheck: needsEmptiedAcknowledgement }); + setMarkAllOpen(false); + if (result) setSuccessMessage(`Marked ${result.updated} ${videos(result.updated)} as deleted.`); + }; + + const handleEmptyTrash = async (options: EmptyTrashOptions) => { + setSuccessMessage(null); + setRescanAnywayError(null); + awaitingRescanAnyway.current = false; + const result = await emptyTrash(options); + setEmptyTrashOpen(false); + if (!result) return; + const archive = result.archiveEntriesRemoved > 0 + ? `, ${result.archiveEntriesRemoved} download archive ${result.archiveEntriesRemoved === 1 ? 'entry' : 'entries'} removed` + : ''; + setSuccessMessage(`Emptied trash: ${result.purged} ${videos(result.purged)} removed${archive}.`); + }; + + return ( + <ConfigurationCard title="Deleted and missing videos"> + <div className="flex flex-col gap-4"> + <Typography variant="body2" color="text.secondary"> + Videos whose files are no longer on disk stay in Youtarr's library so they can be re-downloaded. + Deleted means Youtarr removed the file: a delete in the app, automatic cleanup, or you marked a missing + video as deleted. Missing means Youtarr expected a file and could not find it. The Videos page hides + deleted videos by default and shows missing ones so you can check on them. + </Typography> + + {loading && !summary && ( + <Typography variant="body2" color="text.secondary">Counting deleted and missing videos...</Typography> + )} + {summary && ( + <Typography variant="body2"> + {`Deleted: ${deleted}${legacy > 0 ? ` (${legacy} removed before Youtarr recorded why)` : ''}. Missing: ${missing}.`} + </Typography> + )} + + {libraryWarning && ( + <Alert severity="warning" className="break-words"> + {missingBlocked + ? folderLooksEmpty + ? `${libraryWarning} Until that changes, missing videos stored there cannot be emptied from the trash;` + + ' Mark all missing as deleted asks you to confirm that you removed the files yourself.' + + (canRescanAnyway ? ' Rescan anyway first if some of the videos stored there still show as available.' : '') + : `${libraryWarning} Until that changes, marking or emptying missing videos is disabled.` + : `${libraryWarning} Until that changes, videos stored there are not being marked missing.` + + (canRescanAnyway ? ' If you removed the files yourself, rescan anyway to mark them missing.' : '')} + </Alert> + )} + {error && <Alert severity="error">{error}</Alert>} + {actionError && <Alert severity="error">{actionError}</Alert>} + {rescanAnywayError && <Alert severity="error">{rescanAnywayError}</Alert>} + {successMessage && <Alert severity="success">{successMessage}</Alert>} + + <div className="flex flex-wrap gap-2"> + <Button + variant="outlined" + disabled={!summary || missing === 0 || (missingBlocked && !folderLooksEmpty) || rescanRunning || actionPending} + onClick={openMarkAll} + > + Mark all missing as deleted + </Button> + <Button + variant="outlined" + color="error" + disabled={!summary || nothingToPurge || actionPending} + onClick={() => { + // The download state is a snapshot; refresh it as the dialog opens. + void refetch(); + setEmptyTrashOpen(true); + }} + > + Empty trash... + </Button> + {canRescanAnyway && ( + <Button + variant="outlined" + color="warning" + disabled={rescanRunning || actionPending} + onClick={() => setRescanAnywayOpen(true)} + > + Rescan anyway... + </Button> + )} + </div> + </div> + + <RescanAnywayDialog + open={rescanAnywayOpen} + onClose={() => setRescanAnywayOpen(false)} + onConfirm={() => { void handleRescanAnyway(); }} + pending={rescanRunning} + folderMessage={libraryWarning} + /> + + <Dialog open={markAllOpen} onClose={() => setMarkAllOpen(false)} maxWidth="sm" fullWidth> + <DialogTitle>Mark all missing videos as deleted</DialogTitle> + <DialogContent> + <div className="space-y-3"> + <Typography variant="body2"> + {missing === 1 + ? '1 missing video will be marked as deleted and hidden from the Videos page by default. Its record is kept, so it can still be re-downloaded, and if its file turns up again the rescan marks it downloaded. Only do this if the file is really gone, not sitting on an unmounted drive.' + : `${missing} missing videos will be marked as deleted and hidden from the Videos page by default. Their records are kept, so they can still be re-downloaded, and if a file turns up again the rescan marks it downloaded. Only do this if the files are really gone, not sitting on an unmounted drive.`} + </Typography> + {needsEmptiedAcknowledgement && ( + <> + <Alert severity="warning" className="break-words">{libraryWarning}</Alert> + <FormControlLabel + control={ + <Checkbox checked={emptiedAcknowledged} onChange={(event) => setEmptiedAcknowledged(event.target.checked)} /> + } + label={EMPTIED_MYSELF} + /> + </> + )} + </div> + </DialogContent> + <DialogActions> + <Button onClick={() => setMarkAllOpen(false)} variant="contained" color="primary" autoFocus> + Cancel + </Button> + <Button + onClick={handleMarkAll} + variant="outlined" + color="warning" + disabled={actionPending || (needsEmptiedAcknowledgement && !emptiedAcknowledged)} + > + Mark {missing} as deleted + </Button> + </DialogActions> + </Dialog> + + <EmptyTrashDialog + open={emptyTrashOpen} + onClose={() => setEmptyTrashOpen(false)} + onConfirm={handleEmptyTrash} + counts={{ deleted, missing }} + missingDisabledReason={missingBlocked ? libraryWarning : null} + archiveRemovalDisabledReason={archiveRemovalDisabledReason} + pending={actionPending} + /> + </ConfigurationCard> + ); +} + +export default RemovedVideosCard; diff --git a/client/src/components/Settings/components/RescanAnywayDialog.tsx b/client/src/components/Settings/components/RescanAnywayDialog.tsx new file mode 100644 index 000000000..bd5ca6fa4 --- /dev/null +++ b/client/src/components/Settings/components/RescanAnywayDialog.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '../../ui'; + +interface RescanAnywayDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + pending: boolean; + // The library check's message naming the folder that looks empty, or null. + folderMessage: string | null; +} + +// Confirms a rescan that skips the unmounted-drive check: the escape for a +// downloads folder (or subfolder) the user emptied on purpose. +const RescanAnywayDialog: React.FC<RescanAnywayDialogProps> = ({ open, onClose, onConfirm, pending, folderMessage }) => ( + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> + <DialogTitle>Rescan anyway</DialogTitle> + + <DialogContent> + <div className="space-y-3"> + <Typography variant="body2" className="break-words"> + {folderMessage ?? 'The downloads folder looks empty.'} Youtarr is not marking the videos stored there + missing, in case a drive or network share is unmounted. Only continue if you removed the files yourself. + If a drive or network share is unmounted, mount it instead. + </Typography> + <Typography variant="body2" color="text.secondary"> + Every downloaded video whose file is not found will be marked missing. You can then mark them as deleted + from this card. + </Typography> + <Alert severity="warning"> + Nothing on disk is touched, but with a drive unmounted this would mark every video on it missing. + </Alert> + </div> + </DialogContent> + + <DialogActions> + <Button onClick={onClose} variant="contained" color="primary" autoFocus> + Cancel + </Button> + <Button onClick={onConfirm} variant="outlined" color="warning" disabled={pending}> + Rescan anyway + </Button> + </DialogActions> + </Dialog> +); + +export default RescanAnywayDialog; diff --git a/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx b/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx new file mode 100644 index 000000000..f74bb6eef --- /dev/null +++ b/client/src/components/Settings/components/__tests__/EmptyTrashDialog.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import EmptyTrashDialog from '../EmptyTrashDialog'; +import { renderWithProviders } from '../../../../test-utils'; + +const renderDialog = (overrides: Partial<React.ComponentProps<typeof EmptyTrashDialog>> = {}) => { + const onClose = jest.fn(); + const onConfirm = jest.fn(); + renderWithProviders( + <EmptyTrashDialog + open + onClose={onClose} + onConfirm={onConfirm} + counts={{ deleted: 12, missing: 4 }} + missingDisabledReason={null} + archiveRemovalDisabledReason={null} + pending={false} + {...overrides} + /> + ); + return { onClose, onConfirm, user: userEvent.setup() }; +}; + +const confirmButton = () => screen.getByRole('button', { name: /^Empty trash \(/ }); + +describe('EmptyTrashDialog', () => { + test('opens on Missing when there are no deleted videos', () => { + renderDialog({ counts: { deleted: 0, missing: 4 } }); + + expect(screen.getByRole('radio', { name: 'Missing (4)' })).toHaveAttribute('aria-checked', 'true'); + expect(confirmButton()).toHaveTextContent('Empty trash (4 videos)'); + }); + + test('disables removing archive entries with the reason while a download runs, and keeps them', async () => { + const { onConfirm, user } = renderDialog({ archiveRemovalDisabledReason: 'Unavailable while a download is running.' }); + + expect(screen.getByRole('radio', { name: 'Remove them from the download archive' })).toBeDisabled(); + expect(screen.getByText('Unavailable while a download is running.')).toBeInTheDocument(); + + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(confirmButton()); + expect(onConfirm).toHaveBeenCalledWith({ states: ['deleted'], removeFromArchive: false }); + }); + + test('starts with Deleted and Keep selected, the confirm button disabled until the risk is acknowledged', async () => { + const { user } = renderDialog(); + + expect(screen.getByRole('radio', { name: 'Deleted (12)' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: 'Keep them in the download archive (recommended)' })).toHaveAttribute('aria-checked', 'true'); + expect(confirmButton()).toHaveTextContent('Empty trash (12 videos)'); + expect(confirmButton()).toBeDisabled(); + + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + expect(confirmButton()).toBeEnabled(); + }); + + test('spells out what happens and that it cannot be undone', () => { + renderDialog(); + expect(screen.getByText('They disappear from the Videos page and from Download History.')).toBeInTheDocument(); + expect(screen.getByText('Their channel and playlist pages show them as not downloaded.')).toBeInTheDocument(); + expect(screen.getByText('Watch history Youtarr synced for them from your media servers is removed.')).toBeInTheDocument(); + expect(screen.getByText("No files in your downloads folder are touched. If a missing video's file turns up later, Youtarr will not know about it; download it again instead.")).toBeInTheDocument(); + expect(screen.getByText('This cannot be undone.')).toBeInTheDocument(); + expect(screen.getByText('Automatic channel and playlist downloads keep skipping them, as they do now.')).toBeInTheDocument(); + expect(screen.getByText('Automatic downloads treat them as new and may download them again at the next run.')).toBeInTheDocument(); + }); + + test('confirms with the chosen states and archive choice', async () => { + const { user, onConfirm } = renderDialog(); + + await user.click(screen.getByRole('radio', { name: 'Both (16)' })); + expect(confirmButton()).toHaveTextContent('Empty trash (16 videos)'); + await user.click(screen.getByRole('radio', { name: 'Remove them from the download archive' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(confirmButton()); + + expect(onConfirm).toHaveBeenCalledWith({ states: ['deleted', 'missing'], removeFromArchive: true }); + }); + + test('confirms missing only, keeping the archive', async () => { + const { user, onConfirm } = renderDialog(); + + await user.click(screen.getByRole('radio', { name: 'Missing (4)' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(confirmButton()); + + expect(confirmButton()).toHaveTextContent('Empty trash (4 videos)'); + expect(onConfirm).toHaveBeenCalledWith({ states: ['missing'], removeFromArchive: false }); + }); + + test('disables the missing choices and shows why while the downloads folder looks unmounted', () => { + renderDialog({ missingDisabledReason: 'The downloads folder is empty.' }); + + expect(screen.getByRole('radio', { name: 'Missing (4)' })).toBeDisabled(); + expect(screen.getByRole('radio', { name: 'Both (16)' })).toBeDisabled(); + expect(screen.getByText('The downloads folder is empty.')).toBeInTheDocument(); + }); + + test('disables the missing choices when there are no missing videos', () => { + renderDialog({ counts: { deleted: 1, missing: 0 } }); + + expect(screen.getByRole('radio', { name: 'Missing (0)' })).toBeDisabled(); + expect(screen.getByRole('radio', { name: 'Both (1)' })).toBeDisabled(); + expect(confirmButton()).toHaveTextContent('Empty trash (1 video)'); + }); + + test('keeps the confirm button disabled while pending', async () => { + const { user } = renderDialog({ pending: true }); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + expect(confirmButton()).toBeDisabled(); + }); + + test('cancels', async () => { + const { user, onClose } = renderDialog(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('renders nothing when closed', () => { + renderDialog({ open: false }); + expect(screen.queryByText('Empty trash')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx new file mode 100644 index 000000000..d66e3d1ab --- /dev/null +++ b/client/src/components/Settings/components/__tests__/RemovedVideosCard.test.tsx @@ -0,0 +1,307 @@ +import React from 'react'; +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import RemovedVideosCard from '../RemovedVideosCard'; +import { renderWithProviders } from '../../../../test-utils'; +import type { RemovedVideosSummary, UseRemovedVideosReturn } from '../../hooks/useRemovedVideos'; + +jest.mock('../../hooks/useRemovedVideos', () => ({ + useRemovedVideos: jest.fn(), +})); + +const { useRemovedVideos } = require('../../hooks/useRemovedVideos') as { + useRemovedVideos: jest.Mock<UseRemovedVideosReturn, [string | null]>; +}; + +const summary = (overrides: Partial<RemovedVideosSummary> = {}): RemovedVideosSummary => ({ + deleted: 12, + missing: 4, + legacy: 0, + libraryCheck: { ok: true, reason: null, path: null, message: null }, + missingActionsBlocked: false, + downloadsActive: false, + ...overrides, +}); + +type CardProps = Partial<React.ComponentProps<typeof RemovedVideosCard>>; + +const EMPTY_ROOT = { ok: false, reason: 'empty', path: '/d', message: 'The downloads folder is empty.' }; +const rescanThatStarts = () => jest.fn().mockResolvedValue({ started: true }); + +function setup( + summaryValue: RemovedVideosSummary | null = summary(), + overrides: Partial<UseRemovedVideosReturn> = {}, + props: CardProps = {} +) { + const hook: UseRemovedVideosReturn = { + summary: summaryValue, + loading: false, + error: null, + refetch: jest.fn().mockResolvedValue(undefined), + markAllMissingDeleted: jest.fn().mockResolvedValue({ updated: 4 }), + emptyTrash: jest.fn().mockResolvedValue({ purged: 16, archiveEntriesRemoved: 16, jobsAffected: 2 }), + actionPending: false, + actionError: null, + ...overrides, + }; + useRemovedVideos.mockReturnValue(hook); + const view = renderWithProviders(<RemovedVideosCard token="tok" {...props} />); + return { hook, user: userEvent.setup(), rerender: view.rerender }; +} + +describe('RemovedVideosCard', () => { + beforeEach(() => jest.clearAllMocks()); + + test('explains the two states and shows the counts', () => { + setup(); + expect(screen.getByText('Deleted and missing videos')).toBeInTheDocument(); + expect(screen.getByText(/Deleted means Youtarr removed the file/)).toBeInTheDocument(); + expect(screen.getByText('Deleted: 12. Missing: 4.')).toBeInTheDocument(); + }); + + test('says how many deleted videos predate the reason being recorded', () => { + setup(summary({ legacy: 3 })); + expect(screen.getByText('Deleted: 12 (3 removed before Youtarr recorded why). Missing: 4.')).toBeInTheDocument(); + }); + + test('disables the missing action with the warning while the downloads folder looks unmounted', () => { + setup(summary({ libraryCheck: { ok: false, reason: 'missing', path: '/d', message: 'The downloads folder does not exist.' }, missingActionsBlocked: true })); + expect(screen.getByText('The downloads folder does not exist. Until that changes, marking or emptying missing videos is disabled.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeEnabled(); + }); + + test('words the warning to fit a message that names several subfolders', () => { + const check = { ok: false, reason: 'subfolder-empty', path: '/d/__Kids', message: 'The subfolders __Kids and __Music are empty.' }; + setup(summary({ libraryCheck: check })); + expect(screen.getByText( + 'The subfolders __Kids and __Music are empty. Until that changes, videos stored there are not being marked missing.' + )).toBeInTheDocument(); + }); + + test('offers Rescan anyway while the downloads folder looks empty', () => { + setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); + expect(screen.getByText(/If you removed the files yourself, rescan anyway to mark them missing\./)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Rescan anyway...' })).toBeEnabled(); + }); + + test('points to Mark all once the emptied folder\'s videos are missing', () => { + setup(summary({ libraryCheck: EMPTY_ROOT, missingActionsBlocked: true }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); + expect(screen.getByText(/Mark all missing as deleted asks you to confirm that you removed the files yourself/)).toBeInTheDocument(); + }); + + test('keeps the missing actions open, without asking, when the failing folder holds no missing videos', async () => { + const { hook, user } = setup(summary({ libraryCheck: EMPTY_ROOT, missingActionsBlocked: false })); + + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Mark 4 as deleted' })); + + expect(hook.markAllMissingDeleted).toHaveBeenCalledWith({ ignoreLibraryCheck: false }); + }); + + test('offers missing videos in Empty trash when the failing folder holds none of them', async () => { + const { user } = setup(summary({ libraryCheck: EMPTY_ROOT, missingActionsBlocked: false })); + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + expect(screen.getByRole('radio', { name: 'Missing (4)' })).toBeEnabled(); + }); + + test('disables Mark all while a rescan is running, so it cannot mark a half-scanned set', () => { + setup(summary(), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: true }); + expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); + }); + + test('lets Mark all look past an empty folder once the user says they emptied it', async () => { + const { hook, user } = setup(summary({ libraryCheck: EMPTY_ROOT, missingActionsBlocked: true })); + + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + const confirm = screen.getByRole('button', { name: 'Mark 4 as deleted' }); + expect(confirm).toBeDisabled(); + await user.click(screen.getByRole('checkbox', { name: 'I removed these files myself; they are not on an unmounted drive' })); + await user.click(confirm); + + expect(hook.markAllMissingDeleted).toHaveBeenCalledWith({ ignoreLibraryCheck: true }); + }); + + test('speaks of a single missing video in the singular', async () => { + const { user } = setup(summary({ missing: 1 })); + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + expect(screen.getByText(/1 missing video will be marked as deleted.*Its record is kept, so it can still be re-downloaded/)).toBeInTheDocument(); + }); + + test('does not offer Rescan anyway when the downloads folder is missing', () => { + const missingRoot = { ok: false, reason: 'missing', path: '/d', message: 'The downloads folder does not exist.' }; + setup(summary({ libraryCheck: missingRoot }), {}, { onRescanAnyway: rescanThatStarts(), rescanRunning: false }); + expect(screen.queryByRole('button', { name: 'Rescan anyway...' })).not.toBeInTheDocument(); + }); + + test('runs the rescan anyway after a confirmation that explains it', async () => { + const onRescanAnyway = rescanThatStarts(); + const { user } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + expect(screen.getByText(/Only continue if you removed the files yourself/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + + expect(onRescanAnyway).toHaveBeenCalledTimes(1); + expect(await screen.findByText('Rescan started. When it finishes, the missing videos can be marked as deleted here.')).toBeInTheDocument(); + }); + + test('says when the rescan anyway has finished, replacing the started message', async () => { + const onRescanAnyway = rescanThatStarts(); + const { user, rerender } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + expect(await screen.findByText(/^Rescan started\./)).toBeInTheDocument(); + + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning />); + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning={false} />); + + expect(screen.getByText('Rescan finished. Videos it could not find now count as missing and can be marked as deleted.')).toBeInTheDocument(); + expect(screen.queryByText(/^Rescan started\./)).not.toBeInTheDocument(); + }); + + // The rescan hook flips running on optimistically and off again on a refusal, + // which can land before the refusal itself does. + test('does not claim a rescan finished when it was refused after an optimistic start', async () => { + let refuse: (outcome: { started: boolean }) => void = () => {}; + const onRescanAnyway = jest.fn(() => new Promise<{ started: boolean }>((resolve) => { refuse = resolve; })); + const { user, rerender } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning />); + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning={false} />); + await act(async () => refuse({ started: false })); + + expect(screen.getByText('The rescan did not start. The rescan card above shows why.')).toBeInTheDocument(); + expect(screen.queryByText(/^Rescan finished\./)).not.toBeInTheDocument(); + }); + + test('does not claim a rescan started when it was refused', async () => { + const onRescanAnyway = jest.fn().mockResolvedValue({ started: false }); + const { user } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + + expect(await screen.findByText('The rescan did not start. The rescan card above shows why.')).toBeInTheDocument(); + expect(screen.queryByText(/Rescan started\./)).not.toBeInTheDocument(); + }); + + test('reloads the summary when Empty trash opens, so the download state is current', async () => { + const { hook, user } = setup(); + + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + + expect(hook.refetch).toHaveBeenCalledTimes(1); + }); + + test('clears a stale rescan message when another action runs', async () => { + const onRescanAnyway = jest.fn().mockResolvedValue({ started: false }); + const { user } = setup(summary({ libraryCheck: EMPTY_ROOT }), {}, { onRescanAnyway, rescanRunning: false }); + await user.click(screen.getByRole('button', { name: 'Rescan anyway...' })); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + expect(await screen.findByText('The rescan did not start. The rescan card above shows why.')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(screen.getByRole('button', { name: /^Empty trash \(/ })); + + expect(await screen.findByText(/Emptied trash:/)).toBeInTheDocument(); + expect(screen.queryByText('The rescan did not start. The rescan card above shows why.')).not.toBeInTheDocument(); + }); + + test('reloads the counts when a rescan finishes', () => { + const onRescanAnyway = rescanThatStarts(); + const { hook, rerender } = setup(summary(), {}, { onRescanAnyway, rescanRunning: true }); + expect(hook.refetch).not.toHaveBeenCalled(); + + rerender(<RemovedVideosCard token="tok" onRescanAnyway={onRescanAnyway} rescanRunning={false} />); + + expect(hook.refetch).toHaveBeenCalledTimes(1); + }); + + test('disables Empty trash when only missing videos remain and the folder looks unmounted', () => { + setup(summary({ deleted: 0, missing: 5, libraryCheck: EMPTY_ROOT, missingActionsBlocked: true })); + expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeDisabled(); + }); + + test('keeps Empty trash available for missing videos alone while the folder is mounted', () => { + setup(summary({ deleted: 0, missing: 5 })); + expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeEnabled(); + }); + + test('tells the dialog when a download is running so archive removal is disabled up front', async () => { + const { user } = setup(summary({ downloadsActive: true })); + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + expect(screen.getByRole('radio', { name: 'Remove them from the download archive' })).toBeDisabled(); + expect(screen.getByText('Unavailable while a download is running.')).toBeInTheDocument(); + }); + + test('disables both actions when the trash is empty', () => { + setup(summary({ deleted: 0, missing: 0 })); + expect(screen.getByRole('button', { name: 'Mark all missing as deleted' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Empty trash...' })).toBeDisabled(); + }); + + test('marks all missing videos as deleted after a confirmation that explains it', async () => { + const { hook, user } = setup(); + + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + expect(screen.getByText('Mark all missing videos as deleted')).toBeInTheDocument(); + expect(screen.getByText(/4 missing videos will be marked as deleted and hidden from the Videos page by default\./)).toBeInTheDocument(); + expect(screen.getByText(/Only do this if the files are really gone, not sitting on an unmounted drive\./)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Mark 4 as deleted' })); + + expect(hook.markAllMissingDeleted).toHaveBeenCalledWith({ ignoreLibraryCheck: false }); + expect(await screen.findByText('Marked 4 videos as deleted.')).toBeInTheDocument(); + }); + + test('speaks of one file when marking the only missing video', async () => { + const { user } = setup(summary({ missing: 1 })); + + await user.click(screen.getByRole('button', { name: 'Mark all missing as deleted' })); + + expect(screen.getByText(/Only do this if the file is really gone, not sitting on an unmounted drive\./)).toBeInTheDocument(); + }); + + test('empties the trash through the dialog and reports what went', async () => { + const { hook, user } = setup(); + + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + expect(screen.getByText('Empty trash')).toBeInTheDocument(); + await user.click(screen.getByRole('radio', { name: 'Both (16)' })); + await user.click(screen.getByRole('radio', { name: 'Remove them from the download archive' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(screen.getByRole('button', { name: 'Empty trash (16 videos)' })); + + expect(hook.emptyTrash).toHaveBeenCalledWith({ states: ['deleted', 'missing'], removeFromArchive: true }); + expect(await screen.findByText('Emptied trash: 16 videos removed, 16 download archive entries removed.')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('Empty trash')).not.toBeInTheDocument()); + }); + + test('reports an emptied trash without archive changes', async () => { + const { user } = setup(summary(), { + emptyTrash: jest.fn().mockResolvedValue({ purged: 1, archiveEntriesRemoved: 0, jobsAffected: 0 }), + }); + + await user.click(screen.getByRole('button', { name: 'Empty trash...' })); + await user.click(screen.getByRole('checkbox', { name: 'I understand this cannot be undone' })); + await user.click(screen.getByRole('button', { name: 'Empty trash (12 videos)' })); + + expect(await screen.findByText('Emptied trash: 1 video removed.')).toBeInTheDocument(); + }); + + test('shows an action error', () => { + setup(summary(), { actionError: 'A purge is already running' }); + expect(screen.getByText('A purge is already running')).toBeInTheDocument(); + }); + + test('shows a load error', () => { + setup(null, { error: 'Failed to load the deleted and missing video counts' }); + expect(screen.getByText('Failed to load the deleted and missing video counts')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx b/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx new file mode 100644 index 000000000..a6059ecdf --- /dev/null +++ b/client/src/components/Settings/components/__tests__/RescanAnywayDialog.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import RescanAnywayDialog from '../RescanAnywayDialog'; +import { renderWithProviders } from '../../../../test-utils'; + +const renderDialog = (overrides: Partial<React.ComponentProps<typeof RescanAnywayDialog>> = {}) => { + const onClose = jest.fn(); + const onConfirm = jest.fn(); + renderWithProviders( + <RescanAnywayDialog + open + onClose={onClose} + onConfirm={onConfirm} + pending={false} + folderMessage="The subfolder __Kids is empty." + {...overrides} + /> + ); + return { onClose, onConfirm, user: userEvent.setup() }; +}; + +describe('RescanAnywayDialog', () => { + test('names the folder that looks empty', () => { + renderDialog(); + expect(screen.getByText(/The subfolder __Kids is empty\./)).toBeInTheDocument(); + }); + + test('explains when to use it and what it does', () => { + renderDialog(); + expect(screen.getByText(/Only continue if you removed the files yourself\./)).toBeInTheDocument(); + expect(screen.getByText(/If a drive or network share is unmounted, mount it instead\./)).toBeInTheDocument(); + expect(screen.getByText(/Every downloaded video whose file is not found will be marked missing\./)).toBeInTheDocument(); + }); + + test('confirms', async () => { + const { onConfirm, user } = renderDialog(); + await user.click(screen.getByRole('button', { name: 'Rescan anyway' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + test('cancels', async () => { + const { onClose, user } = renderDialog(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('disables the confirm button while pending', () => { + renderDialog({ pending: true }); + expect(screen.getByRole('button', { name: 'Rescan anyway' })).toBeDisabled(); + }); + + test('renders nothing when closed', () => { + renderDialog({ open: false }); + expect(screen.queryByText('Rescan anyway')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts b/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts new file mode 100644 index 000000000..267b46f22 --- /dev/null +++ b/client/src/components/Settings/hooks/__tests__/useRemovedVideos.test.ts @@ -0,0 +1,125 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; + +jest.mock('axios', () => ({ + get: jest.fn(), + post: jest.fn(), +})); + +const axios = require('axios'); + +const { useRemovedVideos } = require('../useRemovedVideos'); + +const SUMMARY = { + deleted: 12, + missing: 4, + legacy: 3, + libraryCheck: { ok: true, reason: null, path: null, message: null }, +}; + +describe('useRemovedVideos', () => { + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: SUMMARY }); + }); + + test('loads the summary on mount with the token', async () => { + const { result } = renderHook(() => useRemovedVideos('tok')); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(axios.get).toHaveBeenCalledWith('/api/videos/removed-summary', { headers: { 'x-access-token': 'tok' } }); + expect(result.current.summary).toEqual(SUMMARY); + expect(result.current.error).toBeNull(); + }); + + test('does not load without a token', () => { + renderHook(() => useRemovedVideos(null)); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test('exposes a load error', async () => { + axios.get.mockRejectedValueOnce(new Error('offline')); + const { result } = renderHook(() => useRemovedVideos('tok')); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.error).toBe('Failed to load the deleted and missing video counts'); + expect(result.current.summary).toBeNull(); + }); + + test('markAllMissingDeleted posts all: true, returns the count, and reloads the summary', async () => { + axios.post.mockResolvedValueOnce({ data: { updated: 4 } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let outcome: { updated: number } | null = null; + await act(async () => { + outcome = await result.current.markAllMissingDeleted(); + }); + + expect(outcome).toEqual({ updated: 4 }); + expect(axios.post).toHaveBeenCalledWith('/api/videos/mark-deleted', { all: true }, { headers: { 'x-access-token': 'tok' } }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(result.current.actionPending).toBe(false); + }); + + test('markAllMissingDeleted can ask the server to look past an empty downloads folder', async () => { + axios.post.mockResolvedValueOnce({ data: { updated: 4 } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.markAllMissingDeleted({ ignoreLibraryCheck: true }); + }); + + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/mark-deleted', + { all: true, ignoreLibraryCheck: true }, + { headers: { 'x-access-token': 'tok' } } + ); + }); + + test('emptyTrash posts the options and returns the result', async () => { + axios.post.mockResolvedValueOnce({ data: { purged: 16, archiveEntriesRemoved: 16, jobsAffected: 3 } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let outcome: unknown = null; + await act(async () => { + outcome = await result.current.emptyTrash({ states: ['deleted', 'missing'], removeFromArchive: true }); + }); + + expect(outcome).toEqual({ purged: 16, archiveEntriesRemoved: 16, jobsAffected: 3 }); + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/purge-removed', + { states: ['deleted', 'missing'], removeFromArchive: true }, + { headers: { 'x-access-token': 'tok' } } + ); + }); + + test('surfaces the server message when an action is refused', async () => { + axios.post.mockRejectedValueOnce({ response: { status: 409, data: { error: 'The downloads folder is empty.', reason: 'library-unavailable' } } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + + let outcome: unknown = 'unset'; + await act(async () => { + outcome = await result.current.emptyTrash({ states: ['missing'], removeFromArchive: false }); + }); + + expect(outcome).toBeNull(); + expect(result.current.actionError).toBe('The downloads folder is empty.'); + expect(result.current.actionPending).toBe(false); + }); + + test('reloads the summary after a failed purge, which may have forgotten some videos', async () => { + axios.post.mockRejectedValueOnce({ response: { status: 500, data: { error: 'Failed to empty the trash' } } }); + const { result } = renderHook(() => useRemovedVideos('tok')); + await waitFor(() => expect(result.current.loading).toBe(false)); + axios.get.mockResolvedValueOnce({ data: { ...SUMMARY, deleted: 2 } }); + + await act(async () => { + await result.current.emptyTrash({ states: ['deleted'], removeFromArchive: false }); + }); + + expect(result.current.summary?.deleted).toBe(2); + }); +}); diff --git a/client/src/components/Settings/hooks/useRemovedVideos.ts b/client/src/components/Settings/hooks/useRemovedVideos.ts new file mode 100644 index 000000000..9f8070ed3 --- /dev/null +++ b/client/src/components/Settings/hooks/useRemovedVideos.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react'; +import axios from 'axios'; + +export interface RemovedVideosLibraryCheck { + ok: boolean; + reason: string | null; + path: string | null; + message: string | null; +} + +export interface RemovedVideosSummary { + deleted: number; + missing: number; + // Deleted videos removed before Youtarr recorded why (a subset of deleted). + legacy: number; + libraryCheck: RemovedVideosLibraryCheck; + // The failing library check covers missing videos: marking all of them as + // deleted needs the user's say-so (empty folder only) and emptying them is refused. + missingActionsBlocked: boolean; + // A download is running, so emptying the trash with archive removal would be refused. + downloadsActive: boolean; +} + +export type TrashState = 'deleted' | 'missing'; + +export interface EmptyTrashOptions { + states: TrashState[]; + removeFromArchive: boolean; +} + +export interface MarkAllMissingOptions { + // The user emptied the downloads folder (or a subfolder) on purpose; the + // server honours it only while the check fails because a folder is empty. + ignoreLibraryCheck?: boolean; +} + +export interface EmptyTrashResult { + purged: number; + archiveEntriesRemoved: number; + jobsAffected: number; +} + +export interface UseRemovedVideosReturn { + summary: RemovedVideosSummary | null; + loading: boolean; + error: string | null; + refetch: () => Promise<void>; + // Both resolve null on failure, with the server's message in actionError. + markAllMissingDeleted: (options?: MarkAllMissingOptions) => Promise<{ updated: number } | null>; + emptyTrash: (options: EmptyTrashOptions) => Promise<EmptyTrashResult | null>; + actionPending: boolean; + actionError: string | null; +} + +const LOAD_ERROR = 'Failed to load the deleted and missing video counts'; + +function serverMessage(err: unknown, fallback: string): string { + const message = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error; + return typeof message === 'string' ? message : fallback; +} + +export function useRemovedVideos(token: string | null): UseRemovedVideosReturn { + const [summary, setSummary] = useState<RemovedVideosSummary | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [actionPending, setActionPending] = useState(false); + const [actionError, setActionError] = useState<string | null>(null); + + const headers = useCallback(() => ({ headers: { 'x-access-token': token ?? '' } }), [token]); + + const refetch = useCallback(async () => { + if (!token) return; + setLoading(true); + try { + const response = await axios.get<RemovedVideosSummary>('/api/videos/removed-summary', headers()); + setSummary(response.data); + setError(null); + } catch { + setError(LOAD_ERROR); + } finally { + setLoading(false); + } + }, [token, headers]); + + useEffect(() => { + void refetch(); + }, [refetch]); + + const runAction = useCallback(async <T,>(request: () => Promise<{ data: T }>, fallback: string): Promise<T | null> => { + setActionPending(true); + setActionError(null); + try { + const response = await request(); + return response.data; + } catch (err: unknown) { + setActionError(serverMessage(err, fallback)); + return null; + } finally { + // Also after a failure: a purge that failed partway still forgot the + // videos it reached, and a refusal can mean the library check changed. + await refetch(); + setActionPending(false); + } + }, [refetch]); + + const markAllMissingDeleted = useCallback( + ({ ignoreLibraryCheck = false }: MarkAllMissingOptions = {}) => runAction( + () => axios.post<{ updated: number }>( + '/api/videos/mark-deleted', + ignoreLibraryCheck ? { all: true, ignoreLibraryCheck: true } : { all: true }, + headers() + ), + 'Failed to mark videos as deleted' + ), + [runAction, headers] + ); + + const emptyTrash = useCallback( + (options: EmptyTrashOptions) => runAction( + () => axios.post<EmptyTrashResult>('/api/videos/purge-removed', options, headers()), + 'Failed to empty the trash' + ), + [runAction, headers] + ); + + return { summary, loading, error, refetch, markAllMissingDeleted, emptyTrash, actionPending, actionError }; +} diff --git a/client/src/components/VideosPage/components/HiddenDeletedNote.tsx b/client/src/components/VideosPage/components/HiddenDeletedNote.tsx new file mode 100644 index 000000000..031d8cc3a --- /dev/null +++ b/client/src/components/VideosPage/components/HiddenDeletedNote.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button, Typography } from '../../ui'; +import InfoHint from '../../shared/InfoHint'; +import { REMOVED_VIDEOS_SETTINGS_PATH } from '../../../utils/videoRemoval'; + +interface HiddenDeletedNoteProps { + // Deleted videos the current filters hide; null when deleted videos are not hidden. + hiddenDeletedCount: number | null; + onShowDeleted: () => void; +} + +// "N deleted videos hidden." with Show, Clean up (Settings > Maintenance) and +// an explanation. Sits beside the Library heading on wide screens and under it +// on phones, so it annotates the total without taking a line of its own. +function HiddenDeletedNote({ hiddenDeletedCount, onShowDeleted }: HiddenDeletedNoteProps) { + const navigate = useNavigate(); + if (hiddenDeletedCount === null || hiddenDeletedCount <= 0) return null; + + return ( + <Typography variant="caption" color="text.secondary" component="p" className="flex flex-wrap items-center justify-center gap-1"> + <span>{hiddenDeletedCount} deleted {hiddenDeletedCount === 1 ? 'video' : 'videos'} hidden.</span> + <Button variant="text" size="sm" onClick={onShowDeleted}>Show</Button> + <span aria-hidden="true">·</span> + <Button variant="text" size="sm" onClick={() => navigate(REMOVED_VIDEOS_SETTINGS_PATH)}>Clean up</Button> + <InfoHint label="About deleted videos"> + Deleted videos stay in your library so they can be re-downloaded. In Settings > Maintenance you can + mark missing videos as deleted, or empty the trash to remove deleted videos for good. + </InfoHint> + </Typography> + ); +} + +export default HiddenDeletedNote; diff --git a/client/src/components/VideosPage/components/LibraryCleanupHints.tsx b/client/src/components/VideosPage/components/LibraryCleanupHints.tsx new file mode 100644 index 000000000..14db088c6 --- /dev/null +++ b/client/src/components/VideosPage/components/LibraryCleanupHints.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button, Typography } from '../../ui'; +import type { ChipFilterMode } from '../../shared/VideoList'; +import { REMOVED_VIDEOS_SETTINGS_PATH } from '../../../utils/videoRemoval'; + +interface LibraryCleanupHintsProps { + missingFilter: ChipFilterMode; + deletedFilter: ChipFilterMode; + // Whether the current page lists any video, so a hint never sits over an empty list. + hasVideos: boolean; +} + +const INLINE_LINK_CLASS = 'h-auto p-0 align-baseline text-xs'; + +// Points the Videos page at Settings > Maintenance, where missing videos can be +// marked as deleted and deleted ones removed for good, under the Missing or +// Deleted filter when it shows only those videos. +function LibraryCleanupHints({ missingFilter, deletedFilter, hasVideos }: LibraryCleanupHintsProps) { + const navigate = useNavigate(); + const maintenanceLink = (label: string) => ( + <Button variant="link" className={INLINE_LINK_CLASS} onClick={() => navigate(REMOVED_VIDEOS_SETTINGS_PATH)}> + {label} + </Button> + ); + + return ( + <> + {hasVideos && missingFilter === 'only' && ( + <Typography variant="caption" color="text.secondary" component="p" align="center"> + If these files are really gone, select them and choose Mark as deleted, or{' '} + {maintenanceLink('mark them all in Maintenance')}. + </Typography> + )} + {hasVideos && deletedFilter === 'only' && ( + <Typography variant="caption" color="text.secondary" component="p" align="center"> + Deleted videos stay listed so they can be re-downloaded. To remove them for good,{' '} + {maintenanceLink('empty the trash in Maintenance')}. + </Typography> + )} + </> + ); +} + +export default LibraryCleanupHints; diff --git a/client/src/components/VideosPage/components/MarkDeletedDialog.tsx b/client/src/components/VideosPage/components/MarkDeletedDialog.tsx new file mode 100644 index 000000000..f2a2918e1 --- /dev/null +++ b/client/src/components/VideosPage/components/MarkDeletedDialog.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from '../../ui'; +import { FileX as MarkDeletedIcon } from 'lucide-react'; + +interface MarkDeletedDialogProps { + open: boolean; + onClose: () => void; + onConfirm: () => void; + // Missing videos in the selection: the ones that will be marked. + count: number; + // Selected videos that are not missing, left unchanged. + skippedCount: number; + pending: boolean; +} + +const videos = (n: number) => (n === 1 ? 'video' : 'videos'); + +// Confirms turning missing videos into deleted ones. The record is kept; only +// how the Videos page treats it changes. +const MarkDeletedDialog: React.FC<MarkDeletedDialogProps> = ({ + open, + onClose, + onConfirm, + count, + skippedCount, + pending, +}) => ( + <Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> + <DialogTitle> + <MarkDeletedIcon size={20} className="shrink-0" /> + Mark as deleted + </DialogTitle> + + <DialogContent> + <div className="space-y-3"> + <Typography component="p" variant="body2"> + {count} missing {videos(count)} will be marked as deleted and hidden by default. + </Typography> + {skippedCount > 0 && ( + <Typography component="p" variant="body2" color="text.secondary"> + {skippedCount} selected {videos(skippedCount)} {skippedCount === 1 ? 'is' : 'are'} not missing and will not change. + </Typography> + )} + <Typography component="p" variant="body2" color="text.secondary"> + The record is kept, so the video can still be re-downloaded; if its file turns up again, the rescan marks it downloaded. + </Typography> + </div> + </DialogContent> + + <DialogActions> + <Button onClick={onClose} variant="contained" color="primary" autoFocus> + Cancel + </Button> + <Button onClick={onConfirm} variant="outlined" color="warning" disabled={pending || count === 0}> + Mark {count} as deleted + </Button> + </DialogActions> + </Dialog> +); + +export default MarkDeletedDialog; diff --git a/client/src/components/VideosPage/components/VideoCard.tsx b/client/src/components/VideosPage/components/VideoCard.tsx index 2b286cf22..3a9b88b52 100644 --- a/client/src/components/VideosPage/components/VideoCard.tsx +++ b/client/src/components/VideosPage/components/VideoCard.tsx @@ -20,7 +20,9 @@ import ProtectionShieldButton from '../../shared/ProtectionShieldButton'; import ThumbnailClickOverlay from '../../shared/ThumbnailClickOverlay'; import AvailabilityChip from '../../shared/AvailabilityChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; -import ChannelNameDisplay from './ChannelNameDisplay'; +import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../../utils/videoRemoval'; +import RemovalDescription from '../../shared/RemovalDescription'; +import ChannelNameDisplay from '../../shared/ChannelNameDisplay'; import WatchedChip from '../../shared/WatchedChip'; export interface VideoCardProps { @@ -151,20 +153,32 @@ function VideoCard({ left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(244, 67, 54, 0.3)', + backgroundColor: getRemovalState(video.removed, video.removed_reason) === 'missing' + ? 'rgba(244, 67, 54, 0.3)' + : DELETED_OVERLAY_BACKGROUND, display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1, }} > - <ErrorOutlineIcon - className="text-destructive" - style={{ - fontSize: '3rem', - filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))', - }} - /> + {getRemovalState(video.removed, video.removed_reason) === 'missing' ? ( + <ErrorOutlineIcon + className="text-destructive" + style={{ + fontSize: '3rem', + filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))', + }} + /> + ) : ( + <DeleteIcon + className="text-muted-foreground" + style={{ + fontSize: '3rem', + filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))', + }} + /> + )} </Box> )} @@ -305,9 +319,9 @@ function VideoCard({ size="small" /> {video.removed ? ( - <AvailabilityChip isAvailable={false} /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={<RemovalDescription row={video} />} /> ) : video.fileSize ? ( - <AvailabilityChip isAvailable={true} /> + <AvailabilityChip state="present" /> ) : null} <WatchedChip watchedBy={video.watchedBy || []} /> </Box> diff --git a/client/src/components/VideosPage/components/VideosListMobile.tsx b/client/src/components/VideosPage/components/VideosListMobile.tsx index a90c1dc0e..d4277dff0 100644 --- a/client/src/components/VideosPage/components/VideosListMobile.tsx +++ b/client/src/components/VideosPage/components/VideosListMobile.tsx @@ -1,7 +1,9 @@ import React from 'react'; import VideoActivityChip from '../../shared/VideoActivityChip'; import { Box, Typography, Chip, Checkbox, Stack } from '../../ui'; -import { AlertCircle as ErrorOutlineIcon } from 'lucide-react'; +import { AlertCircle as ErrorOutlineIcon, Trash2 as DeletedIcon } from 'lucide-react'; +import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../../utils/videoRemoval'; +import RemovalDescription from '../../shared/RemovalDescription'; import { formatDuration, formatYTDate } from '../../../utils'; import { formatAddedDateTime, formatFileSize } from '../../../utils/formatters'; import { getMediaTypeInfo } from '../../../utils/videoStatus'; @@ -14,7 +16,7 @@ import ThumbnailClickOverlay from '../../shared/ThumbnailClickOverlay'; import AvailabilityChip from '../../shared/AvailabilityChip'; import WatchedChip from '../../shared/WatchedChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; -import ChannelNameDisplay from './ChannelNameDisplay'; +import ChannelNameDisplay from '../../shared/ChannelNameDisplay'; export interface VideosListMobileProps { videos: VideoData[]; @@ -169,7 +171,9 @@ function VideosListMobile({ style={{ position: 'absolute', inset: 0, - backgroundColor: 'rgba(244, 67, 54, 0.3)', + backgroundColor: getRemovalState(video.removed, video.removed_reason) === 'missing' + ? 'rgba(244, 67, 54, 0.3)' + : DELETED_OVERLAY_BACKGROUND, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -177,10 +181,17 @@ function VideosListMobile({ pointerEvents: 'none', }} > - <ErrorOutlineIcon - className="text-destructive" - style={{ fontSize: '1.5rem' }} - /> + {getRemovalState(video.removed, video.removed_reason) === 'missing' ? ( + <ErrorOutlineIcon + className="text-destructive" + style={{ fontSize: '1.5rem' }} + /> + ) : ( + <DeletedIcon + className="text-muted-foreground" + style={{ fontSize: '1.5rem' }} + /> + )} </Box> )} {video.duration ? ( @@ -300,9 +311,9 @@ function VideosListMobile({ style={compactRatingChipStyle} /> {video.removed ? ( - <AvailabilityChip isAvailable={false} compact /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={<RemovalDescription row={video} />} compact /> ) : video.fileSize ? ( - <AvailabilityChip isAvailable={true} compact /> + <AvailabilityChip state="present" compact /> ) : null} <WatchedChip watchedBy={video.watchedBy || []} compact /> </Stack> diff --git a/client/src/components/VideosPage/components/VideosTable.tsx b/client/src/components/VideosPage/components/VideosTable.tsx index 082a8880f..116b5df63 100644 --- a/client/src/components/VideosPage/components/VideosTable.tsx +++ b/client/src/components/VideosPage/components/VideosTable.tsx @@ -19,6 +19,7 @@ import { } from '../../ui'; import { AlertCircle as ErrorOutlineIcon, + Trash2 as DeletedIcon, Trash2 as DeleteIcon, } from 'lucide-react'; import { formatDuration, formatYTDate } from '../../../utils'; @@ -32,7 +33,9 @@ import ProtectionShieldButton from '../../shared/ProtectionShieldButton'; import ThumbnailClickOverlay from '../../shared/ThumbnailClickOverlay'; import AvailabilityChip from '../../shared/AvailabilityChip'; import { SHARED_STATUS_CHIP_SMALL_STYLE } from '../../shared/chipStyles'; -import ChannelNameDisplay from './ChannelNameDisplay'; +import { DELETED_OVERLAY_BACKGROUND, getRemovalState } from '../../../utils/videoRemoval'; +import RemovalDescription from '../../shared/RemovalDescription'; +import ChannelNameDisplay from '../../shared/ChannelNameDisplay'; import WatchedChip from '../../shared/WatchedChip'; export interface VideosTableProps { @@ -219,7 +222,9 @@ function VideosTable({ style={{ position: 'absolute', inset: 0, - backgroundColor: 'rgba(244, 67, 54, 0.3)', + backgroundColor: getRemovalState(video.removed, video.removed_reason) === 'missing' + ? 'rgba(244, 67, 54, 0.3)' + : DELETED_OVERLAY_BACKGROUND, display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -227,10 +232,17 @@ function VideosTable({ pointerEvents: 'none', }} > - <ErrorOutlineIcon - className="text-destructive" - style={{ fontSize: '1.5rem' }} - /> + {getRemovalState(video.removed, video.removed_reason) === 'missing' ? ( + <ErrorOutlineIcon + className="text-destructive" + style={{ fontSize: '1.5rem' }} + /> + ) : ( + <DeletedIcon + className="text-muted-foreground" + style={{ fontSize: '1.5rem' }} + /> + )} </Box> )} </Box> @@ -312,9 +324,9 @@ function VideosTable({ /> )} {video.removed ? ( - <AvailabilityChip isAvailable={false} /> + <AvailabilityChip state={getRemovalState(video.removed, video.removed_reason)} tooltip={<RemovalDescription row={video} />} /> ) : video.fileSize ? ( - <AvailabilityChip isAvailable={true} /> + <AvailabilityChip state="present" /> ) : null} <WatchedChip watchedBy={video.watchedBy || []} /> </Stack> diff --git a/client/src/components/VideosPage/components/__tests__/HiddenDeletedNote.test.tsx b/client/src/components/VideosPage/components/__tests__/HiddenDeletedNote.test.tsx new file mode 100644 index 000000000..b1b2dcc54 --- /dev/null +++ b/client/src/components/VideosPage/components/__tests__/HiddenDeletedNote.test.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import HiddenDeletedNote from '../HiddenDeletedNote'; + +function CurrentLocation() { + const location = useLocation(); + return <div data-testid="location">{`${location.pathname}${location.hash}`}</div>; +} + +function renderNote(hiddenDeletedCount: number | null, onShowDeleted: () => void = jest.fn()) { + return render( + <MemoryRouter initialEntries={['/videos']}> + <Routes> + <Route + path="/videos" + element={<HiddenDeletedNote hiddenDeletedCount={hiddenDeletedCount} onShowDeleted={onShowDeleted} />} + /> + <Route path="/settings/maintenance" element={<CurrentLocation />} /> + </Routes> + </MemoryRouter> + ); +} + +describe('HiddenDeletedNote', () => { + test('says how many deleted videos are hidden', () => { + renderNote(3); + expect(screen.getByText('3 deleted videos hidden.')).toBeInTheDocument(); + }); + + test('uses the singular for one hidden video', () => { + renderNote(1); + expect(screen.getByText('1 deleted video hidden.')).toBeInTheDocument(); + }); + + test('shows the deleted videos when Show is clicked', async () => { + const onShowDeleted = jest.fn(); + renderNote(3, onShowDeleted); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Show' })); + + expect(onShowDeleted).toHaveBeenCalledTimes(1); + }); + + test('Clean up opens the deleted and missing videos card in Maintenance', async () => { + renderNote(3); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Clean up' })); + + expect(screen.getByTestId('location')).toHaveTextContent('/settings/maintenance#deleted-and-missing'); + }); + + test('explains what Maintenance can do from the info icon', async () => { + renderNote(3); + + await userEvent.setup().hover(screen.getByRole('button', { name: 'About deleted videos' })); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('empty the trash to remove deleted videos for good'); + }); + + test('shows nothing when no deleted video is hidden', () => { + renderNote(0); + expect(screen.queryByText(/deleted videos? hidden/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Clean up' })).not.toBeInTheDocument(); + }); + + test('shows nothing while deleted videos are not hidden', () => { + renderNote(null); + expect(screen.queryByRole('button', { name: 'Show' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/VideosPage/components/__tests__/LibraryCleanupHints.test.tsx b/client/src/components/VideosPage/components/__tests__/LibraryCleanupHints.test.tsx new file mode 100644 index 000000000..2c57e7ca0 --- /dev/null +++ b/client/src/components/VideosPage/components/__tests__/LibraryCleanupHints.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import LibraryCleanupHints from '../LibraryCleanupHints'; +import type { ChipFilterMode } from '../../../shared/VideoList'; + +function CurrentLocation() { + const location = useLocation(); + return <div data-testid="location">{`${location.pathname}${location.hash}`}</div>; +} + +interface Props { + missingFilter?: ChipFilterMode; + deletedFilter?: ChipFilterMode; + hasVideos?: boolean; +} + +function renderHints({ missingFilter = 'off', deletedFilter = 'exclude', hasVideos = true }: Props = {}) { + return render( + <MemoryRouter initialEntries={['/videos']}> + <Routes> + <Route + path="/videos" + element={<LibraryCleanupHints missingFilter={missingFilter} deletedFilter={deletedFilter} hasVideos={hasVideos} />} + /> + <Route path="/settings/maintenance" element={<CurrentLocation />} /> + </Routes> + </MemoryRouter> + ); +} + +describe('LibraryCleanupHints', () => { + describe('missing filter', () => { + test('points at Mark as deleted while showing only missing videos', () => { + renderHints({ missingFilter: 'only' }); + expect(screen.getByText(/select them and choose Mark as deleted/)).toBeInTheDocument(); + }); + + test('links to marking them all in Maintenance', async () => { + renderHints({ missingFilter: 'only' }); + + await userEvent.setup().click(screen.getByRole('button', { name: 'mark them all in Maintenance' })); + + expect(screen.getByTestId('location')).toHaveTextContent('/settings/maintenance#deleted-and-missing'); + }); + + test('stays out of the way while missing videos are not isolated', () => { + renderHints({ missingFilter: 'off' }); + expect(screen.queryByText(/select them and choose Mark as deleted/)).not.toBeInTheDocument(); + }); + + test('says nothing over an empty list', () => { + renderHints({ missingFilter: 'only', hasVideos: false }); + expect(screen.queryByText(/select them and choose Mark as deleted/)).not.toBeInTheDocument(); + }); + }); + + describe('deleted filter', () => { + test('links to emptying the trash while showing only deleted videos', async () => { + renderHints({ deletedFilter: 'only' }); + + await userEvent.setup().click(screen.getByRole('button', { name: 'empty the trash in Maintenance' })); + + expect(screen.getByTestId('location')).toHaveTextContent('/settings/maintenance#deleted-and-missing'); + }); + + test('says nothing over an empty list', () => { + renderHints({ deletedFilter: 'only', hasVideos: false }); + expect(screen.queryByRole('button', { name: 'empty the trash in Maintenance' })).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/VideosPage/components/__tests__/MarkDeletedDialog.test.tsx b/client/src/components/VideosPage/components/__tests__/MarkDeletedDialog.test.tsx new file mode 100644 index 000000000..c9c0fa45a --- /dev/null +++ b/client/src/components/VideosPage/components/__tests__/MarkDeletedDialog.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import MarkDeletedDialog from '../MarkDeletedDialog'; +import { renderWithProviders } from '../../../../test-utils'; + +const renderDialog = (overrides: Partial<React.ComponentProps<typeof MarkDeletedDialog>> = {}) => { + const onClose = jest.fn(); + const onConfirm = jest.fn(); + renderWithProviders( + <MarkDeletedDialog + open + onClose={onClose} + onConfirm={onConfirm} + count={2} + skippedCount={0} + pending={false} + {...overrides} + /> + ); + return { onClose, onConfirm }; +}; + +describe('MarkDeletedDialog', () => { + test('explains what marking does, in plural', () => { + renderDialog({ count: 2, skippedCount: 1 }); + expect(screen.getByText('Mark as deleted')).toBeInTheDocument(); + expect(screen.getByText('2 missing videos will be marked as deleted and hidden by default.')).toBeInTheDocument(); + expect(screen.getByText('1 selected video is not missing and will not change.')).toBeInTheDocument(); + expect(screen.getByText('The record is kept, so the video can still be re-downloaded; if its file turns up again, the rescan marks it downloaded.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mark 2 as deleted' })).toBeEnabled(); + }); + + test('explains what marking does, in singular, without a skipped note when nothing is skipped', () => { + renderDialog({ count: 1, skippedCount: 0 }); + expect(screen.getByText('1 missing video will be marked as deleted and hidden by default.')).toBeInTheDocument(); + expect(screen.queryByText(/will not change/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mark 1 as deleted' })).toBeInTheDocument(); + }); + + test('pluralizes the skipped note', () => { + renderDialog({ count: 1, skippedCount: 3 }); + expect(screen.getByText('3 selected videos are not missing and will not change.')).toBeInTheDocument(); + }); + + test('confirms and cancels', async () => { + const user = userEvent.setup(); + const { onClose, onConfirm } = renderDialog(); + await user.click(screen.getByRole('button', { name: 'Mark 2 as deleted' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('disables the confirm button while pending', () => { + renderDialog({ pending: true }); + expect(screen.getByRole('button', { name: 'Mark 2 as deleted' })).toBeDisabled(); + }); + + test('renders nothing when closed', () => { + renderDialog({ open: false }); + expect(screen.queryByText('Mark as deleted')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/VideosPage/components/__tests__/VideoCard.test.tsx b/client/src/components/VideosPage/components/__tests__/VideoCard.test.tsx index 4c15187bc..b30dde908 100644 --- a/client/src/components/VideosPage/components/__tests__/VideoCard.test.tsx +++ b/client/src/components/VideosPage/components/__tests__/VideoCard.test.tsx @@ -138,10 +138,17 @@ describe('VideoCard', () => { ); expect(screen.getByText('Available')).toBeInTheDocument(); unmount(); - renderCard({ video: { ...baseVideo, removed: true } }); + renderCard({ video: { ...baseVideo, removed: true, removed_reason: 'not_found' } }); expect(screen.getByText('Missing')).toBeInTheDocument(); }); + test('shows a neutral Deleted chip for a video Youtarr removed', () => { + renderCard({ video: { ...baseVideo, removed: true, removed_reason: 'auto_removal', removed_detail: 'older than 30 days' } }); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.queryByText('Missing')).not.toBeInTheDocument(); + expect(screen.queryByText('Available')).not.toBeInTheDocument(); + }); + test('delete button calls onDeleteSingle with the video id', () => { const { onDeleteSingle } = renderCard(); fireEvent.click(screen.getByRole('button', { name: 'Delete video from disk' })); diff --git a/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts b/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts new file mode 100644 index 000000000..33752c827 --- /dev/null +++ b/client/src/components/VideosPage/hooks/__tests__/useMarkDeleted.test.ts @@ -0,0 +1,95 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; + +jest.mock('axios', () => ({ + post: jest.fn(), + isAxiosError: jest.fn((err: unknown) => Boolean((err as { isAxiosError?: boolean })?.isAxiosError)), +})); + +const axios = require('axios'); + +const { useMarkDeleted, MARK_DELETED_BATCH_SIZE } = require('../useMarkDeleted'); + +const ids = (count: number) => Array.from({ length: count }, (_, i) => i + 1); + +describe('useMarkDeleted', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('posts the selected ids with the token and returns how many were marked', async () => { + axios.post.mockResolvedValueOnce({ data: { updated: 2 } }); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number; error: string | null } | null = null; + await act(async () => { + outcome = await result.current.markDeleted([4, 9]); + }); + + expect(outcome).toEqual({ updated: 2, error: null }); + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/mark-deleted', + { videoIds: [4, 9] }, + { headers: { 'x-access-token': 'tok' } } + ); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + test('splits a large selection into batches the server accepts and sums the counts', async () => { + axios.post + .mockResolvedValueOnce({ data: { updated: 1000 } }) + .mockResolvedValueOnce({ data: { updated: 300 } }); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number; error: string | null } | null = null; + await act(async () => { + outcome = await result.current.markDeleted(ids(1500)); + }); + + expect(MARK_DELETED_BATCH_SIZE).toBe(1000); + expect(outcome).toEqual({ updated: 1300, error: null }); + expect(axios.post).toHaveBeenCalledTimes(2); + expect(axios.post.mock.calls[0][1].videoIds).toHaveLength(1000); + expect(axios.post.mock.calls[1][1].videoIds).toEqual(ids(1500).slice(1000)); + }); + + test('stops at the first failing batch and reports what was marked before it', async () => { + axios.post + .mockResolvedValueOnce({ data: { updated: 1000 } }) + .mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder is empty.' } } }); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number; error: string | null } | null = null; + await act(async () => { + outcome = await result.current.markDeleted(ids(2500)); + }); + + expect(outcome).toEqual({ updated: 1000, error: 'The downloads folder is empty.' }); + expect(axios.post).toHaveBeenCalledTimes(2); + expect(result.current.loading).toBe(false); + }); + + test('returns the server error message when the request fails', async () => { + axios.post.mockRejectedValueOnce({ isAxiosError: true, response: { data: { error: 'The downloads folder is empty.' } } }); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number; error: string | null } | null = null; + await act(async () => { + outcome = await result.current.markDeleted([4]); + }); + + expect(outcome).toEqual({ updated: 0, error: 'The downloads folder is empty.' }); + expect(result.current.loading).toBe(false); + }); + + test('falls back to a generic message when the failure carries none', async () => { + axios.post.mockRejectedValueOnce(new Error('network')); + const { result } = renderHook(() => useMarkDeleted('tok')); + + let outcome: { updated: number; error: string | null } | null = null; + await act(async () => { + outcome = await result.current.markDeleted([4]); + }); + + expect(outcome).toEqual({ updated: 0, error: 'Failed to mark videos as deleted' }); + }); +}); diff --git a/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts b/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts index 4698b2000..046243d19 100644 --- a/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts +++ b/client/src/components/VideosPage/hooks/__tests__/useVideosData.test.ts @@ -21,11 +21,14 @@ const baseParams = { maxRatingFilter: '', protectedFilter: 'off' as const, missingFilter: 'off' as const, + deletedFilter: 'off' as const, watchedFilter: 'off' as const, useInfiniteScroll: false, }; -const buildResponse = (videos: { id: number; youtubeId: string }[]) => ({ +const buildResponse = (videos: { id: number; youtubeId: string }[]): { + data: Record<string, unknown> & { hiddenDeletedCount?: number | null; libraryWarning?: Record<string, unknown> | null }; +} => ({ data: { videos: videos.map((v) => ({ ...v, @@ -72,12 +75,14 @@ describe('useVideosData', () => { maxRatingFilter: 'TV-MA', protectedFilter: 'only', missingFilter: 'exclude', + deletedFilter: 'exclude', watchedFilter: 'exclude', }) ); await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); const url = axios.get.mock.calls[0][0] as string; + expect(url).toContain('deletedFilter=exclude'); expect(url).toContain('page=2'); expect(url).toContain('limit=12'); expect(url).toContain('sortBy=published'); @@ -98,10 +103,34 @@ describe('useVideosData', () => { await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); const url = axios.get.mock.calls[0][0] as string; expect(url).not.toContain('missingFilter'); + expect(url).not.toContain('deletedFilter'); expect(url).not.toContain('protectedFilter'); expect(url).not.toContain('watchedFilter'); }); + test('exposes the hidden deleted count and the library warning from the response', async () => { + const response = buildResponse([{ id: 1, youtubeId: 'a' }]); + response.data.hiddenDeletedCount = 4; + response.data.libraryWarning = { reason: 'empty', path: '/d', message: 'The downloads folder is empty.', heldBack: 2 }; + axios.get.mockResolvedValueOnce(response); + + const { result } = renderHook(() => useVideosData(baseParams)); + + await waitFor(() => expect(result.current.hiddenDeletedCount).toBe(4)); + expect(result.current.libraryWarning).toEqual({ reason: 'empty', path: '/d', message: 'The downloads folder is empty.', heldBack: 2 }); + }); + + test('reports no hidden deleted count and no warning when the response has none', async () => { + axios.get.mockResolvedValueOnce(buildResponse([{ id: 1, youtubeId: 'a' }])); + + const { result } = renderHook(() => useVideosData(baseParams)); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.hiddenDeletedCount).toBeNull(); + expect(result.current.libraryWarning).toBeNull(); + }); + test('replaces videos when not using infinite scroll', async () => { axios.get.mockResolvedValueOnce(buildResponse([{ id: 1, youtubeId: 'a' }])); diff --git a/client/src/components/VideosPage/hooks/useMarkDeleted.ts b/client/src/components/VideosPage/hooks/useMarkDeleted.ts new file mode 100644 index 000000000..e33bdca47 --- /dev/null +++ b/client/src/components/VideosPage/hooks/useMarkDeleted.ts @@ -0,0 +1,52 @@ +import { useCallback, useState } from 'react'; +import axios from 'axios'; + +// The server accepts this many ids per request; larger selections are sent in batches. +export const MARK_DELETED_BATCH_SIZE = 1000; + +export interface MarkDeletedOutcome { + // How many videos were marked, counting the batches that succeeded. + updated: number; + // The server's message for the batch that failed, or null when every batch went through. + error: string | null; +} + +export interface UseMarkDeletedReturn { + markDeleted: (videoIds: number[]) => Promise<MarkDeletedOutcome>; + loading: boolean; +} + +const FALLBACK_ERROR = 'Failed to mark videos as deleted'; + +function serverMessage(err: unknown): string { + // The server answers 400/409/500 with { error }; anything else gets the fallback. + const message = (err as { response?: { data?: { error?: unknown } } })?.response?.data?.error; + return typeof message === 'string' ? message : FALLBACK_ERROR; +} + +export function useMarkDeleted(token: string | null): UseMarkDeletedReturn { + const [loading, setLoading] = useState(false); + + const markDeleted = useCallback(async (videoIds: number[]): Promise<MarkDeletedOutcome> => { + setLoading(true); + let updated = 0; + try { + for (let start = 0; start < videoIds.length; start += MARK_DELETED_BATCH_SIZE) { + const batch = videoIds.slice(start, start + MARK_DELETED_BATCH_SIZE); + const response = await axios.post<{ updated: number }>( + '/api/videos/mark-deleted', + { videoIds: batch }, + { headers: { 'x-access-token': token ?? '' } } + ); + updated += response.data.updated; + } + return { updated, error: null }; + } catch (err: unknown) { + return { updated, error: serverMessage(err) }; + } finally { + setLoading(false); + } + }, [token]); + + return { markDeleted, loading }; +} diff --git a/client/src/components/VideosPage/hooks/useVideosData.ts b/client/src/components/VideosPage/hooks/useVideosData.ts index f27301bd8..e229347d9 100644 --- a/client/src/components/VideosPage/hooks/useVideosData.ts +++ b/client/src/components/VideosPage/hooks/useVideosData.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import axios from 'axios'; -import { EnabledChannel, PaginatedVideosResponse, VideoData } from '../../../types/VideoData'; +import { EnabledChannel, LibraryWarning, PaginatedVideosResponse, VideoData } from '../../../types/VideoData'; import { ChipFilterMode } from '../../shared/VideoList/types'; export interface UseVideosDataParams { @@ -16,6 +16,7 @@ export interface UseVideosDataParams { maxRatingFilter: string; protectedFilter: ChipFilterMode; missingFilter: ChipFilterMode; + deletedFilter: ChipFilterMode; watchedFilter: ChipFilterMode; useInfiniteScroll: boolean; } @@ -27,6 +28,9 @@ export interface UseVideosDataResult { totalPages: number; uniqueChannels: string[]; enabledChannels: EnabledChannel[]; + // How many deleted videos the current filters hide; null unless deleted are excluded. + hiddenDeletedCount: number | null; + libraryWarning: LibraryWarning | null; loading: boolean; loadError: string | null; refetch: () => Promise<void>; @@ -55,6 +59,7 @@ export function useVideosData({ maxRatingFilter, protectedFilter, missingFilter, + deletedFilter, watchedFilter, useInfiniteScroll, }: UseVideosDataParams): UseVideosDataResult { @@ -63,6 +68,8 @@ export function useVideosData({ const [totalPages, setTotalPages] = useState(1); const [uniqueChannels, setUniqueChannels] = useState<string[]>([]); const [enabledChannels, setEnabledChannels] = useState<EnabledChannel[]>([]); + const [hiddenDeletedCount, setHiddenDeletedCount] = useState<number | null>(null); + const [libraryWarning, setLibraryWarning] = useState<LibraryWarning | null>(null); const [loading, setLoading] = useState(false); const [loadError, setLoadError] = useState<string | null>(null); const latestRequestId = useRef(0); @@ -86,6 +93,7 @@ export function useVideosData({ if (maxRatingFilter) params.append('maxRating', maxRatingFilter); if (protectedFilter !== 'off') params.append('protectedFilter', protectedFilter); if (missingFilter !== 'off') params.append('missingFilter', missingFilter); + if (deletedFilter !== 'off') params.append('deletedFilter', deletedFilter); if (watchedFilter !== 'off') params.append('watchedFilter', watchedFilter); try { @@ -113,6 +121,8 @@ export function useVideosData({ setTotalPages(response.data.totalPages); setUniqueChannels(response.data.channels || []); setEnabledChannels(response.data.enabledChannels || []); + setHiddenDeletedCount(response.data.hiddenDeletedCount ?? null); + setLibraryWarning(response.data.libraryWarning ?? null); } catch (error) { if (requestId !== latestRequestId.current) { return; @@ -139,6 +149,7 @@ export function useVideosData({ maxRatingFilter, protectedFilter, missingFilter, + deletedFilter, watchedFilter, useInfiniteScroll, ]); @@ -154,6 +165,8 @@ export function useVideosData({ totalPages, uniqueChannels, enabledChannels, + hiddenDeletedCount, + libraryWarning, loading, loadError, refetch: fetchVideos, diff --git a/client/src/components/VideosPage/index.tsx b/client/src/components/VideosPage/index.tsx index 9557fae25..cd3f905b1 100644 --- a/client/src/components/VideosPage/index.tsx +++ b/client/src/components/VideosPage/index.tsx @@ -3,8 +3,9 @@ import { useVideoActivity } from '../../providers/VideoActivityProvider'; import axios from 'axios'; import { useNavigate } from 'react-router-dom'; import { useSwipeable } from 'react-swipeable'; -import { Alert, Box, Grid, Snackbar, Typography } from '../ui'; +import { Alert, Box, Button, Grid, Snackbar, Typography } from '../ui'; import { Trash2 as DeleteIcon, Star as RatingIcon, Download as DownloadIcon } from '../../lib/icons'; +import { FileX as MarkDeletedIcon } from 'lucide-react'; import { useMediaQuery } from '../../hooks/useMediaQuery'; import { useConfig } from '../../hooks/useConfig'; import { useDownloadListingsRefresh } from '../../hooks/useDownloadListingsRefresh'; @@ -17,6 +18,11 @@ import ChangeRatingDialog from '../shared/ChangeRatingDialog'; import { useVideoProtection } from '../shared/useVideoProtection'; import VideoModal from '../shared/VideoModal'; import { VideoModalData } from '../shared/VideoModal/types'; +import { getRemovalState, removalFromRow, removedState, type RemovalState } from '../../utils/videoRemoval'; +import MarkDeletedDialog from './components/MarkDeletedDialog'; +import LibraryCleanupHints from './components/LibraryCleanupHints'; +import HiddenDeletedNote from './components/HiddenDeletedNote'; +import { useMarkDeleted } from './hooks/useMarkDeleted'; import DownloadSettingsDialog from '../DownloadManager/ManualDownload/DownloadSettingsDialog'; import { DownloadSettings } from '../DownloadManager/ManualDownload/types'; import VideoCard from './components/VideoCard'; @@ -28,6 +34,7 @@ import { VideoListContainer, VideoListPaginationBar, useListPageSize, + usePersistedChipFilter, useVideoListState, useVideoSelection, type ChipFilterMode, @@ -43,6 +50,12 @@ interface VideosPageProps { } const VIEW_MODE_STORAGE_KEY = 'youtarr:videosPageViewMode'; +// Deleted videos are hidden by default; showing them is remembered in this browser. +const DELETED_FILTER_STORAGE_KEY = 'youtarr:videosPageDeletedFilter'; +// Check results that can mean the user emptied the folder on purpose, the only +// ones Maintenance offers "Rescan anyway" for (a mix with an unreadable share is +// reported as unreadable, so it never qualifies). +const EMPTY_FOLDER_REASONS = ['empty', 'subfolder-empty']; const YOUTUBE_CHANNEL_ID_PATTERN = /^UC[a-zA-Z0-9_-]{22}$/; @@ -50,6 +63,7 @@ interface VideoSelectionMeta { youtubeId: string; channelId: string | null; removed: boolean; + removalState: RemovalState; youtubeRemoved: boolean; } @@ -63,7 +77,8 @@ function videoDataToModalData(video: VideoData): VideoModalData { publishedAt: video.originalDate || null, addedAt: video.timeCreated || null, mediaType: video.media_type || 'video', - status: video.removed ? 'missing' : 'downloaded', + status: video.removed ? removedState(video.removed_reason) : 'downloaded', + removal: removalFromRow(video), isDownloaded: !video.removed, filePath: video.filePath || null, fileSize: video.fileSize ? Number(video.fileSize) : null, @@ -95,10 +110,15 @@ function VideosPage({ token }: VideosPageProps) { const [maxRatingFilter, setMaxRatingFilter] = useState(''); const [protectedFilter, setProtectedFilter] = useState<ChipFilterMode>('off'); const [missingFilter, setMissingFilter] = useState<ChipFilterMode>('off'); + const [deletedFilter, setDeletedFilter] = usePersistedChipFilter(DELETED_FILTER_STORAGE_KEY, 'exclude'); const [watchedFilter, setWatchedFilter] = useState<ChipFilterMode>('off'); const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({}); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [markDeletedDialogOpen, setMarkDeletedDialogOpen] = useState(false); + // The message the user closed; a different one, or the same one after the + // folder came back and failed again, shows the banner again. + const [dismissedLibraryWarning, setDismissedLibraryWarning] = useState<string | null>(null); const [ratingDialogOpen, setRatingDialogOpen] = useState(false); const [successMessage, setSuccessMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null); @@ -117,6 +137,7 @@ function VideosPage({ token }: VideosPageProps) { const loadMoreRef = useRef<HTMLDivElement | null>(null); const { deleteVideos, loading: deleteLoading } = useVideoDeletion(); + const { markDeleted, loading: markDeletedLoading } = useMarkDeleted(token); const configState = useConfig(token); const useInfiniteScroll = configState?.config?.channelVideosHotLoad ?? false; const { @@ -141,6 +162,8 @@ function VideosPage({ token }: VideosPageProps) { totalPages, uniqueChannels, enabledChannels, + hiddenDeletedCount, + libraryWarning, loading, loadError, refetch, @@ -157,10 +180,15 @@ function VideosPage({ token }: VideosPageProps) { maxRatingFilter, protectedFilter, missingFilter, + deletedFilter, watchedFilter, useInfiniteScroll, }); + useEffect(() => { + if (!libraryWarning) setDismissedLibraryWarning(null); + }, [libraryWarning]); + const videoMetaRef = useRef<Map<number, VideoSelectionMeta>>(new Map()); useEffect(() => { @@ -169,6 +197,7 @@ function VideosPage({ token }: VideosPageProps) { youtubeId: video.youtubeId, channelId: video.channel_id || null, removed: Boolean(video.removed), + removalState: getRemovalState(video.removed, video.removed_reason), youtubeRemoved: Boolean(video.youtube_removed), }); } @@ -216,6 +245,18 @@ function VideosPage({ token }: VideosPageProps) { } }; + // Reload after rows leave the listing (deleted, marked deleted). In hot-load + // mode a refetch of a later page merges into the rows already shown, so the + // removed rows would stay: go back to page one, which the hook fetches on the + // page change. Classic pagination replaces the page in place, so it stays put. + const reloadFromFirstPage = () => { + if (useInfiniteScroll && page !== 1) { + setPage(1); + } else { + void refetch(); + } + }; + const handleDeleteConfirm = async (selectedIds: number[]) => { setDeleteDialogOpen(false); const deletableIds = selectedIds.filter((id) => { @@ -229,7 +270,7 @@ function VideosPage({ token }: VideosPageProps) { `Successfully deleted ${result.deleted.length} video${result.deleted.length !== 1 ? 's' : ''}` ); selection.clear(); - refetch(); + reloadFromFirstPage(); } else { const deletedCount = result.deleted.length; const failedCount = result.failed.length; @@ -238,7 +279,7 @@ function VideosPage({ token }: VideosPageProps) { `Deleted ${deletedCount} video${deletedCount !== 1 ? 's' : ''}, but ${failedCount} failed` ); selection.clear(); - refetch(); + reloadFromFirstPage(); } else { setErrorMessage( `Failed to delete videos: ${result.failed[0]?.error || 'Unknown error'}` @@ -247,6 +288,30 @@ function VideosPage({ token }: VideosPageProps) { } }; + // Mark as deleted applies to the missing videos in the selection only. + const missingSelectedIds = (selectedIds: number[]) => + selectedIds.filter((id) => videoMetaRef.current.get(id)?.removalState === 'missing'); + + const handleMarkDeletedConfirm = async (selectedIds: number[]) => { + const ids = missingSelectedIds(selectedIds); + if (ids.length === 0) { + setMarkDeletedDialogOpen(false); + return; + } + const result = await markDeleted(ids); + setMarkDeletedDialogOpen(false); + const marked = `${result.updated} video${result.updated !== 1 ? 's' : ''}`; + if (result.error) { + // A later batch may have failed after earlier ones went through. + setErrorMessage(result.updated > 0 ? `Marked ${marked} as deleted, then failed: ${result.error}` : result.error); + } else { + setSuccessMessage(`Marked ${marked} as deleted`); + } + if (result.updated === 0) return; + selection.clear(); + reloadFromFirstPage(); + }; + const handleApplyRating = async (rating: string | null, selectedIds: number[]) => { if (!token) return; try { @@ -303,8 +368,18 @@ function VideosPage({ token }: VideosPageProps) { }), onClick: () => setDeleteDialogOpen(true), }, + { + id: 'mark-deleted', + label: 'Mark as deleted', + icon: <MarkDeletedIcon size={14} />, + intent: 'warning', + disabled: (ids) => + markDeletedLoading || + !ids.some((id) => videoMetaRef.current.get(id)?.removalState === 'missing'), + onClick: () => setMarkDeletedDialogOpen(true), + }, ], - [deleteLoading, snapshot] + [deleteLoading, markDeletedLoading, snapshot] ); const selection = useVideoSelection<number>({ actions: selectionActions }); @@ -322,6 +397,7 @@ function VideosPage({ token }: VideosPageProps) { maxRatingFilter, protectedFilter, missingFilter, + deletedFilter, watchedFilter, selection.clear, ]); @@ -375,6 +451,13 @@ function VideosPage({ token }: VideosPageProps) { }; const deleteCounts = getDeleteCounts(); + const markDeletedCounts = markDeletedDialogOpen + ? { + eligible: missingSelectedIds(selection.selectedIds).length, + skipped: selection.selectedIds.length - missingSelectedIds(selection.selectedIds).length, + } + : { eligible: 0, skipped: 0 }; + const handleDownloadConfirm = async (settings: DownloadSettings | null) => { setDownloadDialogOpen(false); const eligible = selection.selectedIds @@ -458,6 +541,7 @@ function VideosPage({ token }: VideosPageProps) { { id: 'maxRating', value: maxRatingFilter, onChange: withPageReset(setMaxRatingFilter) }, { id: 'protected', value: protectedFilter, onChange: withPageReset(setProtectedFilter) }, { id: 'missing', value: missingFilter, onChange: withPageReset(setMissingFilter) }, + { id: 'deleted', value: deletedFilter, onChange: withPageReset(setDeletedFilter), defaultValue: 'exclude' }, { id: 'watched', value: watchedFilter, onChange: withPageReset(setWatchedFilter) }, { id: 'channel', @@ -467,7 +551,7 @@ function VideosPage({ token }: VideosPageProps) { }, ]; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dateFrom, dateTo, maxRatingFilter, protectedFilter, missingFilter, watchedFilter, channelFilter, uniqueChannels]); + }, [dateFrom, dateTo, maxRatingFilter, protectedFilter, missingFilter, deletedFilter, watchedFilter, channelFilter, uniqueChannels]); const sortConfig: SortConfig = useMemo( () => ({ @@ -485,11 +569,44 @@ function VideosPage({ token }: VideosPageProps) { [orderBy, sortOrder] ); + const showDeleted = () => { + setDeletedFilter('off'); + setPage(1); + }; + const headerSlot = ( <div style={{ padding: '12px 16px 0 16px' }}> - <Typography variant={isMobile ? 'h6' : 'h5'} component="h2" gutterBottom align="center"> - Library ({totalVideos} total) - </Typography> + {/* Wide screens put the hidden-deleted note on the heading's line; phones stack it. */} + <div className={isMobile ? undefined : 'flex flex-wrap items-baseline justify-center gap-x-4'}> + <Typography variant={isMobile ? 'h6' : 'h5'} component="h2" gutterBottom align="center"> + Library ({totalVideos} total) + </Typography> + <HiddenDeletedNote hiddenDeletedCount={hiddenDeletedCount} onShowDeleted={showDeleted} /> + </div> + <LibraryCleanupHints + missingFilter={missingFilter} + deletedFilter={deletedFilter} + hasVideos={totalVideos > 0} + /> + {libraryWarning && libraryWarning.message !== dismissedLibraryWarning && ( + <Alert severity="warning" className="mt-2 break-words" onClose={() => setDismissedLibraryWarning(libraryWarning.message)}> + {libraryWarning.message} Until that changes, videos stored there are not being marked missing; check + that the drive or share is mounted. + {EMPTY_FOLDER_REASONS.includes(libraryWarning.reason) && ( + <> + {' '}If you removed the files yourself, you can rescan anyway from{' '} + <Button + variant="link" + className="h-auto p-0 align-baseline text-sm" + onClick={() => navigate('/settings/maintenance')} + > + Maintenance + </Button> + . + </> + )} + </Alert> + )} </div> ); @@ -640,6 +757,15 @@ function VideosPage({ token }: VideosPageProps) { skippedCount={deleteCounts.skipped} /> + <MarkDeletedDialog + open={markDeletedDialogOpen} + onClose={() => setMarkDeletedDialogOpen(false)} + onConfirm={() => handleMarkDeletedConfirm(selection.selectedIds)} + count={markDeletedCounts.eligible} + skippedCount={markDeletedCounts.skipped} + pending={markDeletedLoading} + /> + <ChangeRatingDialog open={ratingDialogOpen} onClose={() => setRatingDialogOpen(false)} @@ -708,8 +834,9 @@ function VideosPage({ token }: VideosPageProps) { token={token} onVideoDeleted={() => { setModalVideo(null); - refetch(); + reloadFromFirstPage(); }} + onMarkedDeleted={reloadFromFirstPage} onProtectionChanged={(youtubeId, isProtected) => { setVideos((prev) => prev.map((v) => 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/__tests__/VideosPage.test.tsx b/client/src/components/__tests__/VideosPage.test.tsx index fd068bd2f..127e5460a 100644 --- a/client/src/components/__tests__/VideosPage.test.tsx +++ b/client/src/components/__tests__/VideosPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import { MemoryRouter, useLocation } from 'react-router-dom'; @@ -6,7 +6,9 @@ import VideosPage from '../VideosPage'; import { VideoData } from '../../types/VideoData'; jest.mock('axios', () => ({ - get: jest.fn() + get: jest.fn(), + post: jest.fn(), + isAxiosError: jest.fn(() => false), })); const axios = require('axios'); @@ -114,7 +116,10 @@ jest.mock('../shared/VideoModal', () => ({ return React.createElement('div', { 'data-testid': 'video-modal', 'data-video-title': props.video?.title, - }, props.video?.title); + }, props.video?.title, React.createElement('button', { + type: 'button', + onClick: () => props.onMarkedDeleted?.(props.video?.youtubeId), + }, 'Modal mark as deleted')); } })); @@ -221,7 +226,8 @@ jest.mock('../shared/VideoList', () => { 'button', { type: 'button', - disabled: (selection.actions || []).some((a: any) => + // Like the real pill: only an all-disabled menu is unreachable. + disabled: (selection.actions || []).length > 0 && (selection.actions || []).every((a: any) => a.disabled ? a.disabled(selection.selectedIds) : false ), onClick: () => setMenuOpen((v: boolean) => !v), @@ -331,6 +337,7 @@ jest.mock('../shared/VideoList', () => { sortControls, selectionBar, content, + props.infiniteScrollSentinel, props.pagination ); }, @@ -454,6 +461,132 @@ describe('VideosPage Component', () => { }); }); + describe('deleted videos', () => { + test('hides them by default and offers to show them, saying how many are hidden', async () => { + axios.get.mockResolvedValueOnce({ data: { ...mockPaginatedResponse(mockVideos), hiddenDeletedCount: 3 } }); + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); + + render(<VideosPage token={mockToken} />); + + expect(await screen.findByText('3 deleted videos hidden.')).toBeInTheDocument(); + expect(axios.get.mock.calls[0][0]).toContain('deletedFilter=exclude'); + + await setupUser().click(screen.getByRole('button', { name: 'Show' })); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(axios.get.mock.calls[1][0]).not.toContain('deletedFilter'); + expect(window.localStorage.getItem('youtarr:videosPageDeletedFilter')).toBe('off'); + }); + + test('shows no hidden note when nothing is hidden', async () => { + axios.get.mockResolvedValueOnce({ data: { ...mockPaginatedResponse(mockVideos), hiddenDeletedCount: 0 } }); + + render(<VideosPage token={mockToken} />); + + await waitFor(() => expect(axios.get).toHaveBeenCalled()); + expect(screen.queryByText(/deleted videos? hidden/)).not.toBeInTheDocument(); + }); + + test('remembers that deleted videos were shown', async () => { + window.localStorage.setItem('youtarr:videosPageDeletedFilter', 'off'); + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); + + render(<VideosPage token={mockToken} />); + + await waitFor(() => expect(axios.get).toHaveBeenCalled()); + expect(axios.get.mock.calls[0][0]).not.toContain('deletedFilter'); + }); + }); + + test('warns when the downloads folder looks unmounted and videos were not marked missing', async () => { + axios.get.mockResolvedValueOnce({ + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.', heldBack: 2 }, + }, + }); + + render(<VideosPage token={mockToken} />); + + expect(await screen.findByText(/The downloads folder is empty\. Until that changes, videos stored there are not being marked missing; check/)).toBeInTheDocument(); + }); + + test('hides the unmounted-folder warning once dismissed', async () => { + const user = setupUser(); + axios.get.mockResolvedValueOnce({ + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.', heldBack: 2 }, + }, + }); + + render(<VideosPage token={mockToken} />); + await screen.findByText(/videos stored there are not being marked missing/); + + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(screen.queryByText(/videos stored there are not being marked missing/)).not.toBeInTheDocument(); + }); + + test('shows the warning again when a later problem follows one that cleared', async () => { + const user = setupUser(); + const warned = { + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.', heldBack: 2 }, + }, + }; + axios.get + .mockResolvedValueOnce(warned) + .mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }) + .mockResolvedValueOnce(warned); + + render(<VideosPage token={mockToken} />); + await screen.findByText(/videos stored there are not being marked missing/); + await user.click(screen.getByRole('button', { name: 'Close' })); + + // Each mark from the modal reloads the list: first with the folder back, then failing again. + await user.click(screen.getByRole('button', { name: 'How to Code' })); + await user.click(screen.getByRole('button', { name: 'Modal mark as deleted' })); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + await user.click(screen.getByRole('button', { name: 'Modal mark as deleted' })); + + expect(await screen.findByText(/videos stored there are not being marked missing/)).toBeInTheDocument(); + }); + + test('does not suggest rescanning anyway when a folder could not be read', async () => { + axios.get.mockResolvedValueOnce({ + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { + reason: 'subfolder-unreadable', path: '/data/youtube/__Kids', + message: 'The subfolder __Music is empty. The subfolder __Kids could not be read.', heldBack: 2, + }, + }, + }); + + render(<VideosPage token={mockToken} />); + + expect(await screen.findByText(/videos stored there are not being marked missing/)).toBeInTheDocument(); + expect(screen.queryByText(/rescan anyway/)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Maintenance' })).not.toBeInTheDocument(); + }); + + test('links the unmounted-folder warning to Maintenance for a folder emptied on purpose', async () => { + const user = setupUser(); + axios.get.mockResolvedValueOnce({ + data: { + ...mockPaginatedResponse(mockVideos), + libraryWarning: { reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.', heldBack: 2 }, + }, + }); + + render(<VideosPage token={mockToken} />); + + await user.click(await screen.findByRole('button', { name: 'Maintenance' })); + expect(mockNavigate).toHaveBeenCalledWith('/settings/maintenance'); + }); + test('fetches and displays videos when token is provided', async () => { axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -508,6 +641,20 @@ describe('VideosPage Component', () => { expect(screen.getByTestId('video-modal')).toHaveAttribute('data-video-title', 'How to Code'); }); + test('reloads the list after the modal marks a video as deleted', async () => { + const user = setupUser(); + axios.get.mockResolvedValue({ data: mockPaginatedResponse([mockVideos[0]]) }); + + render(<VideosPage token={mockToken} />); + await screen.findByText('How to Code'); + await user.click(screen.getByRole('button', { name: 'How to Code' })); + const callsBefore = axios.get.mock.calls.length; + + await user.click(screen.getByRole('button', { name: 'Modal mark as deleted' })); + + await waitFor(() => expect(axios.get.mock.calls.length).toBe(callsBefore + 1)); + }); + test('renders the channel name as a link to the channel page', async () => { const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponseWithEnabledChannels([mockVideos[0]]) }); @@ -790,6 +937,15 @@ describe('VideosPage Component', () => { }); }); + test('still says how many deleted videos are hidden', async () => { + axios.get.mockResolvedValueOnce({ data: { ...mockPaginatedResponse(mockVideos), hiddenDeletedCount: 2 } }); + + render(<VideosPage token={mockToken} />); + + expect(await screen.findByText('2 deleted videos hidden.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clean up' })).toBeInTheDocument(); + }); + test('shows 6 videos per page in mobile view', async () => { const manyVideos = Array.from({ length: 10 }, (_, i) => ({ id: i, @@ -912,7 +1068,8 @@ describe('VideosPage Component', () => { test('displays missing file status for removed videos', async () => { const removedVideo = { ...mockVideos[0], - removed: true + removed: true, + removed_reason: 'not_found' as const }; axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([removedVideo]) }); @@ -1247,6 +1404,204 @@ describe('VideosPage Component', () => { }); }); + test('marks the missing videos in a selection as deleted, skipping the rest, then refetches', async () => { + const user = setupUser(); + const missingVideo = { ...mockVideos[0], removed: true, removed_reason: 'not_found' as const }; + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([missingVideo, mockVideos[1]]) }); + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[1]]) }); + axios.post.mockResolvedValueOnce({ data: { updated: 1 } }); + + render(<VideosPage token={mockToken} />); + + await waitFor(() => { + expect(screen.getByText('How to Code')).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[1]); + await user.click(checkboxes[2]); + await waitFor(() => { + expect(screen.getByText(/2 videos selected/)).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Actions (2)')); + await user.click(screen.getByText('Mark as deleted Selected')); + + expect(await screen.findByText('1 missing video will be marked as deleted and hidden by default.')).toBeInTheDocument(); + expect(screen.getByText('1 selected video is not missing and will not change.')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Mark 1 as deleted' })); + + await waitFor(() => { + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/mark-deleted', + { videoIds: [1] }, + { headers: { 'x-access-token': mockToken } } + ); + }); + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(2)); + expect(await screen.findByText('Marked 1 video as deleted')).toBeInTheDocument(); + }); + + test('shows the server message when marking as deleted is refused', async () => { + const user = setupUser(); + const missingVideo = { ...mockVideos[0], removed: true, removed_reason: 'not_found' as const }; + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse([missingVideo]) }); + axios.post.mockRejectedValueOnce({ response: { data: { error: 'Send either videoIds (1 to 1000 video ids) or all: true' } } }); + + render(<VideosPage token={mockToken} />); + await screen.findByText('How to Code'); + + await user.click(screen.getAllByRole('checkbox')[1]); + await user.click(screen.getByText('Actions (1)')); + await user.click(screen.getByText('Mark as deleted Selected')); + await user.click(await screen.findByRole('button', { name: 'Mark 1 as deleted' })); + + expect(await screen.findByText('Send either videoIds (1 to 1000 video ids) or all: true')).toBeInTheDocument(); + }); + + test('offers Mark as deleted only when the selection holds a missing video', async () => { + const user = setupUser(); + axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); + + render(<VideosPage token={mockToken} />); + + await waitFor(() => { + expect(screen.getByText('How to Code')).toBeInTheDocument(); + }); + + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[1]); + await user.click(screen.getByText('Actions (1)')); + + expect(screen.getByText('Mark as deleted Selected')).toHaveAttribute('data-disabled', 'true'); + }); + + test('reloads page one after marking an earlier infinite-scroll video as deleted', async () => { + const { useConfig } = require('../../hooks/useConfig'); + const originalObserver = window.IntersectionObserver; + let intersect = () => {}; + window.IntersectionObserver = jest.fn().mockImplementation((callback: IntersectionObserverCallback) => { + const observer = { + observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn(), + takeRecords: () => [], root: null, rootMargin: '', thresholds: [0], + }; + intersect = () => callback([{ isIntersecting: true } as IntersectionObserverEntry], observer); + return observer; + }); + useConfig.mockReturnValue({ config: { channelVideosHotLoad: true } }); + useTableView(); + const user = setupUser(); + const missingVideo = { ...mockVideos[0], removed: true, removed_reason: 'not_found' as const }; + axios.get + .mockResolvedValueOnce({ data: { ...mockPaginatedResponse([missingVideo]), total: 2, totalPages: 2 } }) + .mockResolvedValueOnce({ data: { ...mockPaginatedResponse([mockVideos[1]]), page: 2, total: 2, totalPages: 2 } }) + .mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[1]]) }); + axios.post.mockResolvedValueOnce({ data: { updated: 1 } }); + + const { unmount } = render(<VideosPage token={mockToken} />); + try { + await screen.findByText('How to Code'); + act(() => intersect()); + await screen.findByText('Game Review'); + expect(axios.get.mock.calls[1][0]).toContain('page=2'); + + await user.click(screen.getAllByRole('checkbox')[1]); + await user.click(screen.getByText('Actions (1)')); + await user.click(screen.getByText('Mark as deleted Selected')); + await user.click(await screen.findByRole('button', { name: 'Mark 1 as deleted' })); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(3)); + expect(axios.get.mock.calls[2][0]).toContain('page=1'); + await screen.findByText('Game Review'); + expect(screen.queryByText('How to Code')).not.toBeInTheDocument(); + } finally { + unmount(); + window.IntersectionObserver = originalObserver; + useConfig.mockReturnValue({ config: { channelVideosHotLoad: false } }); + } + }); + + test('reloads page one after deleting a video from a later infinite-scroll page', async () => { + const { useConfig } = require('../../hooks/useConfig'); + const originalObserver = window.IntersectionObserver; + let intersect = () => {}; + window.IntersectionObserver = jest.fn().mockImplementation((callback: IntersectionObserverCallback) => { + const observer = { + observe: jest.fn(), unobserve: jest.fn(), disconnect: jest.fn(), + takeRecords: () => [], root: null, rootMargin: '', thresholds: [0], + }; + intersect = () => callback([{ isIntersecting: true } as IntersectionObserverEntry], observer); + return observer; + }); + useConfig.mockReturnValue({ config: { channelVideosHotLoad: true } }); + useTableView(); + const user = setupUser(); + axios.get + .mockResolvedValueOnce({ data: { ...mockPaginatedResponse([mockVideos[0]]), total: 2, totalPages: 2 } }) + .mockResolvedValueOnce({ data: { ...mockPaginatedResponse([mockVideos[1]]), page: 2, total: 2, totalPages: 2 } }) + .mockResolvedValueOnce({ data: mockPaginatedResponse([mockVideos[1]]) }); + mockDeleteVideos.mockResolvedValueOnce({ success: true, deleted: [mockVideos[0].id], failed: [] }); + + const { unmount } = render(<VideosPage token={mockToken} />); + try { + await screen.findByText('How to Code'); + act(() => intersect()); + await screen.findByText('Game Review'); + + await user.click(screen.getAllByRole('checkbox')[1]); + await user.click(screen.getByText('Actions (1)')); + await user.click(screen.getByText('Delete Selected')); + await user.click(screen.getByTestId('dialog-confirm')); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(3)); + expect(axios.get.mock.calls[2][0]).toContain('page=1'); + await screen.findByText('Game Review'); + expect(screen.queryByText('How to Code')).not.toBeInTheDocument(); + } finally { + unmount(); + window.IntersectionObserver = originalObserver; + useConfig.mockReturnValue({ config: { channelVideosHotLoad: false } }); + } + }); + + test('stays on the current page after deleting in classic pagination', async () => { + const user = setupUser(); + useTableView(); + const manyVideos = Array.from({ length: 15 }, (_, i) => ({ + id: i, + youtubeId: `video${i}`, + youTubeChannelName: 'Channel', + youTubeVideoName: `Video ${i}`, + timeCreated: '2024-01-15T10:30:00', + originalDate: '20240110', + duration: 600, + description: 'Description', + removed: false, + fileSize: null + })); + axios.get + .mockResolvedValueOnce({ data: mockPaginatedResponse(manyVideos, 1, 12) }) + .mockResolvedValueOnce({ data: mockPaginatedResponse(manyVideos, 2, 12) }) + .mockResolvedValueOnce({ data: mockPaginatedResponse(manyVideos.filter((v) => v.id !== 12), 2, 12) }); + mockDeleteVideos.mockResolvedValueOnce({ success: true, deleted: [12], failed: [] }); + + render(<VideosPage token={mockToken} />); + await screen.findByText('Video 0'); + await user.click(screen.getByRole('button', { name: /go to page 2/i })); + await screen.findByText('Video 12'); + + await user.click(screen.getAllByRole('checkbox')[1]); + await user.click(screen.getByText('Actions (1)')); + await user.click(screen.getByText('Delete Selected')); + await user.click(screen.getByTestId('dialog-confirm')); + + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(3)); + expect(axios.get.mock.calls[2][0]).toContain('page=2'); + expect(await screen.findByText('Video 13')).toBeInTheDocument(); + expect(screen.queryByText('Video 12')).not.toBeInTheDocument(); + }); + test('opens delete dialog when delete button is clicked', async () => { const user = setupUser(); axios.get.mockResolvedValueOnce({ data: mockPaginatedResponse(mockVideos) }); @@ -1616,9 +1971,10 @@ describe('VideosPage Component', () => { const checkboxes = screen.getAllByRole('checkbox'); await user.click(checkboxes[1]); - // Actions button should be disabled - const actionsButton = screen.getByText('Actions (1)'); - expect(actionsButton).toBeDisabled(); + // The delete action should be disabled; the other actions stay available + await user.click(screen.getByText('Actions (1)')); + expect(screen.getByText('Delete Selected')).toHaveAttribute('data-disabled', 'true'); + expect(screen.getByText('Rating Selected')).toHaveAttribute('data-disabled', 'false'); }); }); @@ -1918,4 +2274,4 @@ describe('VideosPage Component', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/client/src/components/shared/AvailabilityChip.tsx b/client/src/components/shared/AvailabilityChip.tsx index cf34ae35a..7faa34055 100644 --- a/client/src/components/shared/AvailabilityChip.tsx +++ b/client/src/components/shared/AvailabilityChip.tsx @@ -1,45 +1,102 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Chip, Tooltip } from '../ui'; import { - AlertCircle as ErrorOutlineIcon, CheckCircle as CheckCircleIcon, + CloudOff as MissingIcon, + Trash2 as DeletedIcon, } from 'lucide-react'; import { SHARED_THEMED_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from './chipStyles'; +import { useMediaQuery } from '../../hooks/useMediaQuery'; +import type { RemovalState } from '../../utils/videoRemoval'; +import { REMOVAL_TOOLTIP_CLASS } from './RemovalDescription'; export interface AvailabilityChipProps { - isAvailable: boolean; + // present = Available; missing = red, a file was expected and not found; + // deleted = neutral, Youtarr removed the file (or the user marked it deleted). + state: RemovalState; + // Plain-language explanation (see RemovalDescription); shown on hover or tap. + tooltip?: React.ReactNode; + // Replaces the state's label, e.g. a "2 missing" summary count. + label?: string; compact?: boolean; } -const baseAvailableStyle: React.CSSProperties = { - ...SHARED_THEMED_CHIP_SMALL_STYLE, - backgroundColor: 'var(--success)', - color: 'var(--success-foreground)', -}; +interface Variant { + label: string; + tooltip: string; + color: 'success' | 'error' | 'default'; + variant: 'filled' | 'outlined'; + icon: React.ReactNode; + style: React.CSSProperties; +} -const baseMissingStyle: React.CSSProperties = { - ...SHARED_THEMED_CHIP_SMALL_STYLE, - backgroundColor: 'var(--destructive)', - color: 'var(--destructive-foreground)', +const VARIANTS: Record<RemovalState, Variant> = { + present: { + label: 'Available', + tooltip: 'Video file exists on disk', + color: 'success', + variant: 'filled', + icon: <CheckCircleIcon size={12} />, + style: { + ...SHARED_THEMED_CHIP_SMALL_STYLE, + backgroundColor: 'var(--success)', + color: 'var(--success-foreground)', + }, + }, + missing: { + label: 'Missing', + tooltip: 'Video file not found on disk', + color: 'error', + variant: 'filled', + icon: <MissingIcon size={12} data-testid="CloudOffIcon" />, + style: { + ...SHARED_THEMED_CHIP_SMALL_STYLE, + backgroundColor: 'var(--destructive)', + color: 'var(--destructive-foreground)', + }, + }, + deleted: { + label: 'Deleted', + tooltip: 'Video file was deleted', + color: 'default', + variant: 'outlined', + icon: <DeletedIcon size={12} data-testid="DeletedChipIcon" />, + style: { + ...SHARED_THEMED_CHIP_SMALL_STYLE, + backgroundColor: 'transparent', + color: 'var(--muted-foreground)', + }, + }, }; -function AvailabilityChip({ isAvailable, compact = false }: AvailabilityChipProps) { - const baseStyle = isAvailable ? baseAvailableStyle : baseMissingStyle; - const style = compact ? { ...baseStyle, ...SHARED_COMPACT_CHIP_OVERRIDES } : baseStyle; - const tooltip = isAvailable ? 'Video file exists on disk' : 'Video file not found on disk'; - const label = isAvailable ? 'Available' : 'Missing'; - const icon = isAvailable ? <CheckCircleIcon size={12} /> : <ErrorOutlineIcon size={12} />; +function AvailabilityChip({ state, tooltip, label, compact = false }: AvailabilityChipProps) { + const variant = VARIANTS[state]; + const style = compact ? { ...variant.style, ...SHARED_COMPACT_CHIP_OVERRIDES } : variant.style; + // On a touch device a tap opens the tooltip (the only way to read the reason + // on a phone), so it must not also select or expand the row underneath. + // Mouse clicks still pass through, as rows that expand on click expect, and + // so do taps where the tooltip does not open on tap: the same query as Tooltip. + const lastPointerType = useRef('mouse'); + const tapOpensTooltip = useMediaQuery('(pointer: coarse)'); return ( - <Tooltip title={tooltip} enterTouchDelay={0}> - <Chip - size="small" - icon={icon} - label={label} - color={isAvailable ? 'success' : 'error'} - variant="filled" - style={style} - /> + <Tooltip title={tooltip ?? variant.tooltip} enterTouchDelay={0} className={REMOVAL_TOOLTIP_CLASS}> + <span + className="inline-flex" + onPointerDown={(event) => { lastPointerType.current = event.pointerType; }} + onClick={(event) => { + if (tapOpensTooltip && lastPointerType.current !== 'mouse') event.stopPropagation(); + }} + > + <Chip + size="small" + icon={variant.icon} + label={label ?? variant.label} + color={variant.color} + variant={variant.variant} + style={style} + /> + </span> </Tooltip> ); } diff --git a/client/src/components/VideosPage/components/ChannelNameDisplay.tsx b/client/src/components/shared/ChannelNameDisplay.tsx similarity index 96% rename from client/src/components/VideosPage/components/ChannelNameDisplay.tsx rename to client/src/components/shared/ChannelNameDisplay.tsx index c8575677f..506078a06 100644 --- a/client/src/components/VideosPage/components/ChannelNameDisplay.tsx +++ b/client/src/components/shared/ChannelNameDisplay.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { Typography } from '../../ui'; -import { Plus } from '../../../lib/icons'; +import { Typography } from '../ui'; +import { Plus } from '../../lib/icons'; interface ChannelNameDisplayProps { channelName: string; diff --git a/client/src/components/shared/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/PlaylistPage/components/InfoHint.tsx b/client/src/components/shared/InfoHint.tsx similarity index 90% rename from client/src/components/PlaylistPage/components/InfoHint.tsx rename to client/src/components/shared/InfoHint.tsx index cbea62365..714ce9fc3 100644 --- a/client/src/components/PlaylistPage/components/InfoHint.tsx +++ b/client/src/components/shared/InfoHint.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Tooltip } from '../../ui'; -import { Info as InfoIcon } from '../../../lib/icons'; +import { Tooltip } from '../ui'; +import { Info as InfoIcon } from '../../lib/icons'; interface InfoHintProps { label: string; diff --git a/client/src/components/shared/RemovalDescription.tsx b/client/src/components/shared/RemovalDescription.tsx new file mode 100644 index 000000000..af1fb859a --- /dev/null +++ b/client/src/components/shared/RemovalDescription.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { + describeMissing, + describeRow, + getRemovalState, + removalFromRow, + type RemovalRow, +} from '../../utils/videoRemoval'; + +// Tooltips showing a RemovalDescription widen past the default so a file path +// wraps less. Touch tooltips keep their own width cap. +export const REMOVAL_TOOLTIP_CLASS = 'max-w-md'; + +interface RemovalDescriptionProps { + row: RemovalRow; +} + +// Why a video has no file, for a chip tooltip. A missing file's path sits on +// its own line in a monospace pill so it stands apart from the explanation; +// a color would not, since the tooltip inverts between light and dark themes. +function RemovalDescription({ row }: RemovalDescriptionProps) { + const { path, rest } = describeMissing(removalFromRow(row), row.filePath); + if (getRemovalState(row.removed, row.removed_reason) !== 'missing' || !path) { + return <>{describeRow(row)}</>; + } + + return ( + <> + Youtarr expected a file at:{' '} + <code className="my-1 block break-all rounded bg-background/15 px-1.5 py-0.5 font-mono">{path}</code>{' '} + {rest} + </> + ); +} + +export default RemovalDescription; diff --git a/client/src/components/shared/VideoList/VideoListContainer.tsx b/client/src/components/shared/VideoList/VideoListContainer.tsx index a8a090db3..780a185e6 100644 --- a/client/src/components/shared/VideoList/VideoListContainer.tsx +++ b/client/src/components/shared/VideoList/VideoListContainer.tsx @@ -85,6 +85,9 @@ function VideoListContainer<IdType extends string | number>({ const hasFilters = activeCount > 0; const hasSearch = Boolean(state.search); const hasContent = itemCount > 0; + // The open inline panel already shows every active filter. The mobile drawer + // covers the page, so its chips stay put rather than shifting the content behind it. + const showSummaryChips = hasFilters && (isMobile || !state.filterPanelOpen); return ( <div data-testid="video-list-container"> @@ -116,7 +119,7 @@ function VideoListContainer<IdType extends string | number>({ customFilters={customFilters} /> - {activeCount > 0 && ( + {showSummaryChips && ( <div style={{ padding: isMobile ? '0 10px 6px 10px' : '0 16px 8px 16px' }} > diff --git a/client/src/components/shared/VideoList/VideoListFilterChips.tsx b/client/src/components/shared/VideoList/VideoListFilterChips.tsx index 99e6e9fdb..bee2cf22c 100644 --- a/client/src/components/shared/VideoList/VideoListFilterChips.tsx +++ b/client/src/components/shared/VideoList/VideoListFilterChips.tsx @@ -152,18 +152,25 @@ function VideoListFilterChips({ filters }: VideoListFilterChipsProps) { } if (isStatusFilter(filter)) { - if (filter.value === 'off') continue; + // A chip at its resting value is not a filter the user applied. + const resting = filter.defaultValue ?? 'off'; + if (filter.value === resting) continue; const { Icon, noun } = STATUS_CHIP_DESCRIPTORS[filter.id]; + const label = filter.value === 'only' + ? `Only: ${noun}` + : filter.value === 'exclude' + ? `Hide: ${noun}` + : `Show: ${noun}`; chips.push( <Chip key={filter.id} icon={<Icon size={14} />} - label={filter.value === 'only' ? `Only: ${noun}` : `Hide: ${noun}`} + label={label} size="small" - onDelete={() => filter.onChange('off')} - onClick={() => filter.onChange('off')} + onDelete={() => filter.onChange(resting)} + onClick={() => filter.onChange(resting)} deleteIcon={<CloseIcon data-testid="CancelIcon" size={14} />} - color={filter.value === 'only' ? 'primary' : 'warning'} + color={filter.value === 'exclude' ? 'warning' : 'primary'} variant="outlined" /> ); @@ -208,7 +215,7 @@ export function countActiveFilters(filters: FilterConfig[]): number { else if (filter.id === 'dateRange' && !filter.hidden && (filter.dateFrom !== null || filter.dateTo !== null)) count++; else if (filter.id === 'dateRangeString' && !filter.hidden && (filter.dateFrom || filter.dateTo)) count++; else if (filter.id === 'maxRating' && filter.value) count++; - else if (isStatusFilter(filter) && filter.value !== 'off') count++; + else if (isStatusFilter(filter) && filter.value !== (filter.defaultValue ?? 'off')) count++; else if (filter.id === 'channel' && filter.value) count++; } return count; @@ -232,7 +239,7 @@ export function clearAllFilters(filters: FilterConfig[]): void { } else if (filter.id === 'maxRating') { filter.onChange(''); } else if (isStatusFilter(filter)) { - filter.onChange('off'); + filter.onChange(filter.defaultValue ?? 'off'); } else if (filter.id === 'channel') { filter.onChange(''); } diff --git a/client/src/components/shared/VideoList/VideoListFilterPanel.tsx b/client/src/components/shared/VideoList/VideoListFilterPanel.tsx index b9ee65166..ebbb7fae4 100644 --- a/client/src/components/shared/VideoList/VideoListFilterPanel.tsx +++ b/client/src/components/shared/VideoList/VideoListFilterPanel.tsx @@ -82,6 +82,8 @@ function renderFilter(filter: FilterConfig, compact: boolean): React.ReactNode { inactiveLabel={noun} onlyLabel={`Only: ${noun}`} excludeLabel={`Hide: ${noun}`} + offLabel={`Show: ${noun}`} + restingValue={filter.defaultValue} /> ); } diff --git a/client/src/components/shared/VideoList/VideoListSortButton.tsx b/client/src/components/shared/VideoList/VideoListSortButton.tsx new file mode 100644 index 000000000..98e1e1e0c --- /dev/null +++ b/client/src/components/shared/VideoList/VideoListSortButton.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { Button, Menu, MenuItem, ListItemIcon, ListItemText } from '../../ui'; +import { + ArrowUpward as ArrowUpIcon, + ArrowDownward as ArrowDownIcon, + Check as CheckIcon, +} from '../../../lib/icons'; +import { SortConfig } from './types'; + +export interface VideoListSortButtonProps { + sort: SortConfig; +} + +function DirectionIcon({ direction }: { direction: 'asc' | 'desc' }) { + const Icon = direction === 'asc' ? ArrowUpIcon : ArrowDownIcon; + return <Icon size={14} data-testid={`sort-direction-${direction}`} />; +} + +function VideoListSortButton({ sort }: VideoListSortButtonProps) { + const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); + + const active = sort.options.find((o) => o.key === sort.activeKey); + const label = active ? active.label : 'Sort'; + const activeIsDirected = !active?.undirected; + + return ( + <> + <Button + variant="outlined" + color="inherit" + size="small" + onClick={(e) => setAnchorEl(e.currentTarget)} + className="hover:border-foreground" + endIcon={activeIsDirected ? <DirectionIcon direction={sort.direction} /> : undefined} + data-testid="video-list-sort-button" + > + Sort: {label} + </Button> + <Menu + anchorEl={anchorEl} + open={Boolean(anchorEl)} + onClose={() => setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + {sort.options.map((option) => { + const isActive = option.key === sort.activeKey; + return ( + <MenuItem + key={option.key} + onClick={() => { + if (!isActive) { + sort.onChange(option.key, 'desc'); + } else if (!option.undirected) { + sort.onChange(option.key, sort.direction === 'asc' ? 'desc' : 'asc'); + } + setAnchorEl(null); + }} + > + <ListItemText>{option.label}</ListItemText> + <ListItemIcon style={{ marginLeft: 8, minWidth: 'auto', opacity: isActive ? 1 : 0 }}> + {isActive && !option.undirected ? ( + <DirectionIcon direction={sort.direction} /> + ) : ( + <CheckIcon size={14} /> + )} + </ListItemIcon> + </MenuItem> + ); + })} + </Menu> + </> + ); +} + +export default VideoListSortButton; diff --git a/client/src/components/shared/VideoList/VideoListToolbar.tsx b/client/src/components/shared/VideoList/VideoListToolbar.tsx index ad72cc351..fe2ee449d 100644 --- a/client/src/components/shared/VideoList/VideoListToolbar.tsx +++ b/client/src/components/shared/VideoList/VideoListToolbar.tsx @@ -1,21 +1,8 @@ -import React, { useState } from 'react'; -import { - Badge, - Button, - Menu, - MenuItem, - TextField, - ListItemIcon, - ListItemText, -} from '../../ui'; -import { - Search as SearchIcon, - ListFilter as FilterListIcon, - ArrowUpward as ArrowUpIcon, - ArrowDownward as ArrowDownIcon, - Check as CheckIcon, -} from '../../../lib/icons'; +import React from 'react'; +import { Badge, Button, TextField } from '../../ui'; +import { Search as SearchIcon, ListFilter as FilterListIcon } from '../../../lib/icons'; import VideoListViewToggle from './VideoListViewToggle'; +import VideoListSortButton from './VideoListSortButton'; import { VideoListViewMode, SortConfig } from './types'; import { VideoListState } from './hooks/useVideoListState'; @@ -32,68 +19,6 @@ export interface VideoListToolbarProps { isMobile: boolean; } -function SortButton({ sort }: { sort: SortConfig }) { - const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); - - const active = sort.options.find((o) => o.key === sort.activeKey); - const label = active ? active.label : 'Sort'; - const DirectionIcon = sort.direction === 'asc' ? ArrowUpIcon : ArrowDownIcon; - - return ( - <> - <Button - variant="outlined" - size="small" - onClick={(e) => setAnchorEl(e.currentTarget)} - className="text-foreground border-border hover:bg-muted hover:border-foreground" - endIcon={<DirectionIcon size={14} />} - data-testid="video-list-sort-button" - > - Sort: {label} - </Button> - <Menu - anchorEl={anchorEl} - open={Boolean(anchorEl)} - onClose={() => setAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} - > - {sort.options.map((option) => { - const isActive = option.key === sort.activeKey; - return ( - <MenuItem - key={option.key} - onClick={() => { - if (isActive) { - sort.onChange(option.key, sort.direction === 'asc' ? 'desc' : 'asc'); - } else { - sort.onChange(option.key, 'desc'); - } - setAnchorEl(null); - }} - > - <ListItemText>{option.label}</ListItemText> - {isActive && ( - <ListItemIcon style={{ marginLeft: 8, minWidth: 'auto' }}> - {sort.direction === 'asc' ? ( - <ArrowUpIcon size={14} /> - ) : ( - <ArrowDownIcon size={14} /> - )} - </ListItemIcon> - )} - {!isActive && ( - <ListItemIcon style={{ marginLeft: 8, minWidth: 'auto', opacity: 0 }}> - <CheckIcon size={14} /> - </ListItemIcon> - )} - </MenuItem> - ); - })} - </Menu> - </> - ); -} - function VideoListToolbar({ state, viewModes, @@ -109,6 +34,7 @@ function VideoListToolbar({ const filtersButton = onFiltersClick ? ( <Button variant={filtersButtonActive ? 'contained' : 'outlined'} + color={filtersButtonActive ? 'primary' : 'inherit'} size="small" onClick={onFiltersClick} startIcon={ @@ -120,11 +46,7 @@ function VideoListToolbar({ <FilterListIcon size={16} /> </Badge> } - className={ - filtersButtonActive - ? undefined - : 'text-foreground border-border hover:bg-muted hover:border-foreground' - } + className={filtersButtonActive ? undefined : 'hover:border-foreground'} data-testid="video-list-filters-button" > Filters @@ -147,7 +69,7 @@ function VideoListToolbar({ <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> <VideoListViewToggle value={state.viewMode} modes={viewModes} onChange={state.setViewMode} /> {filtersButton} - {sort && <SortButton sort={sort} />} + {sort && <VideoListSortButton sort={sort} />} <div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}> {rightActions} </div> @@ -182,7 +104,7 @@ function VideoListToolbar({ /> <VideoListViewToggle value={state.viewMode} modes={viewModes} onChange={state.setViewMode} /> {filtersButton} - {sort && <SortButton sort={sort} />} + {sort && <VideoListSortButton sort={sort} />} {toolbarExtras} <div style={{ marginLeft: 'auto', display: 'flex', gap: 8, alignItems: 'center' }}> {rightActions} diff --git a/client/src/components/shared/VideoList/__tests__/VideoListContainer.test.tsx b/client/src/components/shared/VideoList/__tests__/VideoListContainer.test.tsx index 57d046971..c7662e47c 100644 --- a/client/src/components/shared/VideoList/__tests__/VideoListContainer.test.tsx +++ b/client/src/components/shared/VideoList/__tests__/VideoListContainer.test.tsx @@ -1,20 +1,26 @@ import React from 'react'; import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import VideoListContainer from '../VideoListContainer'; import { useVideoListState } from '../hooks/useVideoListState'; +import { FilterConfig } from '../types'; import { renderWithProviders } from '../../../../test-utils'; interface HarnessProps { isLoading?: boolean; itemCount?: number; paginationMode?: 'pages' | 'infinite'; + filters?: FilterConfig[]; + isMobile?: boolean; } function Harness({ isLoading = false, itemCount = 0, paginationMode = 'pages', + filters, + isMobile = false, }: HarnessProps) { const state = useVideoListState({ initialViewMode: 'grid' }); return ( @@ -24,14 +30,19 @@ function Harness({ isLoading={isLoading} isError={false} paginationMode={paginationMode} + filters={filters} renderContent={() => ( <div data-testid="content">{itemCount} items</div> )} - isMobile={false} + isMobile={isMobile} /> ); } +function activeFilters(): FilterConfig[] { + return [{ id: 'missing', value: 'only', onChange: jest.fn() }]; +} + describe('VideoListContainer', () => { test('renders top and bottom loading bars during a paginated refetch with existing content', () => { renderWithProviders( @@ -65,4 +76,41 @@ describe('VideoListContainer', () => { expect(screen.queryByTestId('video-list-loading-bar-top')).not.toBeInTheDocument(); expect(screen.queryByTestId('video-list-loading-bar-bottom')).not.toBeInTheDocument(); }); + + describe('active filter chips', () => { + test('shows the chips while the filter panel is closed', () => { + renderWithProviders(<Harness filters={activeFilters()} />); + expect(screen.getByTestId('video-list-filter-chips')).toBeInTheDocument(); + }); + + test('hides the chips while the inline filter panel is open', async () => { + const user = userEvent.setup(); + renderWithProviders(<Harness filters={activeFilters()} />); + + await user.click(screen.getByTestId('video-list-filters-button')); + + expect(screen.getByTestId('video-list-filter-panel-inline')).toBeInTheDocument(); + expect(screen.queryByTestId('video-list-filter-chips')).not.toBeInTheDocument(); + }); + + test('shows the chips again after the inline filter panel closes', async () => { + const user = userEvent.setup(); + renderWithProviders(<Harness filters={activeFilters()} />); + + await user.click(screen.getByTestId('video-list-filters-button')); + await user.click(screen.getByTestId('video-list-filters-button')); + + expect(screen.getByTestId('video-list-filter-chips')).toBeInTheDocument(); + }); + + test('keeps the chips while the mobile filter drawer is open', async () => { + const user = userEvent.setup(); + renderWithProviders(<Harness filters={activeFilters()} isMobile />); + + await user.click(screen.getByTestId('video-list-filters-button')); + + expect(screen.getByTestId('video-list-filter-drawer')).toBeInTheDocument(); + expect(screen.getByTestId('video-list-filter-chips')).toBeInTheDocument(); + }); + }); }); diff --git a/client/src/components/shared/VideoList/__tests__/VideoListFilterChips.test.tsx b/client/src/components/shared/VideoList/__tests__/VideoListFilterChips.test.tsx index 840f2cc0c..5d3a0db7c 100644 --- a/client/src/components/shared/VideoList/__tests__/VideoListFilterChips.test.tsx +++ b/client/src/components/shared/VideoList/__tests__/VideoListFilterChips.test.tsx @@ -169,6 +169,48 @@ describe('VideoListFilterChips', () => { expect(onMissingChange).toHaveBeenCalledWith('off'); }); + describe('a chip whose resting value is not off (the Videos page hides deleted videos by default)', () => { + test('renders no chip and counts nothing while at its resting value', () => { + const filters: FilterConfig[] = [ + { id: 'deleted', value: 'exclude', onChange: jest.fn(), defaultValue: 'exclude' }, + ]; + renderWithProviders(<VideoListFilterChips filters={filters} />); + expect(screen.queryByTestId('video-list-filter-chips')).not.toBeInTheDocument(); + expect(countActiveFilters(filters)).toBe(0); + }); + + test('renders a "Show" chip when turned off, counts it, and clicking it returns to the resting value', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + const filters: FilterConfig[] = [ + { id: 'deleted', value: 'off', onChange, defaultValue: 'exclude' }, + ]; + renderWithProviders(<VideoListFilterChips filters={filters} />); + expect(screen.getByText('Show: Deleted')).toBeInTheDocument(); + expect(countActiveFilters(filters)).toBe(1); + await user.click(screen.getByTestId('CancelIcon')); + expect(onChange).toHaveBeenCalledWith('exclude'); + }); + + test('clearAllFilters returns it to its resting value, not off', () => { + const onChange = jest.fn(); + const filters: FilterConfig[] = [ + { id: 'deleted', value: 'only', onChange, defaultValue: 'exclude' }, + ]; + clearAllFilters(filters); + expect(onChange).toHaveBeenCalledWith('exclude'); + }); + + test('a chip without a resting value still rests at off', () => { + const filters: FilterConfig[] = [ + { id: 'protected', value: 'only', onChange: jest.fn() }, + ]; + renderWithProviders(<VideoListFilterChips filters={filters} />); + expect(screen.getByText(/Only: Protected/i)).toBeInTheDocument(); + expect(countActiveFilters(filters)).toBe(1); + }); + }); + test('countActiveFilters counts missing and ignored when on', () => { const filters: FilterConfig[] = [ { id: 'protected', value: 'only', onChange: jest.fn() }, diff --git a/client/src/components/shared/VideoList/__tests__/VideoListSortButton.test.tsx b/client/src/components/shared/VideoList/__tests__/VideoListSortButton.test.tsx new file mode 100644 index 000000000..4f7e75a80 --- /dev/null +++ b/client/src/components/shared/VideoList/__tests__/VideoListSortButton.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import VideoListSortButton from '../VideoListSortButton'; +import { SortConfig } from '../types'; +import { renderWithProviders } from '../../../../test-utils'; + +function buildSort(overrides: Partial<SortConfig> = {}): SortConfig { + return { + options: [ + { key: 'relevance', label: 'Relevance', undirected: true }, + { key: 'published', label: 'Published' }, + ], + activeKey: 'published', + direction: 'desc', + onChange: jest.fn(), + ...overrides, + }; +} + +describe('VideoListSortButton', () => { + test('shows the active label with a direction arrow for a directional sort', () => { + renderWithProviders(<VideoListSortButton sort={buildSort()} />); + const button = screen.getByTestId('video-list-sort-button'); + expect(button).toHaveTextContent('Sort: Published'); + expect(screen.getByTestId('sort-direction-desc')).toBeInTheDocument(); + }); + + test('hides the direction arrow while an undirected sort is active', () => { + renderWithProviders(<VideoListSortButton sort={buildSort({ activeKey: 'relevance' })} />); + expect(screen.getByTestId('video-list-sort-button')).toHaveTextContent('Sort: Relevance'); + expect(screen.queryByTestId('sort-direction-desc')).not.toBeInTheDocument(); + expect(screen.queryByTestId('sort-direction-asc')).not.toBeInTheDocument(); + }); + + test('choosing a different option sorts it descending', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders(<VideoListSortButton sort={buildSort({ activeKey: 'relevance', onChange })} />); + await user.click(screen.getByTestId('video-list-sort-button')); + await user.click(await screen.findByRole('menuitem', { name: /published/i })); + expect(onChange).toHaveBeenCalledWith('published', 'desc'); + }); + + test('choosing the active directional option flips its direction', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders(<VideoListSortButton sort={buildSort({ onChange })} />); + await user.click(screen.getByTestId('video-list-sort-button')); + await user.click(await screen.findByRole('menuitem', { name: /published/i })); + expect(onChange).toHaveBeenCalledWith('published', 'asc'); + }); + + test('choosing the active undirected option changes nothing', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders(<VideoListSortButton sort={buildSort({ activeKey: 'relevance', onChange })} />); + await user.click(screen.getByTestId('video-list-sort-button')); + await user.click(await screen.findByRole('menuitem', { name: /relevance/i })); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/shared/VideoList/__tests__/VideoListToolbar.test.tsx b/client/src/components/shared/VideoList/__tests__/VideoListToolbar.test.tsx index 571c6d21e..4468be5c9 100644 --- a/client/src/components/shared/VideoList/__tests__/VideoListToolbar.test.tsx +++ b/client/src/components/shared/VideoList/__tests__/VideoListToolbar.test.tsx @@ -98,6 +98,47 @@ describe('VideoListToolbar', () => { expect(onFiltersClick).toHaveBeenCalledTimes(1); }); + test('inactive filters button keeps foreground text on hover', () => { + renderWithProviders( + <VideoListToolbar + state={buildState()} + viewModes={VIEW_MODES} + onFiltersClick={jest.fn()} + isMobile={false} + /> + ); + const filtersButton = screen.getByTestId('video-list-filters-button'); + expect(filtersButton).toHaveClass('hover:text-foreground'); + expect(filtersButton).not.toHaveClass('hover:text-primary-foreground'); + }); + + test('active filters button uses the primary fill', () => { + renderWithProviders( + <VideoListToolbar + state={buildState()} + viewModes={VIEW_MODES} + onFiltersClick={jest.fn()} + filtersButtonActive + isMobile={false} + /> + ); + expect(screen.getByTestId('video-list-filters-button')).toHaveClass('bg-primary'); + }); + + test('sort button keeps foreground text on hover', () => { + renderWithProviders( + <VideoListToolbar + state={buildState()} + viewModes={VIEW_MODES} + sort={buildSort()} + isMobile={false} + /> + ); + const sortButton = screen.getByTestId('video-list-sort-button'); + expect(sortButton).toHaveClass('hover:text-foreground'); + expect(sortButton).not.toHaveClass('hover:text-primary-foreground'); + }); + test('omits the sort button when sort prop is missing', () => { renderWithProviders( <VideoListToolbar diff --git a/client/src/components/shared/VideoList/__tests__/usePersistedChipFilter.test.ts b/client/src/components/shared/VideoList/__tests__/usePersistedChipFilter.test.ts new file mode 100644 index 000000000..b01367977 --- /dev/null +++ b/client/src/components/shared/VideoList/__tests__/usePersistedChipFilter.test.ts @@ -0,0 +1,44 @@ +import { renderHook, act } from '@testing-library/react'; +import { usePersistedChipFilter } from '../usePersistedChipFilter'; + +const STORAGE_KEY = 'youtarr.test.deletedFilter'; + +describe('usePersistedChipFilter', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('returns the default when nothing is stored', () => { + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + expect(result.current[0]).toBe('exclude'); + }); + + test('returns a stored mode', () => { + localStorage.setItem(STORAGE_KEY, 'only'); + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + expect(result.current[0]).toBe('only'); + }); + + test.each(['', 'bogus', 'ONLY', '1'])('falls back to the default for the stored value "%s"', (bad) => { + localStorage.setItem(STORAGE_KEY, bad); + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + expect(result.current[0]).toBe('exclude'); + }); + + test('remembers a change in storage and in state', () => { + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + act(() => result.current[1]('off')); + expect(result.current[0]).toBe('off'); + expect(localStorage.getItem(STORAGE_KEY)).toBe('off'); + }); + + test('keeps working in memory when storage throws', () => { + const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + const { result } = renderHook(() => usePersistedChipFilter(STORAGE_KEY, 'exclude')); + act(() => result.current[1]('only')); + expect(result.current[0]).toBe('only'); + setItem.mockRestore(); + }); +}); diff --git a/client/src/components/shared/VideoList/filters/ChannelFilter.tsx b/client/src/components/shared/VideoList/filters/ChannelFilter.tsx index 882120308..d74791462 100644 --- a/client/src/components/shared/VideoList/filters/ChannelFilter.tsx +++ b/client/src/components/shared/VideoList/filters/ChannelFilter.tsx @@ -39,10 +39,11 @@ function ChannelFilter({ value, options, onChange, buttonLabel }: ChannelFilterP <> <Button variant="outlined" + color="inherit" size="small" startIcon={<FilterListIcon size={16} data-testid="FilterListIcon" />} onClick={(event) => setAnchorEl(event.currentTarget)} - className="text-foreground border-border hover:bg-muted hover:border-foreground" + className="hover:border-foreground" > {label} </Button> diff --git a/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx b/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx index 59ce7bdc2..5865f279c 100644 --- a/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx +++ b/client/src/components/shared/VideoList/filters/ToggleChipFilter.tsx @@ -9,6 +9,11 @@ export interface ToggleChipFilterProps { inactiveLabel: string; onlyLabel: string; excludeLabel: string; + // The value the chip returns to when cleared (default off). A chip whose + // resting value is exclude, like "deleted videos hidden", is active when off. + restingValue?: ChipFilterMode; + // Label for the off state when it is not the resting value (e.g. "Show: Deleted"). + offLabel?: string; size?: 'small' | 'medium'; } @@ -25,13 +30,23 @@ function ToggleChipFilter({ inactiveLabel, onlyLabel, excludeLabel, + restingValue = 'off', + offLabel, size = 'small', }: ToggleChipFilterProps) { - const label = value === 'only' ? onlyLabel : value === 'exclude' ? excludeLabel : inactiveLabel; - const color = value === 'only' ? 'primary' : value === 'exclude' ? 'warning' : 'default'; - const variant = value === 'off' ? 'outlined' : 'filled'; - const ariaPressed: boolean | 'mixed' = - value === 'off' ? false : value === 'only' ? true : 'mixed'; + const offIsActive = restingValue !== 'off'; + const label = value === 'only' + ? onlyLabel + : value === 'exclude' + ? excludeLabel + : (offIsActive && offLabel) || inactiveLabel; + const isResting = value === restingValue; + // A chip at rest is not filtering anything the user chose, so it looks + // neutral even when its resting label reads "Hide: ...". + const color = isResting ? 'default' : value === 'exclude' ? 'warning' : 'primary'; + const variant = isResting ? 'outlined' : 'filled'; + // Pressed means "moved off the resting value"; the label carries which mode. + const ariaPressed = !isResting; return ( <Chip @@ -41,7 +56,7 @@ function ToggleChipFilter({ color={color} size={size} onClick={() => onChange(NEXT_MODE[value])} - onDelete={value === 'off' ? undefined : () => onChange('off')} + onDelete={isResting ? undefined : () => onChange(restingValue)} style={{ cursor: 'pointer' }} aria-pressed={ariaPressed} /> diff --git a/client/src/components/shared/VideoList/filters/__tests__/ChannelFilter.test.tsx b/client/src/components/shared/VideoList/filters/__tests__/ChannelFilter.test.tsx new file mode 100644 index 000000000..126c4c88d --- /dev/null +++ b/client/src/components/shared/VideoList/filters/__tests__/ChannelFilter.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ChannelFilter from '../ChannelFilter'; + +describe('ChannelFilter', () => { + test('button keeps foreground text on hover', () => { + render(<ChannelFilter value="" options={['Channel A']} onChange={jest.fn()} />); + const button = screen.getByRole('button', { name: 'Filter by Channel' }); + expect(button).toHaveClass('hover:text-foreground'); + expect(button).not.toHaveClass('hover:text-primary-foreground'); + }); +}); diff --git a/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx b/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx index 47ce47666..c1d38a2d5 100644 --- a/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx +++ b/client/src/components/shared/VideoList/filters/__tests__/ToggleChipFilter.test.tsx @@ -99,6 +99,96 @@ describe('ToggleChipFilter', () => { expect(screen.queryByLabelText('Remove')).not.toBeInTheDocument(); }); + test('announces only and exclude as pressed, and off as not pressed', () => { + const { unmount } = renderWithProviders( + <ToggleChipFilter {...defaultProps} value="exclude" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + unmount(); + + const { unmount: unmountOnly } = renderWithProviders( + <ToggleChipFilter {...defaultProps} value="only" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + unmountOnly(); + + renderWithProviders( + <ToggleChipFilter {...defaultProps} value="off" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false'); + }); + + describe('with a resting value other than off', () => { + const restingProps = { + ...defaultProps, + inactiveLabel: 'Deleted', + onlyLabel: 'Only: Deleted', + excludeLabel: 'Hide: Deleted', + offLabel: 'Show: Deleted', + restingValue: 'exclude' as const, + }; + + test('shows no delete affordance while resting at exclude', () => { + renderWithProviders( + <ToggleChipFilter {...restingProps} value="exclude" onChange={jest.fn()} /> + ); + expect(screen.getByText('Hide: Deleted')).toBeInTheDocument(); + expect(screen.queryByLabelText('Remove')).not.toBeInTheDocument(); + }); + + test('looks neutral while resting, like other chips that are not filtering', () => { + renderWithProviders( + <ToggleChipFilter {...restingProps} value="exclude" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveClass('chip-default'); + expect(screen.getByRole('button')).not.toHaveClass('chip-warning'); + }); + + test('announces the resting state as not pressed and the active off state as pressed', () => { + const { unmount } = renderWithProviders( + <ToggleChipFilter {...restingProps} value="exclude" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'false'); + unmount(); + + renderWithProviders( + <ToggleChipFilter {...restingProps} value="off" onChange={jest.fn()} /> + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + }); + + test('labels the off state as showing, with a delete affordance back to resting', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders( + <ToggleChipFilter {...restingProps} value="off" onChange={onChange} /> + ); + expect(screen.getByText('Show: Deleted')).toBeInTheDocument(); + await user.click(screen.getByLabelText('Remove')); + expect(onChange).toHaveBeenCalledWith('exclude'); + }); + + test('delete from only returns to the resting value', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders( + <ToggleChipFilter {...restingProps} value="only" onChange={onChange} /> + ); + await user.click(screen.getByLabelText('Remove')); + expect(onChange).toHaveBeenCalledWith('exclude'); + }); + + test('still cycles off -> only -> exclude -> off on click', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + renderWithProviders( + <ToggleChipFilter {...restingProps} value="exclude" onChange={onChange} /> + ); + await user.click(screen.getByText('Hide: Deleted')); + expect(onChange).toHaveBeenCalledWith('off'); + }); + }); + test('renders the provided icon', () => { renderWithProviders( <ToggleChipFilter {...defaultProps} value="off" onChange={jest.fn()} /> diff --git a/client/src/components/shared/VideoList/index.ts b/client/src/components/shared/VideoList/index.ts index bdbfff7b6..5049a6994 100644 --- a/client/src/components/shared/VideoList/index.ts +++ b/client/src/components/shared/VideoList/index.ts @@ -1,6 +1,7 @@ export { default as VideoListContainer } from './VideoListContainer'; export { default as VideoListToolbar } from './VideoListToolbar'; export { default as VideoListViewToggle } from './VideoListViewToggle'; +export { default as VideoListSortButton } from './VideoListSortButton'; export { default as VideoListFilterPanel } from './VideoListFilterPanel'; export { default as VideoListFilterChips, countActiveFilters, hasActiveFilters, clearAllFilters } from './VideoListFilterChips'; export { default as VideoListSelectionPill } from './VideoListSelectionPill'; @@ -26,3 +27,4 @@ export { default as VideoListPaginationBar } from './VideoListPaginationBar'; export { ALLOWED_PAGE_SIZES, DEFAULT_PAGE_SIZE, INFINITE_SCROLL_FETCH_SIZE, isPageSize } from './pageSizes'; export type { PageSize } from './pageSizes'; export { useListPageSize } from './useListPageSize'; +export { usePersistedChipFilter } from './usePersistedChipFilter'; diff --git a/client/src/components/shared/VideoList/statusFilters.ts b/client/src/components/shared/VideoList/statusFilters.ts index cbda47bed..4e805312f 100644 --- a/client/src/components/shared/VideoList/statusFilters.ts +++ b/client/src/components/shared/VideoList/statusFilters.ts @@ -4,10 +4,11 @@ import { Block as BlockIcon, Download as DownloadIcon, Eye as EyeIcon, + Trash2 as Trash2Icon, } from '../../../lib/icons'; import type { FilterConfig } from './types'; -export type StatusChipId = 'protected' | 'missing' | 'ignored' | 'downloaded' | 'watched'; +export type StatusChipId = 'protected' | 'missing' | 'deleted' | 'ignored' | 'downloaded' | 'watched'; export type StatusFilterConfig = Extract<FilterConfig, { id: StatusChipId }>; // Shape kept deliberately thin: the Icon component (not an element) so each @@ -22,6 +23,7 @@ export interface StatusChipDescriptor { export const STATUS_CHIP_DESCRIPTORS: Record<StatusChipId, StatusChipDescriptor> = { protected: { Icon: ShieldIcon, noun: 'Protected' }, missing: { Icon: CloudOffIcon, noun: 'Missing' }, + deleted: { Icon: Trash2Icon, noun: 'Deleted' }, ignored: { Icon: BlockIcon, noun: 'Ignored' }, downloaded: { Icon: DownloadIcon, noun: 'Downloaded' }, watched: { Icon: EyeIcon, noun: 'Watched' }, diff --git a/client/src/components/shared/VideoList/types.ts b/client/src/components/shared/VideoList/types.ts index faf6e0229..28703381b 100644 --- a/client/src/components/shared/VideoList/types.ts +++ b/client/src/components/shared/VideoList/types.ts @@ -29,30 +29,43 @@ export type FilterConfig = value: string; onChange: (value: string) => void; } + // Status chips rest at `defaultValue` (off unless given): a chip at its + // resting value is not "active", and clearing filters returns it there. | { id: 'protected'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'missing'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; + } + | { + id: 'deleted'; + value: ChipFilterMode; + onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'ignored'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'downloaded'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'watched'; value: ChipFilterMode; onChange: (value: ChipFilterMode) => void; + defaultValue?: ChipFilterMode; } | { id: 'duration'; @@ -74,6 +87,9 @@ export type FilterConfig = export interface SortOption { key: string; label: string; + // An order with no direction (e.g. the source's own ranking): the button + // shows no arrow for it and re-choosing it changes nothing. + undirected?: boolean; } export interface SortConfig { diff --git a/client/src/components/shared/VideoList/usePersistedChipFilter.ts b/client/src/components/shared/VideoList/usePersistedChipFilter.ts new file mode 100644 index 000000000..880035705 --- /dev/null +++ b/client/src/components/shared/VideoList/usePersistedChipFilter.ts @@ -0,0 +1,36 @@ +import { useState, useCallback } from 'react'; +import type { ChipFilterMode } from './types'; + +const MODES: ChipFilterMode[] = ['off', 'only', 'exclude']; + +function readStoredMode(storageKey: string, defaultValue: ChipFilterMode): ChipFilterMode { + try { + const raw = localStorage.getItem(storageKey); + return raw !== null && (MODES as string[]).includes(raw) ? (raw as ChipFilterMode) : defaultValue; + } catch { + return defaultValue; + } +} + +// A tri-state chip filter remembered in this browser, for a filter whose +// resting value is a choice the user made (e.g. "show deleted videos"). +export function usePersistedChipFilter( + storageKey: string, + defaultValue: ChipFilterMode +): [ChipFilterMode, (next: ChipFilterMode) => void] { + const [mode, setModeState] = useState<ChipFilterMode>(() => readStoredMode(storageKey, defaultValue)); + + const setMode = useCallback( + (next: ChipFilterMode) => { + try { + localStorage.setItem(storageKey, next); + } catch { + // localStorage may be unavailable (private mode, quota); keep in-memory value + } + setModeState(next); + }, + [storageKey] + ); + + return [mode, setMode]; +} diff --git a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx index 771b88979..75391a0e0 100644 --- a/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx +++ b/client/src/components/shared/VideoModal/__tests__/VideoModal.test.tsx @@ -470,6 +470,23 @@ describe('VideoModal', () => { expect(screen.queryByRole('button', { name: /delete video/i })).not.toBeInTheDocument(); }); + test('hides delete and protect for a deleted video and offers a download instead', () => { + renderModal({ + video: { ...baseVideo, status: 'deleted' }, + }); + expect(screen.queryByRole('button', { name: /delete video/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /protect/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /download video/i })).toBeInTheDocument(); + }); + + test('passes missingVideoCount 1 to the download dialog for a deleted video', () => { + renderModal({ + video: { ...baseVideo, status: 'deleted' }, + }); + fireEvent.click(screen.getByRole('button', { name: /download video/i })); + expect(screen.getByTestId('download-dialog-missing-count')).toHaveTextContent('1'); + }); + test('renders ignore button for non-downloaded videos', () => { renderModal({ video: neverDownloadedVideo }); expect(screen.getByRole('button', { name: /ignore/i })).toBeInTheDocument(); @@ -668,6 +685,87 @@ describe('VideoModal', () => { }); }); + describe('handleMarkDeleted', () => { + const missingVideo: VideoModalData = { + ...baseVideo, + status: 'missing', + isDownloaded: false, + removal: { reason: 'not_found', at: '2026-10-18T21:41:04.000Z', detail: null }, + }; + + test('marks a missing video as deleted and tells the page', async () => { + const onMarkedDeleted = jest.fn(); + axios.post.mockResolvedValueOnce({ data: { updated: 1 } }); + + renderModal({ video: missingVideo, onMarkedDeleted }); + await userEvent.click(screen.getByRole('button', { name: 'Mark as deleted' })); + + await waitFor(() => { + expect(axios.post).toHaveBeenCalledWith( + '/api/videos/mark-deleted', + { videoIds: [42] }, + { headers: { 'x-access-token': 'test-token' } } + ); + }); + expect(await screen.findByText('Marked as deleted')).toBeInTheDocument(); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Mark as deleted' })).not.toBeInTheDocument(); + expect(onMarkedDeleted).toHaveBeenCalledWith('test123'); + }); + + test('says nothing changed when the video was no longer missing', async () => { + const onMarkedDeleted = jest.fn(); + axios.post.mockResolvedValueOnce({ data: { updated: 0 } }); + + renderModal({ video: missingVideo, onMarkedDeleted }); + await userEvent.click(screen.getByRole('button', { name: 'Mark as deleted' })); + + expect(await screen.findByText('Not marked: this video is no longer missing.')).toBeInTheDocument(); + expect(screen.queryByText('Marked as deleted')).not.toBeInTheDocument(); + expect(onMarkedDeleted).not.toHaveBeenCalled(); + }); + + test('is not offered for a video that is already deleted', () => { + renderModal({ video: { ...missingVideo, status: 'deleted', removal: { reason: 'user_deleted', at: null, detail: null } } }); + expect(screen.queryByRole('button', { name: 'Mark as deleted' })).not.toBeInTheDocument(); + }); + + describe('on a phone', () => { + const originalMatchMedia = window.matchMedia; + beforeEach(() => { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: query === '(max-width: 599px)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + }); + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + test('keeps its label while the other actions show only icons', () => { + renderModal({ video: missingVideo }); + expect(screen.getByRole('button', { name: 'Download video' })).not.toHaveTextContent('Download'); + expect(screen.getByRole('button', { name: 'Mark as deleted' })).toHaveTextContent('Mark as deleted'); + }); + }); + + test('shows the server message when marking fails', async () => { + axios.post.mockRejectedValueOnce({ response: { data: { error: 'Failed to mark videos as deleted' } } }); + + renderModal({ video: missingVideo }); + await userEvent.click(screen.getByRole('button', { name: 'Mark as deleted' })); + + expect(await screen.findByText('Failed to mark videos as deleted')).toBeInTheDocument(); + expect(screen.getByText('Missing')).toBeInTheDocument(); + }); + }); + describe('handleRatingApply', () => { test('calls rating API and fires onRatingChanged on success', async () => { const onRatingChanged = jest.fn(); diff --git a/client/src/components/shared/VideoModal/components/VideoActions.tsx b/client/src/components/shared/VideoModal/components/VideoActions.tsx index 01dcaa055..6473828e1 100644 --- a/client/src/components/shared/VideoModal/components/VideoActions.tsx +++ b/client/src/components/shared/VideoModal/components/VideoActions.tsx @@ -9,7 +9,7 @@ import { Shield as ShieldIcon, ShieldCheck as ShieldOutlinedIcon, } from '../../../../lib/icons'; -import { Youtube as YoutubeIcon } from 'lucide-react'; +import { FileX as MarkDeletedIcon, Youtube as YoutubeIcon } from 'lucide-react'; import { VideoModalData } from '../types'; import { getMediaTypeInfo, @@ -18,6 +18,7 @@ import { getStatusColor, getStatusIcon, getStatusLabel, + isRemovedStatus, } from '../../../../utils/videoStatus'; import { SHARED_STATUS_CHIP_STYLE, SHARED_THEMED_CHIP_STYLE } from '../../../shared/chipStyles'; import { YOUTUBE_URL_BASE } from '../constants'; @@ -29,7 +30,9 @@ interface VideoActionsProps { onIgnoreToggle: () => void; onDownloadClick: () => void; onRatingClick: () => void; + onMarkDeleted: () => void; protectionLoading: boolean; + markDeletedPending: boolean; isMobile: boolean; allowIgnore?: boolean; } @@ -41,15 +44,18 @@ function VideoActions({ onIgnoreToggle, onDownloadClick, onRatingClick, + onMarkDeleted, protectionLoading, + markDeletedPending, isMobile, allowIgnore = true, }: VideoActionsProps) { - const isDownloadedAndPresent = video.isDownloaded && video.status !== 'missing'; + const isDownloadedAndPresent = video.isDownloaded && !isRemovedStatus(video.status); const showProtect = isDownloadedAndPresent; const showDownload = !isDownloadedAndPresent && video.status !== 'queued' && video.status !== 'downloading'; const showIgnore = (!isDownloadedAndPresent || video.isIgnored) && allowIgnore; const showDelete = isDownloadedAndPresent; + const showMarkDeleted = video.status === 'missing' && video.databaseId !== null; const mediaTypeInfo = getMediaTypeInfo(video.mediaType); const statusLabel = video.status === 'downloaded' ? 'Available' : getStatusLabel(video.status); const youtubeUrl = `${YOUTUBE_URL_BASE}${video.youtubeId}`; @@ -83,7 +89,8 @@ function VideoActions({ <Box style={{ display: 'flex', - flexWrap: 'nowrap', + // On a phone, a labeled button that does not fit moves to a second line instead of being cut off. + flexWrap: isMobile ? 'wrap' : 'nowrap', alignItems: 'center', gap: 'var(--video-modal-action-row-gap, 8px)', paddingTop: 'var(--video-modal-action-row-padding-y, 2px)', @@ -222,6 +229,24 @@ function VideoActions({ <Box style={{ flex: 1, minWidth: isMobile ? 0 : 12 }} /> + {showMarkDeleted && ( + // Labeled even on a phone: it acts on the first tap, and its icon alone does not say what it does. + <Tooltip title="The file is really gone: hide this video from the Videos page by default"> + <Button + size="small" + variant="outlined" + color="warning" + onClick={onMarkDeleted} + disabled={markDeletedPending} + aria-label="Mark as deleted" + startIcon={<MarkDeletedIcon size={16} />} + style={labeledActionButtonStyle} + > + Mark as deleted + </Button> + </Tooltip> + )} + {showDelete && ( <Tooltip title="Delete video"> <Button diff --git a/client/src/components/shared/VideoModal/components/VideoMetadata.tsx b/client/src/components/shared/VideoModal/components/VideoMetadata.tsx index 6784d4aad..74cae49e1 100644 --- a/client/src/components/shared/VideoModal/components/VideoMetadata.tsx +++ b/client/src/components/shared/VideoModal/components/VideoMetadata.tsx @@ -11,7 +11,7 @@ import { FileDownload as DownloadedIcon, Plus as AddIcon, } from '../../../../lib/icons'; -import { formatDate, formatDateTime } from '../../../../utils/formatters'; +import { formatCompactCount as formatCount, formatDate, formatDateTime } from '../../../../utils/formatters'; import { VideoModalData, VideoExtendedMetadata } from '../types'; interface VideoMetadataProps { @@ -24,16 +24,6 @@ interface VideoMetadataProps { const DESCRIPTION_PREVIEW_LENGTH = 300; const VISIBLE_TAGS_COUNT = 6; -function formatCount(count: number): string { - if (count >= 1_000_000) { - return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; - } - if (count >= 1_000) { - return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K`; - } - return count.toLocaleString(); -} - function formatDuration(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); diff --git a/client/src/components/shared/VideoModal/components/VideoPlayer.tsx b/client/src/components/shared/VideoModal/components/VideoPlayer.tsx index 7817a22c8..28422efb7 100644 --- a/client/src/components/shared/VideoModal/components/VideoPlayer.tsx +++ b/client/src/components/shared/VideoModal/components/VideoPlayer.tsx @@ -9,7 +9,9 @@ import { Close as CloseIcon, WarningAmber as WarningAmberIcon, Lock as LockIcon, + Trash2 as DeletedIcon, } from '../../../../lib/icons'; +import { isRemovedStatus } from '../../../../utils/videoStatus'; import { VideoModalData } from '../types'; import { YOUTUBE_URL_BASE } from '../constants'; @@ -22,6 +24,13 @@ interface VideoPlayerProps { const DEFAULT_LANDSCAPE_ASPECT_RATIO = 16 / 9; const DEFAULT_PORTRAIT_ASPECT_RATIO = 9 / 16; +// Labels over an arbitrary thumbnail: white with a dark shadow reads on light and dark images. +const OVERLAY_TEXT_SHADOW = '0 1px 3px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.6)'; +const OVERLAY_ICON_SHADOW = 'drop-shadow(0 1px 3px rgba(0,0,0,0.8))'; +// A phone's player is too short for the full-size icon, label, and button stacked. +const MOBILE_OVERLAY_ICON_SIZE = 32; +const MOBILE_DOWNLOAD_BUTTON_SIZE = '56px'; +const MOBILE_DOWNLOAD_ICON_SIZE = '32px'; function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerProps) { const [playbackStarted, setPlaybackStarted] = useState(false); @@ -36,7 +45,7 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro setStreamAspectRatio(null); }, [video.youtubeId]); - const canStream = video.isDownloaded && video.status !== 'missing'; + const canStream = video.isDownloaded && !isRemovedStatus(video.status); const playbackType: 'video' | 'audio' = !video.filePath && video.audioFilePath ? 'audio' : 'video'; const streamUrl = token @@ -174,7 +183,7 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro {streamError ? ( <> <CloudOffIcon size={48} color="white" /> - <Typography variant="body2" sx={{ color: 'common.white' }}> + <Typography variant="body2" sx={{ color: 'white' }}> {playbackType === 'audio' ? 'Unable to stream audio' : 'Unable to stream video'} </Typography> <Link @@ -205,15 +214,19 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro ) : video.status === 'ignored' ? ( <> <BlockIcon size={48} color="white" /> - <Typography variant="body1" sx={{ color: 'common.white', fontWeight: 500 }}> + <Typography variant="body1" sx={{ color: 'white', fontWeight: 500 }}> Ignored </Typography> </> - ) : video.status === 'missing' ? ( + ) : isRemovedStatus(video.status) ? ( <> - <WarningAmberIcon size={48} color="var(--warning)" /> - <Typography variant="body1" sx={{ color: 'common.white', fontWeight: 500 }}> - File Missing + {video.status === 'deleted' ? ( + <DeletedIcon size={isMobile ? MOBILE_OVERLAY_ICON_SIZE : 48} color="white" style={{ filter: OVERLAY_ICON_SHADOW }} /> + ) : ( + <WarningAmberIcon size={isMobile ? MOBILE_OVERLAY_ICON_SIZE : 48} color="var(--warning)" style={{ filter: OVERLAY_ICON_SHADOW }} /> + )} + <Typography variant="body1" sx={{ color: 'white', fontWeight: 600, textShadow: OVERLAY_TEXT_SHADOW }}> + {video.status === 'deleted' ? 'File Deleted' : 'File Missing'} </Typography> <IconButton onClick={onDownloadClick} @@ -221,12 +234,17 @@ function VideoPlayer({ video, token, onDownloadClick, isMobile }: VideoPlayerPro style={{ backgroundColor: 'var(--video-modal-overlay-action-background, var(--media-overlay-background-strong))', color: 'var(--video-modal-overlay-action-foreground, var(--media-overlay-foreground))', - width: 'var(--video-modal-overlay-download-size, 90px)', - height: 'var(--video-modal-overlay-download-size, 90px)', + width: isMobile ? MOBILE_DOWNLOAD_BUTTON_SIZE : 'var(--video-modal-overlay-download-size, 90px)', + height: isMobile ? MOBILE_DOWNLOAD_BUTTON_SIZE : 'var(--video-modal-overlay-download-size, 90px)', zIndex: 2, }} > - <DownloadIcon style={{ width: 'var(--video-modal-overlay-download-icon-size, 50px)', height: 'var(--video-modal-overlay-download-icon-size, 50px)' }} /> + <DownloadIcon + style={{ + width: isMobile ? MOBILE_DOWNLOAD_ICON_SIZE : 'var(--video-modal-overlay-download-icon-size, 50px)', + height: isMobile ? MOBILE_DOWNLOAD_ICON_SIZE : 'var(--video-modal-overlay-download-icon-size, 50px)', + }} + /> </IconButton> </> ) : video.status === 'members_only' ? ( diff --git a/client/src/components/shared/VideoModal/components/VideoTechnical.tsx b/client/src/components/shared/VideoModal/components/VideoTechnical.tsx index 759aa1c26..51e4071ea 100644 --- a/client/src/components/shared/VideoModal/components/VideoTechnical.tsx +++ b/client/src/components/shared/VideoModal/components/VideoTechnical.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { Box, Typography, Skeleton, Tooltip, Chip, Accordion, AccordionSummary, AccordionDetails } from '../../../ui'; -import { formatDate } from '../../../../utils/formatters'; +import { formatDate, stripInternalPath } from '../../../../utils/formatters'; import { tierFromDimensions } from '../../../../utils/videoResolution'; +import { isRemovedStatus } from '../../../../utils/videoStatus'; +import { describeRemoval } from '../../../../utils/videoRemoval'; import { VideoModalData, VideoExtendedMetadata } from '../types'; interface VideoTechnicalProps { @@ -14,8 +16,6 @@ const BYTES_PER_KB = 1024; const BYTES_PER_MB = 1024 * 1024; const BYTES_PER_GB = 1024 * 1024 * 1024; -const INTERNAL_PATH_PREFIX = '/usr/src/app/data/'; - function formatFileSize(bytes: number): string { if (bytes >= BYTES_PER_GB) { return `${(bytes / BYTES_PER_GB).toFixed(2)} GB`; @@ -29,12 +29,6 @@ function formatFileSize(bytes: number): string { return `${bytes} B`; } -function stripInternalPath(filePath: string): string { - if (filePath.startsWith(INTERNAL_PATH_PREFIX)) { - return filePath.slice(INTERNAL_PATH_PREFIX.length); - } - return filePath; -} function getOrientationLabel(ratio: number): string { if (ratio > 1) return 'Landscape'; @@ -63,15 +57,21 @@ function formatDownloadedResolution( interface DetailRowProps { label: string; value: string; + // A sentence rather than a short value: left-aligned so it reads on a phone. + prose?: boolean; } -function DetailRow({ label, value }: DetailRowProps) { +function DetailRow({ label, value, prose = false }: DetailRowProps) { return ( <Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12, paddingTop: 4, paddingBottom: 4 }}> - <Typography variant="body2" color="text.secondary"> + <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0 }}> {label} </Typography> - <Typography variant="body2" color="text.primary" sx={{ fontWeight: 500, textAlign: 'right', wordBreak: 'break-word' }}> + <Typography + variant="body2" + color="text.primary" + sx={{ fontWeight: 500, textAlign: prose ? 'left' : 'right', wordBreak: 'break-word' }} + > {value} </Typography> </Box> @@ -154,7 +154,12 @@ function VideoTechnical({ video, metadata, loading }: VideoTechnicalProps) { const hasAudioFile = video.audioFilePath !== null; const relatedFiles = !loading ? (metadata?.relatedFiles ?? null) : null; const addedDate = formatDate(video.addedAt); - const showFileDetails = video.isDownloaded || video.status === 'missing'; + const showFileDetails = video.isDownloaded || isRemovedStatus(video.status); + // Why there is no file, in plain words, so nobody has to find the tooltip. + // The file row below shows the path, so the sentence leaves it out. + const removalStatus = isRemovedStatus(video.status) + ? describeRemoval(video.status, video.removal ?? undefined) + : null; return ( <Box style={{ display: 'flex', flexDirection: 'column', gap: 'var(--video-modal-section-gap, 12px)' }}> @@ -209,6 +214,7 @@ function VideoTechnical({ video, metadata, loading }: VideoTechnicalProps) { <AccordionSummary>File Details</AccordionSummary> <AccordionDetails> <Box> + {removalStatus && <DetailRow label="Status" value={removalStatus} prose />} {hasVideoFile && ( <FileRow label="Video" diff --git a/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx b/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx index 30224de78..7688b6341 100644 --- a/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx +++ b/client/src/components/shared/VideoModal/components/__tests__/VideoPlayer.test.tsx @@ -20,6 +20,7 @@ jest.mock('../../../../../lib/icons', () => { Close: createIcon('CloseIcon'), WarningAmber: createIcon('WarningAmberIcon'), Lock: createIcon('LockIcon'), + Trash2: createIcon('Trash2Icon'), }; }); @@ -121,6 +122,30 @@ describe('VideoPlayer', () => { expect(screen.getByRole('button', { name: /re-download video/i })).toBeInTheDocument(); }); + test('renders "deleted file" state with a re-download button and no player', () => { + renderPlayer({ + status: 'deleted', + isDownloaded: true, + }); + + expect(screen.getByText('File Deleted')).toBeInTheDocument(); + expect(screen.queryByText('File Missing')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /re-download video/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Play video' })).not.toBeInTheDocument(); + }); + + // The label sits on an arbitrary thumbnail, so it must be white with a shadow, + // never the page's text color. + test('labels a removed file in white so it reads on any thumbnail', () => { + renderPlayer({ status: 'deleted', isDownloaded: true }); + expect(screen.getByText('File Deleted')).toHaveStyle({ color: 'rgb(255, 255, 255)' }); + }); + + test('uses a smaller re-download button on phones, leaving room for the label', () => { + renderPlayer({ status: 'deleted', isDownloaded: true }, { isMobile: true }); + expect(screen.getByRole('button', { name: /re-download video/i })).toHaveStyle({ width: '56px', height: '56px' }); + }); + test('renders "members only" state with lock icon and no download button', () => { renderPlayer({ status: 'members_only', diff --git a/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx b/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx index 8ed2c48aa..b0da1a7bb 100644 --- a/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx +++ b/client/src/components/shared/VideoModal/components/__tests__/VideoTechnical.test.tsx @@ -187,6 +187,41 @@ describe('VideoTechnical', () => { expect(screen.getByText('File Details')).toBeInTheDocument(); }); + test('explains why a deleted video has no file in a Status row', () => { + renderTech({ + isDownloaded: false, + status: 'deleted', + removal: { reason: 'auto_removal', at: null, detail: 'older than 30 days' }, + }); + expect(screen.getByText('File Details')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Deleted by automatic cleanup (older than 30 days). It can be downloaded again.')).toBeInTheDocument(); + }); + + test('tells a missing video from a deleted one in the Status row, leaving the path to the file row below', () => { + renderTech({ + isDownloaded: false, + status: 'missing', + filePath: '/usr/src/app/data/Channel/video.mp4', + removal: { reason: 'not_found', at: null, detail: null }, + }); + expect(screen.getByText(/^Youtarr expected a file and could not find it\./)).toBeInTheDocument(); + }); + + test('lays the Status sentence out left-aligned, as prose', () => { + renderTech({ + isDownloaded: false, + status: 'deleted', + removal: { reason: 'user_deleted', at: null, detail: null }, + }); + expect(screen.getByText(/^Deleted in Youtarr\./)).toHaveStyle({ textAlign: 'left' }); + }); + + test('has no Status row for a present file', () => { + renderTech({ isDownloaded: true, status: 'downloaded', filePath: '/videos/a.mp4', fileSize: 10 }); + expect(screen.queryByText('Status')).not.toBeInTheDocument(); + }); + test('renders the video file row with stripped internal path and formatted size', () => { renderTech({ filePath: '/usr/src/app/data/Channel/video.mp4', diff --git a/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts b/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts index 1bc3ad925..00c392ce1 100644 --- a/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts +++ b/client/src/components/shared/VideoModal/hooks/useVideoModalActions.ts @@ -23,6 +23,7 @@ interface UseVideoModalActionsParams { video: VideoModalData; token: string | null; onVideoDeleted?: (youtubeId: string) => void; + onMarkedDeleted?: (youtubeId: string) => void; onProtectionChanged?: (youtubeId: string, isProtected: boolean) => void; onIgnoreChanged?: (youtubeId: string, isIgnored: boolean) => void; onDownloadQueued?: (youtubeId: string) => void; @@ -37,12 +38,14 @@ interface UseVideoModalActionsReturn { downloadDialogOpen: boolean; ratingDialogOpen: boolean; protectionLoading: boolean; + markDeletedPending: boolean; setDeleteDialogOpen: (open: boolean) => void; setDownloadDialogOpen: (open: boolean) => void; setRatingDialogOpen: (open: boolean) => void; handleProtectionToggle: () => Promise<void>; handleDeleteConfirm: () => Promise<void>; handleIgnoreToggle: () => Promise<void>; + handleMarkDeleted: () => Promise<void>; handleDownloadConfirm: (settings: DownloadSettings | null) => Promise<void>; handleRatingApply: (rating: string | null) => Promise<void>; handleSnackbarClose: () => void; @@ -59,6 +62,7 @@ export function useVideoModalActions({ video, token, onVideoDeleted, + onMarkedDeleted, onProtectionChanged, onIgnoreChanged, onDownloadQueued, @@ -83,11 +87,17 @@ export function useVideoModalActions({ audioFilePath: current.audioFilePath ?? null, audioFileSize: current.audioFileSize ?? null, addedAt: current.addedAt ?? null, + removal: { + reason: current.removedReason ?? null, + at: current.removedAt ?? null, + detail: current.removedDetail ?? null, + }, })); }, [statuses, video.youtubeId]); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [downloadDialogOpen, setDownloadDialogOpen] = useState(false); const [ratingDialogOpen, setRatingDialogOpen] = useState(false); + const [markDeletedPending, setMarkDeletedPending] = useState(false); const [snackbar, setSnackbar] = useState<SnackbarState>({ open: false, message: '', @@ -187,6 +197,36 @@ export function useVideoModalActions({ } }, [localVideo.isIgnored, localVideo.youtubeId, localVideo.channelId, token, showSnackbar, onIgnoreChanged]); + // A missing video whose file is really gone becomes deleted; the record stays. + const handleMarkDeleted = useCallback(async () => { + if (!localVideo.databaseId) return; + setMarkDeletedPending(true); + try { + const response = await axios.post<{ updated: number }>( + '/api/videos/mark-deleted', + { videoIds: [localVideo.databaseId] }, + { headers: { 'x-access-token': token || '' } } + ); + // Zero rows: the file came back, or the video was marked elsewhere, while + // the modal was open. Say so rather than claim a change that did not happen. + if (response.data.updated === 0) { + showSnackbar('Not marked: this video is no longer missing.', 'error'); + return; + } + setLocalVideo((prev) => ({ + ...prev, + status: 'deleted', + removal: { reason: 'marked_deleted', at: new Date().toISOString(), detail: null }, + })); + onMarkedDeleted?.(localVideo.youtubeId); + showSnackbar('Marked as deleted', 'success'); + } catch (err: unknown) { + showSnackbar(extractErrorMessage(err, 'Failed to mark the video as deleted'), 'error'); + } finally { + setMarkDeletedPending(false); + } + }, [localVideo.databaseId, localVideo.youtubeId, token, showSnackbar, onMarkedDeleted]); + const handleDownloadConfirm = useCallback(async (settings: DownloadSettings | null) => { setDownloadDialogOpen(false); if (snapshot.videos[localVideo.youtubeId]?.state) { @@ -256,12 +296,14 @@ export function useVideoModalActions({ downloadDialogOpen, ratingDialogOpen, protectionLoading: protection.loading, + markDeletedPending, setDeleteDialogOpen, setDownloadDialogOpen, setRatingDialogOpen, handleProtectionToggle, handleDeleteConfirm, handleIgnoreToggle, + handleMarkDeleted, handleDownloadConfirm, handleRatingApply, handleSnackbarClose, diff --git a/client/src/components/shared/VideoModal/index.tsx b/client/src/components/shared/VideoModal/index.tsx index 82f40e373..4ed911aff 100644 --- a/client/src/components/shared/VideoModal/index.tsx +++ b/client/src/components/shared/VideoModal/index.tsx @@ -27,6 +27,7 @@ import AddChannelDialog from '../AddChannelDialog'; import DownloadSettingsDialog from '../../DownloadManager/ManualDownload/DownloadSettingsDialog'; import { useConfig } from '../../../hooks/useConfig'; import { uploadDateToIso } from '../../../utils/formatters'; +import { isRemovedStatus } from '../../../utils/videoStatus'; const SNACKBAR_AUTO_HIDE_MS = 4000; @@ -36,6 +37,7 @@ function VideoModal({ video, token, onVideoDeleted, + onMarkedDeleted, onProtectionChanged, onIgnoreChanged, onDownloadQueued, @@ -53,12 +55,14 @@ function VideoModal({ downloadDialogOpen, ratingDialogOpen, protectionLoading, + markDeletedPending, setDeleteDialogOpen, setDownloadDialogOpen, setRatingDialogOpen, handleProtectionToggle, handleDeleteConfirm, handleIgnoreToggle, + handleMarkDeleted, handleDownloadConfirm, handleRatingApply, handleSnackbarClose, @@ -66,6 +70,7 @@ function VideoModal({ video, token, onVideoDeleted, + onMarkedDeleted, onProtectionChanged, onIgnoreChanged, onDownloadQueued, @@ -271,7 +276,9 @@ function VideoModal({ onIgnoreToggle={handleIgnoreToggle} onDownloadClick={() => setDownloadDialogOpen(true)} onRatingClick={() => setRatingDialogOpen(true)} + onMarkDeleted={() => { void handleMarkDeleted(); }} protectionLoading={protectionLoading} + markDeletedPending={markDeletedPending} isMobile={isMobile} allowIgnore={allowIgnore} /> @@ -303,7 +310,7 @@ function VideoModal({ onClose={() => setDownloadDialogOpen(false)} onConfirm={handleDownloadConfirm} videoCount={1} - missingVideoCount={displayVideo.status === 'missing' || displayVideo.inArchive ? 1 : 0} + missingVideoCount={isRemovedStatus(displayVideo.status) || displayVideo.inArchive ? 1 : 0} mode="manual" token={token} defaultResolution={defaultResolution} diff --git a/client/src/components/shared/VideoModal/types.ts b/client/src/components/shared/VideoModal/types.ts index 45c663adb..23c41d1b7 100644 --- a/client/src/components/shared/VideoModal/types.ts +++ b/client/src/components/shared/VideoModal/types.ts @@ -1,4 +1,5 @@ import { VideoStatus } from '../../../utils/videoStatus'; +import type { RemovalInfo } from '../../../utils/videoRemoval'; export interface VideoModalData { youtubeId: string; @@ -13,6 +14,8 @@ export interface VideoModalData { // Listed in the download archive without a database record. Pages that know // it pass it in; the modal's local status lookup then keeps it current. inArchive?: boolean; + // Why the file is gone, for a 'deleted' or 'missing' status (see utils/videoRemoval). + removal?: RemovalInfo | null; isDownloaded: boolean; filePath: string | null; fileSize: number | null; @@ -32,6 +35,8 @@ export interface VideoModalProps { video: VideoModalData; token: string | null; onVideoDeleted?: (youtubeId: string) => void; + // A missing video was marked as deleted from the modal. + onMarkedDeleted?: (youtubeId: string) => void; onProtectionChanged?: (youtubeId: string, isProtected: boolean) => void; onIgnoreChanged?: (youtubeId: string, isIgnored: boolean) => void; onDownloadQueued?: (youtubeId: string) => void; diff --git a/client/src/components/shared/__tests__/AvailabilityChip.test.tsx b/client/src/components/shared/__tests__/AvailabilityChip.test.tsx index af3234231..2c09be1f5 100644 --- a/client/src/components/shared/__tests__/AvailabilityChip.test.tsx +++ b/client/src/components/shared/__tests__/AvailabilityChip.test.tsx @@ -1,24 +1,127 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; import AvailabilityChip from '../AvailabilityChip'; import { renderWithProviders } from '../../../test-utils'; describe('AvailabilityChip', () => { - test('renders Available label when isAvailable is true', () => { - renderWithProviders(<AvailabilityChip isAvailable={true} />); + test('renders Available for a present file', () => { + renderWithProviders(<AvailabilityChip state="present" />); expect(screen.getByText('Available')).toBeInTheDocument(); expect(screen.queryByText('Missing')).not.toBeInTheDocument(); }); - test('renders Missing label when isAvailable is false', () => { - renderWithProviders(<AvailabilityChip isAvailable={false} />); + test('renders Missing for a file Youtarr could not find', () => { + renderWithProviders(<AvailabilityChip state="missing" />); expect(screen.getByText('Missing')).toBeInTheDocument(); expect(screen.queryByText('Available')).not.toBeInTheDocument(); }); + test('shows the same crossed-out cloud for Missing as the status chips elsewhere', () => { + renderWithProviders(<AvailabilityChip state="missing" />); + expect(screen.getByTestId('CloudOffIcon')).toBeInTheDocument(); + }); + + describe('on a touch screen', () => { + const originalMatchMedia = window.matchMedia; + const pointerIs = (coarse: boolean) => { + window.matchMedia = jest.fn().mockImplementation((query: string) => ({ + matches: coarse && query === '(pointer: coarse)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + }; + const renderInRow = (onRowClick: jest.Mock) => renderWithProviders( + <div onClick={onRowClick}> + <AvailabilityChip state="missing" tooltip="Youtarr expected a file and could not find it." /> + </div> + ); + + afterEach(() => { + window.matchMedia = originalMatchMedia; + }); + + test('keeps a tap from reaching the row, so the tap shows the reason', async () => { + pointerIs(true); + const user = userEvent.setup(); + const onRowClick = jest.fn(); + renderInRow(onRowClick); + + await user.pointer({ keys: '[TouchA]', target: screen.getByText('Missing') }); + + expect(onRowClick).not.toHaveBeenCalled(); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Youtarr expected a file and could not find it.'); + }); + + // A laptop with a touchscreen reports a fine pointer; there the tooltip + // does not open on tap, so the tap must keep working on the row. + test('lets a tap through to the row where the device reports a fine pointer', async () => { + pointerIs(false); + const user = userEvent.setup(); + const onRowClick = jest.fn(); + renderInRow(onRowClick); + + await user.pointer({ keys: '[TouchA]', target: screen.getByText('Missing') }); + + expect(onRowClick).toHaveBeenCalledTimes(1); + }); + }); + + test('lets a mouse click through to the row, which may expand on click', async () => { + const user = userEvent.setup(); + const onRowClick = jest.fn(); + renderWithProviders( + <div onClick={onRowClick}> + <AvailabilityChip state="missing" tooltip="Youtarr expected a file and could not find it." /> + </div> + ); + + await user.click(screen.getByText('Missing')); + + expect(onRowClick).toHaveBeenCalledTimes(1); + }); + + test('renders Deleted for a file Youtarr removed', () => { + renderWithProviders(<AvailabilityChip state="deleted" />); + expect(screen.getByText('Deleted')).toBeInTheDocument(); + expect(screen.getByTestId('DeletedChipIcon')).toBeInTheDocument(); + }); + + test('explains the removal in its tooltip', async () => { + const user = userEvent.setup(); + renderWithProviders( + <AvailabilityChip state="deleted" tooltip="Deleted by automatic cleanup on 10/3/2026 (older than 30 days)." /> + ); + + await user.hover(screen.getByText('Deleted')); + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Deleted by automatic cleanup on 10/3/2026 (older than 30 days).'); + }); + + test('shows a formatted explanation in its tooltip', async () => { + const user = userEvent.setup(); + renderWithProviders( + <AvailabilityChip state="missing" tooltip={<>Youtarr expected a file at: <code>/d/v.mp4</code></>} /> + ); + + await user.hover(screen.getByText('Missing')); + + expect(within(await screen.findByRole('tooltip')).getByRole('code')).toHaveTextContent('/d/v.mp4'); + }); + + test('takes a label override for summary counts', () => { + renderWithProviders(<AvailabilityChip state="missing" label="2 missing" />); + expect(screen.getByText('2 missing')).toBeInTheDocument(); + }); + test('renders with compact styling when compact prop is true', () => { - renderWithProviders(<AvailabilityChip isAvailable={true} compact />); + renderWithProviders(<AvailabilityChip state="present" compact />); expect(screen.getByText('Available')).toBeInTheDocument(); }); }); diff --git a/client/src/components/VideosPage/components/__tests__/ChannelNameDisplay.test.tsx b/client/src/components/shared/__tests__/ChannelNameDisplay.test.tsx similarity index 100% rename from client/src/components/VideosPage/components/__tests__/ChannelNameDisplay.test.tsx rename to client/src/components/shared/__tests__/ChannelNameDisplay.test.tsx diff --git a/client/src/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/components/PlaylistPage/components/InfoHint.test.tsx b/client/src/components/shared/__tests__/InfoHint.test.tsx similarity index 97% rename from client/src/components/PlaylistPage/components/InfoHint.test.tsx rename to client/src/components/shared/__tests__/InfoHint.test.tsx index 9049acdb0..03b6a0f3c 100644 --- a/client/src/components/PlaylistPage/components/InfoHint.test.tsx +++ b/client/src/components/shared/__tests__/InfoHint.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import '@testing-library/jest-dom'; -import InfoHint from './InfoHint'; +import InfoHint from '../InfoHint'; describe('InfoHint', () => { test('renders an info trigger labelled for assistive tech', () => { diff --git a/client/src/components/shared/__tests__/RemovalDescription.test.tsx b/client/src/components/shared/__tests__/RemovalDescription.test.tsx new file mode 100644 index 000000000..33010591b --- /dev/null +++ b/client/src/components/shared/__tests__/RemovalDescription.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import RemovalDescription from '../RemovalDescription'; +import { formatDate } from '../../../utils/formatters'; + +const AT = '2026-10-19T12:00:00.000Z'; + +describe('RemovalDescription', () => { + test('sets a missing file\'s path apart from the explanation', () => { + render(<RemovalDescription row={{ removed: true, removed_reason: 'not_found', filePath: '/mnt/nas/Channel/video.mp4' }} />); + + expect(screen.getByRole('code')).toHaveTextContent(/^\/mnt\/nas\/Channel\/video\.mp4$/); + }); + + test('shows the path as the user knows it, without the container prefix', () => { + render(<RemovalDescription row={{ removed: true, removed_reason: 'not_found', filePath: '/usr/src/app/data/Channel/v.mp4' }} />); + + expect(screen.getByText('Channel/v.mp4')).toBeInTheDocument(); + }); + + test('reads as one sentence around the path, with when it was checked', () => { + const { container } = render( + <RemovalDescription row={{ removed: true, removed_reason: 'not_found', removed_at: AT, filePath: '/d/v.mp4' }} /> + ); + + expect(container).toHaveTextContent( + `Youtarr expected a file at: /d/v.mp4 and could not find it (checked ${formatDate(AT)}). If the drive is mounted and the file is really gone, mark it as deleted.` + ); + }); + + test('describes a missing file with no stored path in plain text', () => { + const { container } = render(<RemovalDescription row={{ removed: true, removed_reason: 'not_found' }} />); + + expect(container).toHaveTextContent( + 'Youtarr expected a file and could not find it. If the drive is mounted and the file is really gone, mark it as deleted.' + ); + expect(screen.queryByRole('code')).not.toBeInTheDocument(); + }); + + test('explains a deleted video without showing its path', () => { + const { container } = render( + <RemovalDescription row={{ removed: true, removed_reason: 'user_deleted', removed_at: AT, filePath: '/d/v.mp4' }} /> + ); + + expect(container).toHaveTextContent(`Deleted in Youtarr on ${formatDate(AT)}. It can be downloaded again.`); + expect(screen.queryByText('/d/v.mp4')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/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/__tests__/useRescanStatus.test.ts b/client/src/hooks/__tests__/useRescanStatus.test.ts index c4b8b931c..f8446986e 100644 --- a/client/src/hooks/__tests__/useRescanStatus.test.ts +++ b/client/src/hooks/__tests__/useRescanStatus.test.ts @@ -133,8 +133,9 @@ describe('useRescanStatus', () => { }); await waitFor(() => expect(result.current.loading).toBe(false)); + let outcome: { started: boolean } | undefined; await act(async () => { - await result.current.triggerRescan(); + outcome = await result.current.triggerRescan(); }); expect(mockedAxios.post).toHaveBeenCalledWith( @@ -142,10 +143,32 @@ describe('useRescanStatus', () => { undefined, expect.objectContaining({ headers: { 'x-access-token': 'tok' } }) ); + expect(outcome).toEqual({ started: true }); expect(result.current.error).toBeNull(); expect(result.current.running).toBe(true); }); + test('triggerRescan can ask to skip the downloads folder check', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: { running: false, lastRun: null } }); + mockedAxios.post.mockResolvedValueOnce({ status: 202, data: { status: 'started' } }); + + const { result } = renderHook(() => useRescanStatus('tok'), { + wrapper: makeWrapper([]) + }); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.triggerRescan({ ignoreLibraryCheck: true }); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/maintenance/rescan-files', + { ignoreLibraryCheck: true }, + expect.objectContaining({ headers: { 'x-access-token': 'tok' } }) + ); + expect(result.current.running).toBe(true); + }); + test('triggerRescan surfaces 409 as a user-friendly error', async () => { mockedAxios.get.mockResolvedValueOnce({ data: { running: false, lastRun: null } }); const err = new Error('Conflict') as Error & { @@ -159,10 +182,12 @@ describe('useRescanStatus', () => { }); await waitFor(() => expect(result.current.loading).toBe(false)); + let outcome: { started: boolean } | undefined; await act(async () => { - await result.current.triggerRescan(); + outcome = await result.current.triggerRescan(); }); + expect(outcome).toEqual({ started: false }); expect(result.current.error).toBe('Rescan already in progress'); expect(result.current.running).toBe(true); }); @@ -176,10 +201,12 @@ describe('useRescanStatus', () => { }); await waitFor(() => expect(result.current.loading).toBe(false)); + let outcome: { started: boolean } | undefined; await act(async () => { - await result.current.triggerRescan(); + outcome = await result.current.triggerRescan(); }); + expect(outcome).toEqual({ started: false }); expect(result.current.running).toBe(false); expect(result.current.error).toBe('Network Error'); }); diff --git a/client/src/hooks/useLocalVideoStatus.ts b/client/src/hooks/useLocalVideoStatus.ts index 6ade7ed85..040dbdd7a 100644 --- a/client/src/hooks/useLocalVideoStatus.ts +++ b/client/src/hooks/useLocalVideoStatus.ts @@ -4,7 +4,7 @@ import WebSocketContext from '../contexts/WebSocketContext'; import { SearchResult } from '../components/FindVideos/types'; import { useVideoActivity } from '../providers/VideoActivityProvider'; -export type LocalVideoStatus = Pick<SearchResult, 'youtubeId' | 'status' | 'inArchive' | 'databaseId' | 'filePath' | 'fileSize' | 'audioFilePath' | 'audioFileSize' | 'addedAt' | 'isProtected' | 'normalizedRating' | 'ratingSource'>; +export type LocalVideoStatus = Pick<SearchResult, 'youtubeId' | 'status' | 'inArchive' | 'databaseId' | 'filePath' | 'fileSize' | 'audioFilePath' | 'audioFileSize' | 'addedAt' | 'isProtected' | 'normalizedRating' | 'ratingSource' | 'removedReason' | 'removedAt' | 'removedDetail'>; // Refresh only local DB metadata; never re-run a YouTube search for activity. export function useLocalVideoStatus(ids: string[], token: string | null) { diff --git a/client/src/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/client/src/hooks/useRescanStatus.ts b/client/src/hooks/useRescanStatus.ts index 21cbc089a..48b41fc47 100644 --- a/client/src/hooks/useRescanStatus.ts +++ b/client/src/hooks/useRescanStatus.ts @@ -15,6 +15,8 @@ export interface RescanLastRun { videosScanned: number; filesFoundOnDisk: number; errorMessage: string | null; + // A "Rescan anyway" run, which skipped the unmounted-drive check. + libraryCheckSkipped?: boolean; } interface RescanStatusResponse { @@ -28,12 +30,22 @@ interface RescanStatusPayload { lastRun?: RescanLastRun | null; } +export interface TriggerRescanOptions { + // Rescan anyway: skip the unmounted-drive check, for a folder emptied on purpose. + ignoreLibraryCheck?: boolean; +} + +export interface TriggerRescanOutcome { + // False when the server refused (already running) or the request failed; `error` says why. + started: boolean; +} + export interface UseRescanStatusReturn { running: boolean; lastRun: RescanLastRun | null; loading: boolean; error: string | null; - triggerRescan: () => Promise<void>; + triggerRescan: (options?: TriggerRescanOptions) => Promise<TriggerRescanOutcome>; } export function useRescanStatus(token: string | null): UseRescanStatusReturn { @@ -88,21 +100,24 @@ export function useRescanStatus(token: string | null): UseRescanStatusReturn { return () => ws.unsubscribe(callback); }, [ws]); - const triggerRescan = useCallback(async () => { + const triggerRescan = useCallback(async (options?: TriggerRescanOptions): Promise<TriggerRescanOutcome> => { setError(null); setRunning(true); const headers = token ? { 'x-access-token': token } : undefined; + const body = options?.ignoreLibraryCheck ? { ignoreLibraryCheck: true } : undefined; try { - await axios.post('/api/maintenance/rescan-files', undefined, { headers }); + await axios.post('/api/maintenance/rescan-files', body, { headers }); + return { started: true }; } catch (err: unknown) { if (axios.isAxiosError(err) && err.response?.status === 409) { const data = err.response.data as { error?: string } | undefined; setError(data?.error ?? 'Rescan already in progress'); - return; + return { started: false }; } setRunning(false); const message = err instanceof Error ? err.message : 'Failed to start rescan'; setError(message); + return { started: false }; } }, [token]); diff --git a/client/src/test-utils.tsx b/client/src/test-utils.tsx index ccca0d30a..baa533815 100644 --- a/client/src/test-utils.tsx +++ b/client/src/test-utils.tsx @@ -21,13 +21,13 @@ export function createMockWebSocketContext(): NonNullable<WebSocketValue> { export function renderWithProviders( ui: React.ReactElement, - opts?: { websocketValue?: WebSocketValue } + opts?: { websocketValue?: WebSocketValue; initialEntries?: string[] } ) { const value = opts?.websocketValue ?? createMockWebSocketContext(); function Wrapper({ children }: { children: React.ReactNode }) { return ( - <MemoryRouter> + <MemoryRouter initialEntries={opts?.initialEntries}> <ThemeEngineProvider> <WebSocketContext.Provider value={value}> <TooltipProvider> diff --git a/client/src/types/ChannelVideo.ts b/client/src/types/ChannelVideo.ts index ac3358d2c..a43e172a9 100644 --- a/client/src/types/ChannelVideo.ts +++ b/client/src/types/ChannelVideo.ts @@ -6,6 +6,8 @@ "added": false },*/ +import type { RemovalReason } from '../utils/videoRemoval'; + export interface ChannelVideo { activity?: 'queued' | 'downloading'; id?: number; @@ -19,6 +21,10 @@ export interface ChannelVideo { thumbnail: string; added: boolean; removed?: boolean; + // Why the file is gone (see utils/videoRemoval): 'not_found' = missing, anything else = deleted + removed_reason?: RemovalReason | null; + removed_at?: string | null; + removed_detail?: string | null; // Listed in the download archive without a database record, so a download // skips it unless re-downloading is allowed. Never true for ignored videos. inArchive?: boolean; diff --git a/client/src/types/VideoData.ts b/client/src/types/VideoData.ts index 3bac3f618..190fba591 100644 --- a/client/src/types/VideoData.ts +++ b/client/src/types/VideoData.ts @@ -5,6 +5,8 @@ duration: number; }*/ +import type { RemovalReason } from '../utils/videoRemoval'; + export interface VideoData { id: number; youtubeId: string; @@ -19,6 +21,10 @@ export interface VideoData { audioFilePath?: string | null; audioFileSize?: string | null; removed?: boolean; + // Why the file is gone (see utils/videoRemoval): 'not_found' = missing, anything else = deleted + removed_reason?: RemovalReason | null; + removed_at?: string | null; + removed_detail?: string | null; youtube_removed?: boolean; channel_id?: string | null; media_type?: string; @@ -35,6 +41,15 @@ export interface EnabledChannel { uploader: string; } +// Set when videos were not marked missing because the downloads folder looked +// unmounted (missing, empty, unreadable, or an empty __subfolder). +export interface LibraryWarning { + reason: string; + path: string; + message: string; + heldBack: number; +} + export interface PaginatedVideosResponse { videos: VideoData[]; total: number; @@ -42,4 +57,7 @@ export interface PaginatedVideosResponse { totalPages: number; channels: string[]; enabledChannels: EnabledChannel[]; + // How many deleted videos the deleted filter hid; null unless it excludes them. + hiddenDeletedCount?: number | null; + libraryWarning?: LibraryWarning | null; } diff --git a/client/src/types/playlist.ts b/client/src/types/playlist.ts index e28b06bf2..6e7595542 100644 --- a/client/src/types/playlist.ts +++ b/client/src/types/playlist.ts @@ -1,3 +1,5 @@ +import type { RemovalReason } from '../utils/videoRemoval'; + // Saved output order for the .m3u file and media server playlist sync. export type PlaylistSortOrderSetting = 'default' | 'reversed'; @@ -56,6 +58,10 @@ export interface PlaylistVideo { // Previously downloaded but the file is gone (deleted/missing). The backend // skips these during downloads unless allowRedownload is set. previously_downloaded: boolean; + // Why it is gone (see utils/videoRemoval): 'not_found' = missing, anything else = deleted + removed_reason?: RemovalReason | null; + removed_at?: string | null; + removed_detail?: string | null; youtube_removed: boolean; // Populated only for videos that have been downloaded; used to hydrate VideoModal video_id: number | null; diff --git a/client/src/utils/__tests__/formatters.test.ts b/client/src/utils/__tests__/formatters.test.ts index 443920a0d..a7e58cbe5 100644 --- a/client/src/utils/__tests__/formatters.test.ts +++ b/client/src/utils/__tests__/formatters.test.ts @@ -5,9 +5,60 @@ import { formatAddedDate, formatAddedDateTime, formatAddedDateParts, + formatCompactCount, + formatRelativeDate, uploadDateToIso, } from '../formatters'; +describe('formatCompactCount', () => { + test('shows small counts in full with locale grouping', () => { + expect(formatCompactCount(850)).toBe('850'); + expect(formatCompactCount(0)).toBe('0'); + }); + + test('abbreviates thousands to one decimal, dropping a trailing .0', () => { + expect(formatCompactCount(12_500)).toBe('12.5K'); + expect(formatCompactCount(12_000)).toBe('12K'); + }); + + test('abbreviates millions', () => { + expect(formatCompactCount(1_500_000)).toBe('1.5M'); + expect(formatCompactCount(554_299)).toBe('554.3K'); + }); +}); + +describe('formatRelativeDate', () => { + const now = new Date('2026-10-20T12:00:00Z'); + const daysAgo = (days: number) => new Date(now.getTime() - days * 86_400_000).toISOString(); + + test('returns null for missing or unparseable input', () => { + expect(formatRelativeDate(null, now)).toBeNull(); + expect(formatRelativeDate('garbage', now)).toBeNull(); + }); + + test('describes the last two days in words', () => { + expect(formatRelativeDate(daysAgo(0), now)).toBe('today'); + expect(formatRelativeDate(daysAgo(1), now)).toBe('yesterday'); + }); + + test('counts days under a week, weeks under a month, months under a year', () => { + expect(formatRelativeDate(daysAgo(3), now)).toBe('3 days ago'); + expect(formatRelativeDate(daysAgo(7), now)).toBe('1 week ago'); + expect(formatRelativeDate(daysAgo(20), now)).toBe('2 weeks ago'); + expect(formatRelativeDate(daysAgo(31), now)).toBe('1 month ago'); + expect(formatRelativeDate(daysAgo(200), now)).toBe('6 months ago'); + }); + + test('counts whole years beyond that', () => { + expect(formatRelativeDate(daysAgo(365), now)).toBe('1 year ago'); + expect(formatRelativeDate(daysAgo(5 * 365 + 3), now)).toBe('5 years ago'); + }); + + test('treats a date in the future (clock skew, approximate dates) as today', () => { + expect(formatRelativeDate(daysAgo(-2), now)).toBe('today'); + }); +}); + describe('formatters date helpers', () => { describe('parseDate', () => { test('returns null for null', () => { diff --git a/client/src/utils/__tests__/videoRemoval.test.ts b/client/src/utils/__tests__/videoRemoval.test.ts new file mode 100644 index 000000000..02a45dad1 --- /dev/null +++ b/client/src/utils/__tests__/videoRemoval.test.ts @@ -0,0 +1,106 @@ +import { describeMissing, describeRemoval, describeRow, getRemovalState, removalFromRow } from '../videoRemoval'; +import { formatDate } from '../formatters'; + +const AT = '2026-10-03T12:00:00.000Z'; +const ON = ` on ${formatDate(AT)}`; + +describe('getRemovalState', () => { + test('is present while the row is not removed, whatever the reason says', () => { + expect(getRemovalState(false, null)).toBe('present'); + expect(getRemovalState(undefined, undefined)).toBe('present'); + expect(getRemovalState(false, 'not_found')).toBe('present'); + }); + + test('is missing for a removed row whose file was not found', () => { + expect(getRemovalState(true, 'not_found')).toBe('missing'); + }); + + test('is deleted for a removed row with any other reason', () => { + expect(getRemovalState(true, 'user_deleted')).toBe('deleted'); + expect(getRemovalState(true, 'auto_removal')).toBe('deleted'); + expect(getRemovalState(true, 'marked_deleted')).toBe('deleted'); + }); + + test('treats a removed row without a recorded reason as deleted', () => { + expect(getRemovalState(true, null)).toBe('deleted'); + expect(getRemovalState(true, undefined)).toBe('deleted'); + }); +}); + +describe('describeRemoval', () => { + test('describes a present file', () => { + expect(describeRemoval('present', { reason: null, at: null, detail: null })).toBe('Video file exists on disk'); + }); + + test('explains a row removed before the reason was recorded', () => { + expect(describeRemoval('deleted', { reason: null, at: null, detail: null })).toBe('Removed before Youtarr recorded why. It can be downloaded again.'); + }); + + test('explains a delete made in Youtarr', () => { + expect(describeRemoval('deleted', { reason: 'user_deleted', at: AT, detail: null })).toBe( + `Deleted in Youtarr${ON}. It can be downloaded again.` + ); + }); + + test('explains automatic cleanup with the rule that removed it', () => { + expect(describeRemoval('deleted', { reason: 'auto_removal', at: AT, detail: 'older than 30 days' })).toBe( + `Deleted by automatic cleanup${ON} (older than 30 days). It can be downloaded again.` + ); + expect(describeRemoval('deleted', { reason: 'auto_removal', at: null, detail: null })).toBe( + 'Deleted by automatic cleanup. It can be downloaded again.' + ); + }); + + test('explains a missing video the user marked as deleted', () => { + expect(describeRemoval('deleted', { reason: 'marked_deleted', at: AT, detail: null })).toBe( + `Marked as deleted${ON}. Its file was missing from disk when it was marked. It can be downloaded again.` + ); + }); + + test('explains a missing file, with the path it looked at and when', () => { + expect(describeRemoval('missing', { reason: 'not_found', at: AT, detail: null }, '/data/Channel/video.mp4')).toBe( + `Youtarr expected a file at /data/Channel/video.mp4 and could not find it (checked ${formatDate(AT)}). If the drive is mounted and the file is really gone, mark it as deleted.` + ); + expect(describeRemoval('missing', { reason: 'not_found', at: null, detail: null })).toBe( + 'Youtarr expected a file and could not find it. If the drive is mounted and the file is really gone, mark it as deleted.' + ); + }); +}); + +describe('describeMissing', () => { + test('splits the missing-file sentence around the path', () => { + expect(describeMissing({ reason: 'not_found', at: AT, detail: null }, '/usr/src/app/data/Channel/v.mp4')).toEqual({ + path: 'Channel/v.mp4', + rest: `and could not find it (checked ${formatDate(AT)}). If the drive is mounted and the file is really gone, mark it as deleted.`, + }); + }); + + test('has no path when none is stored', () => { + expect(describeMissing({ reason: 'not_found', at: null, detail: null }, null).path).toBeNull(); + }); +}); + +describe('row helpers', () => { + test('removalFromRow reads the snake_case columns, defaulting to null', () => { + expect(removalFromRow({ removed_reason: 'auto_removal', removed_at: AT, removed_detail: 'older than 30 days' })).toEqual({ + reason: 'auto_removal', at: AT, detail: 'older than 30 days', + }); + expect(removalFromRow({})).toEqual({ reason: null, at: null, detail: null }); + }); + + test('shows the path as the user knows it, without the container prefix', () => { + expect(describeRow({ removed: true, removed_reason: 'not_found', filePath: '/usr/src/app/data/Channel/v.mp4' })).toContain( + 'expected a file at Channel/v.mp4 and' + ); + expect(describeRemoval('missing', { reason: 'not_found', at: null, detail: null }, '/mnt/nas/Channel/v.mp4')).toContain( + 'expected a file at /mnt/nas/Channel/v.mp4 and' + ); + }); + + test('describeRow derives the state and text from a listing row', () => { + expect(describeRow({ removed: true, removed_reason: 'not_found', removed_at: null, removed_detail: null, filePath: '/d/v.mp4' })).toBe( + 'Youtarr expected a file at /d/v.mp4 and could not find it. If the drive is mounted and the file is really gone, mark it as deleted.' + ); + expect(describeRow({ removed: false })).toBe('Video file exists on disk'); + }); +}); diff --git a/client/src/utils/__tests__/videoStatus.test.tsx b/client/src/utils/__tests__/videoStatus.test.tsx index c2dfa76b0..6886a7703 100644 --- a/client/src/utils/__tests__/videoStatus.test.tsx +++ b/client/src/utils/__tests__/videoStatus.test.tsx @@ -4,13 +4,17 @@ import { getStatusColor, getStatusIcon, getStatusLabel, + getStatusChipVariant, + getStatusChipStyle, getMediaTypeInfo, + isRemovedStatus, VideoStatus, } from '../videoStatus'; import { ChannelVideo } from '../../types/ChannelVideo'; import { CheckCircle as CheckCircleIcon, CloudOff as CloudOffIcon, + Trash2 as Trash2Icon, Lock as LockIcon, Block as BlockIcon, NewReleases as NewReleasesIcon, @@ -79,11 +83,22 @@ describe('videoStatus Utility', () => { expect(getVideoStatus(neverDownloadedVideo)).toBe('never_downloaded'); }); - test('returns "missing" when video was added but has been removed', () => { + test('returns "deleted" when Youtarr removed the video\'s file', () => { + expect(getVideoStatus({ ...baseMockVideo, added: true, removed: true, removed_reason: 'user_deleted' })).toBe('deleted'); + expect(getVideoStatus({ ...baseMockVideo, added: true, removed: true, removed_reason: 'auto_removal' })).toBe('deleted'); + expect(getVideoStatus({ ...baseMockVideo, added: true, removed: true, removed_reason: 'marked_deleted' })).toBe('deleted'); + }); + + test('returns "deleted" for a video removed before Youtarr recorded why', () => { + expect(getVideoStatus({ ...baseMockVideo, added: true, removed: true })).toBe('deleted'); + }); + + test('returns "missing" when video was added but its file was not found', () => { const missingVideo = { ...baseMockVideo, added: true, removed: true, + removed_reason: 'not_found' as const, }; expect(getVideoStatus(missingVideo)).toBe('missing'); }); @@ -215,7 +230,7 @@ describe('videoStatus Utility', () => { }); test('all icons have correct size prop', () => { - const statuses: VideoStatus[] = ['downloaded', 'missing', 'members_only', 'ignored', 'never_downloaded']; + const statuses: VideoStatus[] = ['downloaded', 'missing', 'deleted', 'members_only', 'ignored', 'never_downloaded']; statuses.forEach(status => { const icon = getStatusIcon(status); expect(icon.props.size).toBe(16); @@ -323,7 +338,7 @@ describe('videoStatus Utility', () => { }); test('missing video returns correct color, icon, and label', () => { - const missingVideo = { ...baseMockVideo, added: true, removed: true }; + const missingVideo = { ...baseMockVideo, added: true, removed: true, removed_reason: 'not_found' as const }; const status = getVideoStatus(missingVideo); expect(status).toBe('missing'); @@ -333,6 +348,24 @@ describe('videoStatus Utility', () => { expect(getStatusIcon(status).props.size).toBe(16); }); + test('deleted video is neutral, not an error: default color, trash icon, outlined chip', () => { + const status = getVideoStatus({ ...baseMockVideo, added: true, removed: true, removed_reason: 'auto_removal' }); + + expect(status).toBe('deleted'); + expect(getStatusColor(status)).toBe('default'); + expect(getStatusLabel(status)).toBe('Deleted'); + expect(getStatusIcon(status).type).toBe(Trash2Icon); + expect(getStatusChipVariant(status)).toBe('outlined'); + expect(getStatusChipStyle(status)).toEqual({ backgroundColor: 'transparent', color: 'var(--muted-foreground)' }); + }); + + test('isRemovedStatus is true for missing and deleted only', () => { + expect(isRemovedStatus('missing')).toBe(true); + expect(isRemovedStatus('deleted')).toBe(true); + expect(isRemovedStatus('downloaded')).toBe(false); + expect(isRemovedStatus('never_downloaded')).toBe(false); + }); + test('members only video returns correct color, icon, and label', () => { const membersOnlyVideo = { ...baseMockVideo, availability: 'subscriber_only' }; const status = getVideoStatus(membersOnlyVideo); diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index dba504e74..5963576fa 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -16,6 +16,14 @@ export const decodeHtml = (html: string): string => { return txt.value; }; +// Where the container keeps the downloads folder. The host path differs per +// install, so paths shown to users drop this prefix. +export const INTERNAL_PATH_PREFIX = '/usr/src/app/data/'; + +export function stripInternalPath(filePath: string): string { + return filePath.startsWith(INTERNAL_PATH_PREFIX) ? filePath.slice(INTERNAL_PATH_PREFIX.length) : filePath; +} + export function parseDate(dateStr: string | null | undefined): Date | null { if (!dateStr) { return null; @@ -131,3 +139,38 @@ export function formatAddedDate(iso: string | null | undefined): string { const time = date.toLocaleTimeString([], ADDED_TIME_OPTIONS); return `${day} ${time}`; } + +// "12.5K", "1.5M": YouTube-style abbreviation for view, like, and subscriber counts. +export function formatCompactCount(count: number): string { + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + } + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + } + return count.toLocaleString(); +} + +const DAY_MS = 86_400_000; +const DAYS_PER_WEEK = 7; +const DAYS_PER_MONTH = 30; +const DAYS_PER_YEAR = 365; + +function plural(count: number, unit: string): string { + return `${count} ${unit}${count === 1 ? '' : 's'} ago`; +} + +// "3 days ago", "2 weeks ago", "5 years ago": the precision YouTube itself +// shows, which also matches what yt-dlp's approximate search dates carry. +export function formatRelativeDate(iso: string | null | undefined, now: Date = new Date()): string | null { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + const days = Math.floor((now.getTime() - date.getTime()) / DAY_MS); + if (days <= 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < DAYS_PER_WEEK) return plural(days, 'day'); + if (days < DAYS_PER_MONTH) return plural(Math.floor(days / DAYS_PER_WEEK), 'week'); + if (days < DAYS_PER_YEAR) return plural(Math.floor(days / DAYS_PER_MONTH), 'month'); + return plural(Math.floor(days / DAYS_PER_YEAR), 'year'); +} diff --git a/client/src/utils/videoRemoval.ts b/client/src/utils/videoRemoval.ts new file mode 100644 index 000000000..19730e900 --- /dev/null +++ b/client/src/utils/videoRemoval.ts @@ -0,0 +1,98 @@ +import { formatDate, stripInternalPath } from './formatters'; + +// Why a video has no file, layered on the `removed` flag. Mirrors +// server/modules/videoRemovalState.js: a removed row whose reason is +// 'not_found' is MISSING (Youtarr expected a file and could not find it); +// any other removed row, including one with no recorded reason (removed +// before Youtarr kept track), is DELETED. +export type RemovalReason = 'user_deleted' | 'auto_removal' | 'not_found' | 'marked_deleted'; +export type RemovalState = 'present' | 'deleted' | 'missing'; +export type RemovedState = Exclude<RemovalState, 'present'>; + +export interface RemovalInfo { + reason: RemovalReason | null; + at: string | null; + detail: string | null; +} + +// The removal columns as listing rows carry them (snake_case, as stored). +export interface RemovalRow { + removed?: boolean | null; + removed_reason?: string | null; + removed_at?: string | Date | null; + removed_detail?: string | null; + filePath?: string | null; +} + +// Neutral tint for a deleted video's thumbnail; missing keeps the red tint. +export const DELETED_OVERLAY_BACKGROUND = 'rgba(0, 0, 0, 0.35)'; + +const NO_REMOVAL: RemovalInfo = { reason: null, at: null, detail: null }; + +export function removedState(reason: string | null | undefined): RemovedState { + return reason === 'not_found' ? 'missing' : 'deleted'; +} + +export function getRemovalState(removed: boolean | null | undefined, reason: string | null | undefined): RemovalState { + if (!removed) return 'present'; + return removedState(reason); +} + +export function removalFromRow(row: RemovalRow): RemovalInfo { + const at = row.removed_at instanceof Date ? row.removed_at.toISOString() : row.removed_at; + return { + reason: (row.removed_reason as RemovalReason | null | undefined) ?? null, + at: at ?? null, + detail: row.removed_detail ?? null, + }; +} + +const CAN_REDOWNLOAD = 'It can be downloaded again.'; + +// Where Settings > Maintenance cleans up deleted and missing videos. +export const REMOVED_VIDEOS_ANCHOR = 'deleted-and-missing'; +export const REMOVED_VIDEOS_SETTINGS_PATH = `/settings/maintenance#${REMOVED_VIDEOS_ANCHOR}`; + +export interface MissingDescription { + // The path as the user knows it, or null when none is stored. + path: string | null; + // Everything after the path: "and could not find it ...". + rest: string; +} + +// The missing-file sentence in pieces, so a tooltip can set the path apart. +export function describeMissing(info: RemovalInfo = NO_REMOVAL, filePath?: string | null): MissingDescription { + const date = info.at ? formatDate(info.at) : null; + const checked = date ? ` (checked ${date})` : ''; + return { + path: filePath ? stripInternalPath(filePath) : null, + rest: `and could not find it${checked}. If the drive is mounted and the file is really gone, mark it as deleted.`, + }; +} + +export function describeRemoval(state: RemovalState, info: RemovalInfo = NO_REMOVAL, filePath?: string | null): string { + if (state === 'present') return 'Video file exists on disk'; + + if (state === 'missing') { + const { path, rest } = describeMissing(info, filePath); + return `Youtarr expected a file${path ? ` at ${path}` : ''} ${rest}`; + } + + const date = info.at ? formatDate(info.at) : null; + const on = date ? ` on ${date}` : ''; + + switch (info.reason) { + case 'user_deleted': + return `Deleted in Youtarr${on}. ${CAN_REDOWNLOAD}`; + case 'auto_removal': + return `Deleted by automatic cleanup${on}${info.detail ? ` (${info.detail})` : ''}. ${CAN_REDOWNLOAD}`; + case 'marked_deleted': + return `Marked as deleted${on}. Its file was missing from disk when it was marked. ${CAN_REDOWNLOAD}`; + default: + return `Removed before Youtarr recorded why. ${CAN_REDOWNLOAD}`; + } +} + +export function describeRow(row: RemovalRow): string { + return describeRemoval(getRemovalState(row.removed, row.removed_reason), removalFromRow(row), row.filePath); +} diff --git a/client/src/utils/videoStatus.tsx b/client/src/utils/videoStatus.tsx index f88f1df16..955ed4a69 100644 --- a/client/src/utils/videoStatus.tsx +++ b/client/src/utils/videoStatus.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { CheckCircle as CheckCircleIcon, CloudOff as CloudOffIcon, Lock as LockIcon, NewReleases as NewReleasesIcon, Schedule as ScheduleIcon, VideoLibrary as VideoLibraryIcon, Block as BlockIcon } from '../lib/icons'; +import { CheckCircle as CheckCircleIcon, CloudOff as CloudOffIcon, Lock as LockIcon, NewReleases as NewReleasesIcon, Schedule as ScheduleIcon, VideoLibrary as VideoLibraryIcon, Block as BlockIcon, Trash2 as Trash2Icon } from '../lib/icons'; import { ChannelVideo } from '../types/ChannelVideo'; +import { getRemovalState } from './videoRemoval'; -export type VideoStatus = 'never_downloaded' | 'downloaded' | 'missing' | 'members_only' | 'ignored' | 'queued' | 'downloading'; +export type VideoStatus = 'never_downloaded' | 'downloaded' | 'missing' | 'deleted' | 'members_only' | 'ignored' | 'queued' | 'downloading'; export const getVideoStatus = (video: ChannelVideo): VideoStatus => { if (video.activity) return video.activity; @@ -15,12 +16,15 @@ export const getVideoStatus = (video: ChannelVideo): VideoStatus => { if (!video.added) { return 'never_downloaded'; } - if (video.removed) { - return 'missing'; - } - return 'downloaded'; + const removalState = getRemovalState(video.removed, video.removed_reason); + return removalState === 'present' ? 'downloaded' : removalState; }; +// Previously downloaded, but the file is gone: deleted by Youtarr (or marked +// as deleted), or missing from disk. +export const isRemovedStatus = (status: VideoStatus): status is 'missing' | 'deleted' => + status === 'missing' || status === 'deleted'; + export const getStatusColor = (status: VideoStatus) => { switch (status) { case 'queued': @@ -32,8 +36,8 @@ export const getStatusColor = (status: VideoStatus) => { return 'error'; case 'never_downloaded': return 'warning'; + case 'deleted': case 'members_only': - return 'default'; case 'ignored': return 'default'; default: @@ -51,6 +55,8 @@ export const getStatusIcon = (status: VideoStatus) => { return <CheckCircleIcon size={16} data-testid="CheckCircleIcon" />; case 'missing': return <CloudOffIcon size={16} data-testid="CloudOffIcon" />; + case 'deleted': + return <Trash2Icon size={16} data-testid="Trash2Icon" />; case 'members_only': return <LockIcon size={16} data-testid="LockIcon" />; case 'ignored': @@ -70,6 +76,8 @@ export const getStatusLabel = (status: VideoStatus) => { return 'Downloaded'; case 'missing': return 'Missing'; + case 'deleted': + return 'Deleted'; case 'members_only': return 'Members Only'; case 'ignored': @@ -109,6 +117,8 @@ export const getStatusChipStyle = (status: VideoStatus): React.CSSProperties => backgroundColor: 'transparent', color: 'var(--warning)', }; + // Deleted is a neutral state, not a warning: Youtarr removed the file on purpose. + case 'deleted': case 'ignored': return { backgroundColor: 'transparent', diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 5c99e5dab..a5a467dbe 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -23,7 +23,7 @@ Youtarr uses MariaDB/MySQL for storing: | Table | Model | Description | | :----------------- | :------------- | :-------------------------------- | | `channels` | `Channel` | YouTube channel information. `m3u_enabled` (boolean, default false): generate a `.m3u` playlist file in the channel folder. `m3u_sort_order` (string, default `oldest_first`): `.m3u` entry order, `oldest_first` or `newest_first`. `auto_removal_protected` (boolean, default false): exclude every video of this channel from auto-removal; only applies while the channel is subscribed (`enabled`), and enabling it clears `auto_removal_keep_recent_count`. `auto_removal_keep_recent_count` (int, nullable): auto-removal always keeps this many of the channel's most recently downloaded videos; mutually exclusive with `auto_removal_protected`, dormant while the channel is unsubscribed. `additional_tags` (text, nullable, default null): adds additional, custom tags to videos downloaded from the channel. `tab_video_counts` (text, nullable): JSON of YouTube's public video totals per tab, keyed by media type (`{"video":{"total":449,"fetchedAt":"..."}}`), used for the download percentage (refreshed daily with a YouTube API key, every three days or more in capped batches through yt-dlp without one). `tab_counts_attempted_at` (datetime, nullable): when a tab count lookup last started for the channel, successful or not; yt-dlp bulk refreshes take the oldest attempts first and skip channels attempted within three days, and opening a channel page waits an hour after an attempt. Non-unique index on `channel_id` (some installs carry duplicate rows). | -| `videos` | `Video` | Downloaded video metadata. `video_resolution` (VARCHAR(20), nullable): actual pixel dimensions of the downloaded file, e.g. `"1920x1080"`, measured by ffprobe at download time and backfilled by the filesystem rescan; the displayed tier label (e.g. "1080p") is derived client-side (`client/src/utils/videoResolution.ts`), so labeling rules can change without re-probing; NULL = not yet checked or audio-only, `"0x0"` = probe failed (never shown in the UI). Index on `channel_id`. | +| `videos` | `Video` | Downloaded video metadata. `removed` (boolean): no file on disk. `removed_reason` (VARCHAR(32), nullable) says why: `user_deleted` (deleted in the app), `auto_removal` (automatic cleanup; `removed_detail` VARCHAR(255) holds the rule, e.g. "older than 30 days"), `not_found` (a file was expected on disk and not found: the video is *missing*), or `marked_deleted` (the user marked a missing video as deleted). Every other removed row, including NULL (removed before the reason was recorded), is *deleted*. `removed_at` (DATETIME, nullable): when it was removed. Deleted videos are hidden on the Videos page by default; a file that reappears clears all three columns. Index on `(removed, removed_reason)`. `video_resolution` (VARCHAR(20), nullable): actual pixel dimensions of the downloaded file, e.g. `"1920x1080"`, measured by ffprobe at download time and backfilled by the filesystem rescan; the displayed tier label (e.g. "1080p") is derived client-side (`client/src/utils/videoResolution.ts`), so labeling rules can change without re-probing; NULL = not yet checked or audio-only, `"0x0"` = probe failed (never shown in the UI). Index on `channel_id`. | | `channelvideos` | `ChannelVideo` | Channel <-> video associations. `published_at_source` tracks `published_at` provenance: `exact` (.info.json), `approximate` (yt-dlp flat-playlist date), `estimated` (ordering-only placeholder assigned when YouTube returns a listing with no dates; never displayed), NULL (legacy, treated as approximate). Non-unique indexes on `(channel_id, youtube_id)` and `youtube_id`. | | `jobs` | `Job` | Download job queue. `aux_data` (MEDIUMTEXT, nullable): JSON snapshot of job data other than videos (failed downloads, diagnoses, skip counts, terminated channels), written on job save and merged back on startup load; NULL for jobs recorded before the column existed. | | `jobvideos` | `JobVideo` | Job <-> video associations | diff --git a/docs/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/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index bac1bc1c5..c45b6f600 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -155,6 +155,21 @@ See [Authentication - Cannot Find the Setup Token](AUTHENTICATION.md#cannot-find The rescan recognizes `.mp4`, `.webm`, `.mkv`, `.m4v`, `.avi`, and `.mp3`. Files that no longer have the `[<youtube-id>]` segment in their name (for example, if you renamed `Channel - Video [abc123XYZ01].mp4` to `My Movie.mp4`) cannot be matched and will continue to show as missing. +### Deleted vs. Missing Videos, and Emptying the Trash + +Youtarr keeps a record of every video it has downloaded, even after the file is gone, so the video can be re-downloaded and so automatic downloads do not fetch it again. A video without a file is shown in one of two ways: + +- **Deleted** (neutral grey chip): Youtarr removed the file on purpose, through a delete in the app or automatic cleanup, or you marked a missing video as deleted. Videos removed before Youtarr started recording the reason also show as Deleted ("Removed before Youtarr recorded why"). The Videos page hides deleted videos by default; the **Deleted** filter chip (set to "Hide" unless you change it, which is remembered in your browser) and the "N deleted videos hidden. Show" note bring them back. Channel and playlist pages keep listing them. +- **Missing** (red chip): Youtarr expected a file on disk and could not find it. Missing videos stay visible so you can look into them. Hover or tap the chip, or open the video, to see the path Youtarr looked at and when. + +**The downloads folder looks unmounted**: if your downloads live on a drive or network share and it is not mounted, every file looks gone. Youtarr checks that the downloads folder (and each `__subfolder` in it) exists and is not empty before marking anything missing. While that check fails, the Videos page shows a warning instead of marking videos missing, the scheduled rescan records a partial run that names the folder, automatic cleanup records a skipped run instead of stamping videos it cannot reach as deleted, and deleting a video in the app is refused with the same message. If missing videos are stored in the failing folder, **Empty trash** cannot remove them, and **Mark all missing as deleted** is disabled, or, when the folder is only empty (not gone or unreadable), asks you to confirm that you removed the files yourself. Failing `__subfolders` only affect the videos stored in them (every subfolder is checked, so two shares down at once are both covered), and an empty one that no video is stored in (for example one a channel was moved out of) is ignored. Mount the share and the warning goes away on its own; nothing was changed in the meantime. If the folder really is empty because you removed the files yourself, use **Settings -> Maintenance & Rescan -> Rescan anyway...**: it runs the rescan without the check and marks every video whose file is not found as missing; then use **Mark all missing as deleted**. Only do this when you know the files are gone. + +**Mark as deleted**: when a missing video's file really is gone, select it on the Videos page and choose **Mark as deleted** (the video's window has the same button on every page), or use **Settings -> Maintenance & Rescan -> Mark all missing as deleted**. The record is kept (the video can still be re-downloaded, and a file that turns up again is marked downloaded by the rescan); only how the Videos page treats it changes. + +**Empty trash** (`Settings -> Maintenance & Rescan -> Empty trash...`) forgets deleted and/or missing videos entirely. You choose which videos (Deleted, Missing, or both) and what happens to the download archive (`complete.list`): keeping the entries (recommended) means automatic channel and playlist downloads keep skipping those videos; removing them means automatic downloads treat the videos as new and may download them again at the next run. Emptying the trash removes the videos from the Videos page and Download History, makes their channel and playlist pages show them as not downloaded, and removes the watch history Youtarr synced for them from your media servers. No files in your downloads folder are touched, and if a missing video's file turns up later Youtarr will not know about it. This cannot be undone. + +If you choose to remove download archive entries, wait for active downloads to finish before emptying the trash. Downloads queued during that purge wait for it to finish. If a purge fails partway, earlier batches remain purged; a batch whose archive cleanup failed stays in the trash so you can fix the error and retry. + ### I Converted Videos to a Different Format and Youtarr Lost Them **Problem**: You used ffmpeg or another tool to convert downloaded `.mp4` videos to `.mkv` (or another container), and Youtarr now lists those videos as missing. diff --git a/docs/USAGE_GUIDE.md b/docs/USAGE_GUIDE.md index 60b1562a6..3e3be59f3 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. @@ -374,9 +374,11 @@ Videos can become "missing" if they're manually deleted from disk. This feature > **Note**: If the file still exists somewhere (you moved it, renamed its folder, or converted it to a different format), use [Rescan Files on Disk](#rescan-files-on-disk) instead. Rescan reconciles Youtarr's database with what's already on disk without re-downloading. +Youtarr tells two kinds of gone-from-disk videos apart. **Deleted** (grey) means Youtarr removed the file itself, through a delete in the app or automatic cleanup, or you marked it as deleted; the Videos page hides these by default behind the **Deleted** filter chip, and remembers it when you turn them back on. **Missing** (red) means Youtarr expected a file and could not find it; these stay visible so you can check on them. Hover over or tap either chip, or open the video, for the reason in plain words. When a missing video really is gone, select it and choose **Mark as deleted** (or use the same button in the video's window, from any page), or mark them all from **Settings -> Maintenance & Rescan**, where **Empty trash...** can also forget deleted and missing videos entirely (see [Troubleshooting](TROUBLESHOOTING.md#deleted-vs-missing-videos-and-emptying-the-trash) for what that removes and how it treats the download archive). Both kinds can be re-downloaded with the steps below. + 1. **Identify missing videos** - Go to "Downloaded Videos" or a specific channel's video page - - Look for videos marked with a cloud-off icon (indicates missing from disk) + - Look for videos marked with a cloud-off icon (missing from disk) or a trash icon (deleted); on the Videos page, turn the **Deleted** filter chip off to see deleted videos - The video metadata is still in Youtarr's database, but the file is gone 2. **Select videos to re-download** @@ -409,6 +411,8 @@ You can also start a rescan with **Run now** on the Rescan files on disk card in A scan also runs daily on a schedule and once at server startup, so changes you make outside Youtarr will eventually be picked up even if you don't trigger a manual rescan. +If the downloads folder is missing or empty when the scan runs, as it is while a drive or network share is unmounted, the scan does not mark anything missing: it records a run that names the folder and asks whether the drive is mounted, and the Videos page shows the same warning. If only some of its `__subfolders` are empty or unreadable, just the videos stored in those subfolders are left alone; an empty subfolder no video is stored in (for example one a channel was moved out of) does not count. Mount the share and run it again. If the folder really is empty because you removed the files yourself, **Rescan anyway...** on the Deleted and missing videos card runs the scan without that check; it marks every video whose file is gone as missing, and **Mark all missing as deleted** then asks you to confirm that you emptied the folder yourself before marking them. + **Supported file extensions**: `.mp4`, `.webm`, `.mkv`, `.m4v`, `.avi` for video, plus `.mp3` for audio-only downloads. Youtarr only writes `.mp4` (or `.mp3` for audio-only), but the rescan recognizes any of these so transcoding outside Youtarr won't orphan your library. Files must keep the `[<youtube-id>]` segment in their filename (the 11-character ID in brackets that yt-dlp writes by default) for Youtarr to match them back to the database. **When to use this vs. Re-download Missing Videos**: @@ -520,16 +524,23 @@ Search YouTube from inside Youtarr and see which results you already have, which 1. **Open Find Videos on YouTube** - In the sidebar, expand **Videos** and click **Find Videos on YouTube** - Enter a search query (up to 200 characters) - - Pick a result count (10, 25, 50, or 100), optionally choose a minimum duration, and click Search + - Pick a result count (10, 25, 50, or 100), optionally choose a minimum duration (**1+ min** hides Shorts), and click Search + - The query is kept in the page address, so the browser's Back button returns you to the same results without searching again 2. **Results** - - Sorted newest-to-oldest by YouTube's approximate publish date (accurate to within a day or two, same fidelity as the channel videos page) + - Listed in YouTube's own relevance order by default. Sort by published date, views, duration, title, or channel from the table's column headers on desktop (click a header again to reverse it, a third time to return to relevance) or from the **Sort** menu on phones and in grid view + - Desktop opens results in a table; the toggle beside the Search button switches to a grid of cards. The choice, the sort, and the minimum duration are remembered + - Each result shows the channel, duration, how long ago it was published (the exact date on hover), and the view count; videos of a minute or less carry a **Short** badge + - Live and upcoming streams are left out, since a download would record the stream - If a minimum duration is selected, shorter results are hidden in the browser and the page shows how many results were filtered out - - Each card shows the channel, duration, upload date, and a status chip: + - A status chip appears only when there is something to know: - **Downloaded**: already in your library - - **Missing**: previously downloaded but removed from disk - - **Not Downloaded**: new result, not yet saved - - Click a result to open the video detail modal, where you can download, play (if downloaded), or ignore it + - **Deleted**: previously downloaded and removed by Youtarr + - **Missing**: previously downloaded but gone from disk + - **Queued** / **Downloading**: on its way + - Channel names link to the channel page for channels you follow (marked **Subscribed**); other channel names offer to add the channel + - The download icon on a row queues that one video; tick several and use the selection bar to download them together + - Click a result to open the video detail modal, where you can download, play (if downloaded), or open it on YouTube 3. **Notes** - Rate-limited to 10 searches per minute per session; server-side timeout is 60 seconds diff --git a/migrations/20261018055008-add-video-removed-reason.js b/migrations/20261018055008-add-video-removed-reason.js new file mode 100644 index 000000000..99cb6ad10 --- /dev/null +++ b/migrations/20261018055008-add-video-removed-reason.js @@ -0,0 +1,42 @@ +'use strict'; + +const { + addColumnIfMissing, + removeColumnIfExists, + addIndexIfMissing, + removeIndexIfExists +} = require('./helpers'); + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up (queryInterface, Sequelize) { + // Why a removed row has no file: 'user_deleted' (deleted in the app), + // 'auto_removal' (automatic cleanup, with the rule in removed_detail), + // 'not_found' (expected on disk, not there), or 'marked_deleted' (the + // user marked a missing video as deleted). NULL on a removed row means + // it was removed before the reason was recorded; it is shown as deleted. + await addColumnIfMissing(queryInterface, 'videos', 'removed_reason', { + type: Sequelize.STRING(32), + allowNull: true, + defaultValue: null + }); + await addColumnIfMissing(queryInterface, 'videos', 'removed_at', { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null + }); + await addColumnIfMissing(queryInterface, 'videos', 'removed_detail', { + type: Sequelize.STRING(255), + allowNull: true, + defaultValue: null + }); + await addIndexIfMissing(queryInterface, 'videos', ['removed', 'removed_reason']); + }, + + async down (queryInterface) { + await removeIndexIfExists(queryInterface, 'videos', ['removed', 'removed_reason']); + await removeColumnIfExists(queryInterface, 'videos', 'removed_detail'); + await removeColumnIfExists(queryInterface, 'videos', 'removed_at'); + await removeColumnIfExists(queryInterface, 'videos', 'removed_reason'); + } +}; diff --git a/package-lock.json b/package-lock.json index a35e93ecb..4bcf98628 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4728,9 +4728,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", diff --git a/server/__tests__/server.core.test.js b/server/__tests__/server.core.test.js index 07ff3be72..0f2307d3e 100644 --- a/server/__tests__/server.core.test.js +++ b/server/__tests__/server.core.test.js @@ -631,6 +631,7 @@ describe('server initialization', () => { channelFilter: 'channel123', protectedFilter: 'off', missingFilter: 'off', + deletedFilter: 'off', watchedFilter: 'off', maxRating: null, }); @@ -729,6 +730,7 @@ describe('server initialization', () => { channelFilter: '', protectedFilter: 'off', missingFilter: 'off', + deletedFilter: 'off', watchedFilter: 'off', maxRating: null, }); diff --git a/server/__tests__/server.routes.test.js b/server/__tests__/server.routes.test.js index 5b29c411f..05df2a972 100644 --- a/server/__tests__/server.routes.test.js +++ b/server/__tests__/server.routes.test.js @@ -1096,7 +1096,8 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'off', // default watchedFilter - null // default maxRating + null, // default maxRating + 'off' // default deletedFilter ); expect(res.statusCode).toBe(200); expect(res.body).toEqual({ @@ -1142,7 +1143,8 @@ describe('server routes - channels', () => { searchQuery: 'test search', sortBy: 'title', sortOrder: 'asc', - watchedFilter: 'only' + watchedFilter: 'only', + deletedFilter: 'only' } }); const res = createMockResponse(); @@ -1166,7 +1168,8 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'only', // watchedFilter - null // default maxRating + null, // default maxRating + 'only' // deletedFilter ); expect(res.statusCode).toBe(200); }); @@ -1210,7 +1213,8 @@ describe('server routes - channels', () => { 'off', // default missingFilter 'off', // default ignoredFilter 'off', // default watchedFilter - null // default maxRating + null, // default maxRating + 'off' // default deletedFilter ); expect(res.statusCode).toBe(200); }); @@ -1230,7 +1234,8 @@ describe('server routes - channels', () => { await getVideosHandler(req, res); const args = channelModuleMock.getChannelVideos.mock.calls[0]; - expect(args[args.length - 1]).toBe('TV-14'); + // maxRating is the 17th argument; deletedFilter follows it. + expect(args[16]).toBe('TV-14'); }); test('rejects an unknown maxRating with 400', async () => { @@ -1607,6 +1612,7 @@ describe('server routes - videos', () => { channelFilter: '', protectedFilter: 'off', missingFilter: 'off', + deletedFilter: 'off', watchedFilter: 'off', maxRating: null, }); @@ -1659,6 +1665,7 @@ describe('server routes - videos', () => { channelFilter: 'channel123', protectedFilter: 'off', missingFilter: 'off', + deletedFilter: 'off', watchedFilter: 'exclude', maxRating: null, }); diff --git a/server/models/video.js b/server/models/video.js index 1eb7dcfcf..e68143bfc 100644 --- a/server/models/video.js +++ b/server/models/video.js @@ -63,6 +63,23 @@ Video.init( allowNull: false, defaultValue: false, }, + // See modules/videoRemovalState.js: why a removed row has no file. + // NULL on a removed row = removed before the reason was recorded. + removed_reason: { + type: DataTypes.STRING(32), + allowNull: true, + defaultValue: null, + }, + removed_at: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + removed_detail: { + type: DataTypes.STRING(255), + allowNull: true, + defaultValue: null, + }, media_type: { type: DataTypes.STRING, allowNull: false, diff --git a/server/modules/__tests__/archiveModule.test.js b/server/modules/__tests__/archiveModule.test.js index 9490e373d..2a701b5f3 100644 --- a/server/modules/__tests__/archiveModule.test.js +++ b/server/modules/__tests__/archiveModule.test.js @@ -779,4 +779,61 @@ youtube video2 ); }); }); -}); \ No newline at end of file + + describe('removeVideosFromArchive', () => { + test('drops every listed id in one rewrite and reports how many lines went', async () => { + fs.readFileSync.mockReturnValue('youtube video1\nyoutube video2\nyoutube video3\nyoutube video4'); + + const removed = await ArchiveModule.removeVideosFromArchive(['video2', 'video4', 'notthere']); + + expect(removed).toBe(2); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + expect(fs.writeFileSync).toHaveBeenCalledWith( + `${mockArchivePath}.purge.tmp`, + 'youtube video1\nyoutube video3\n', + 'utf-8' + ); + expect(fs.renameSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`, mockArchivePath); + }); + + test('leaves the file alone when given no ids', async () => { + fs.readFileSync.mockReturnValue('youtube video1'); + + expect(await ArchiveModule.removeVideosFromArchive([])).toBe(0); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + test('leaves the file alone when none of the ids are listed', async () => { + fs.readFileSync.mockReturnValue('youtube video1'); + + expect(await ArchiveModule.removeVideosFromArchive(['video9'])).toBe(0); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + test('writes an empty file when every line goes', async () => { + fs.readFileSync.mockReturnValue('youtube video1\nyoutube video2'); + + expect(await ArchiveModule.removeVideosFromArchive(['video1', 'video2'])).toBe(2); + expect(fs.writeFileSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`, '', 'utf-8'); + }); + + test('keeps the original archive when writing its replacement fails', async () => { + fs.readFileSync.mockReturnValue('youtube video1\nyoutube video2'); + fs.writeFileSync.mockImplementation(() => { throw new Error('ENOSPC'); }); + + await expect(ArchiveModule.removeVideosFromArchive(['video1'])).rejects.toThrow('ENOSPC'); + + expect(fs.renameSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`, 'youtube video2\n', 'utf-8'); + expect(fs.unlinkSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`); + }); + + test('propagates replacement failures so the database transaction can roll back', async () => { + fs.readFileSync.mockReturnValue('youtube video1\nyoutube video2'); + fs.renameSync.mockImplementation(() => { throw new Error('EACCES'); }); + + await expect(ArchiveModule.removeVideosFromArchive(['video1'])).rejects.toThrow('EACCES'); + expect(fs.unlinkSync).toHaveBeenCalledWith(`${mockArchivePath}.purge.tmp`); + }); + }); +}); diff --git a/server/modules/__tests__/archiveOnlyIds.test.js b/server/modules/__tests__/archiveOnlyIds.test.js new file mode 100644 index 000000000..f03a4a696 --- /dev/null +++ b/server/modules/__tests__/archiveOnlyIds.test.js @@ -0,0 +1,38 @@ +/* eslint-env jest */ +jest.mock('../../models/channelvideo', () => ({ findAll: jest.fn() })); +jest.mock('../archiveModule', () => ({ filterArchivedVideoIds: jest.fn() })); +const ChannelVideo = require('../../models/channelvideo'); +const archiveModule = require('../archiveModule'); +const { findArchiveOnlyIds } = require('../archiveOnlyIds'); + +beforeEach(() => { + jest.clearAllMocks(); + ChannelVideo.findAll.mockResolvedValue([]); +}); + +describe('findArchiveOnlyIds', () => { + test('returns the ids the download archive lists', async () => { + archiveModule.filterArchivedVideoIds.mockReturnValue(new Set(['aaaaaaaaaaa'])); + + const result = await findArchiveOnlyIds(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + + expect([...result]).toEqual(['aaaaaaaaaaa']); + }); + + test('leaves out ignored videos, whose archive entry an explicit download removes', async () => { + archiveModule.filterArchivedVideoIds.mockReturnValue(new Set(['aaaaaaaaaaa', 'bbbbbbbbbbb'])); + ChannelVideo.findAll.mockResolvedValue([{ youtube_id: 'bbbbbbbbbbb' }]); + + const result = await findArchiveOnlyIds(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + + expect([...result]).toEqual(['aaaaaaaaaaa']); + }); + + test('does not look up ignored videos when nothing is archived', async () => { + archiveModule.filterArchivedVideoIds.mockReturnValue(new Set()); + + await findArchiveOnlyIds(['aaaaaaaaaaa']); + + expect(ChannelVideo.findAll).not.toHaveBeenCalled(); + }); +}); diff --git a/server/modules/__tests__/channelDownloadAllModule.test.js b/server/modules/__tests__/channelDownloadAllModule.test.js index 19ef4a140..1187c659a 100644 --- a/server/modules/__tests__/channelDownloadAllModule.test.js +++ b/server/modules/__tests__/channelDownloadAllModule.test.js @@ -4,6 +4,7 @@ jest.mock('../../models', () => ({ Channel: { findOne: jest.fn() }, })); jest.mock('../downloadModule', () => ({ doSpecificDownloads: jest.fn() })); +jest.mock('../archiveOnlyIds', () => ({ findArchiveOnlyIds: jest.fn() })); jest.mock('../../logger', () => ({ info: jest.fn(), debug: jest.fn(), @@ -14,6 +15,7 @@ jest.mock('../../logger', () => ({ const ChannelVideo = require('../../models/channelvideo'); const { Video, Channel } = require('../../models'); const downloadModule = require('../downloadModule'); +const { findArchiveOnlyIds } = require('../archiveOnlyIds'); const channelDownloadAllModule = require('../channelDownloadAllModule'); const CHANNEL_ID = 'UC123'; @@ -33,6 +35,7 @@ beforeEach(() => { jest.clearAllMocks(); ChannelVideo.findAll.mockResolvedValue([]); Video.findAll.mockResolvedValue([]); + findArchiveOnlyIds.mockResolvedValue(new Set()); Channel.findOne.mockResolvedValue(channelRow); downloadModule.doSpecificDownloads.mockImplementation(async ({ body }) => ({ queued: body.urls.length })); }); @@ -94,6 +97,25 @@ describe('getDownloadableVideos', () => { ); }); + it('excludes videos the download archive lists without a row, such as emptied from the trash', async () => { + ChannelVideo.findAll.mockResolvedValue([cv('a'), cv('b'), cv('c')]); + Video.findAll.mockResolvedValue([{ youtubeId: 'a', removed: true }]); + findArchiveOnlyIds.mockResolvedValue(new Set(['b'])); + + const result = await channelDownloadAllModule.getDownloadableVideos(CHANNEL_ID, 'videos'); + + expect(result.map((v) => v.youtube_id)).toEqual(['c']); + }); + + it('looks up the archive only for videos without a row', async () => { + ChannelVideo.findAll.mockResolvedValue([cv('a'), cv('b')]); + Video.findAll.mockResolvedValue([{ youtubeId: 'a', removed: true }]); + + await channelDownloadAllModule.getDownloadableVideos(CHANNEL_ID, 'videos'); + + expect(findArchiveOnlyIds).toHaveBeenCalledWith(['b']); + }); + it('does not query the videos table when no candidates exist', async () => { ChannelVideo.findAll.mockResolvedValue([]); diff --git a/server/modules/__tests__/channelModule.test.js b/server/modules/__tests__/channelModule.test.js index 801b663b1..6c68c5670 100644 --- a/server/modules/__tests__/channelModule.test.js +++ b/server/modules/__tests__/channelModule.test.js @@ -83,7 +83,7 @@ describe('channelModule facade', () => { ['getChannelsPaginated', '../channel/channelCatalog', 'getChannelsPaginated', [{ page: 2, pageSize: 10 }], 'async'], ['writeChannels', '../channel/channelCatalog', 'writeChannels', [['https://www.youtube.com/@a']], 'async'], ['updateChannelsByDelta', '../channel/channelCatalog', 'updateChannelsByDelta', [{ enableUrls: ['https://www.youtube.com/@a'], disableUrls: [] }], 'async'], - ['getChannelVideos', '../channel/channelVideosService', 'getChannelVideos', ['UC1', 2, 25, 'only', 'q', 'title', 'asc', 'shorts', 60, 600, '2026-01-01', '2026-02-01', 'only', 'exclude', 'off', 'only', 'PG'], 'async'], + ['getChannelVideos', '../channel/channelVideosService', 'getChannelVideos', ['UC1', 2, 25, 'only', 'q', 'title', 'asc', 'shorts', 60, 600, '2026-01-01', '2026-02-01', 'only', 'exclude', 'off', 'only', 'PG', 'exclude'], 'async'], ['fetchAllChannelVideos', '../channel/channelVideosService', 'fetchAllChannelVideos', ['UC1', 2, 25, 'only', 'shorts'], 'async'], ['getChannelAvailableTabs', '../channel/tabManager', 'getChannelAvailableTabs', ['UC1'], 'async'], ['updateAutoDownloadForTab', '../channel/tabManager', 'updateAutoDownloadForTab', ['UC1', 'shorts', true], 'async'], diff --git a/server/modules/__tests__/cronJobs.test.js b/server/modules/__tests__/cronJobs.test.js index 5ffd9ddf9..cd156468e 100644 --- a/server/modules/__tests__/cronJobs.test.js +++ b/server/modules/__tests__/cronJobs.test.js @@ -179,6 +179,36 @@ describe('CronJobs', () => { expect(mockLogger.info).toHaveBeenCalledWith('Running automatic video cleanup cron job'); }); + test('records a skipped run and leaves folders alone when cleanup skipped an unmounted downloads folder', async () => { + mockVideoDeletionModule.performAutomaticCleanup.mockResolvedValue({ + skipped: { reason: 'empty', message: 'The downloads folder is empty.' }, + totalDeleted: 0, + freedBytes: 0, + errors: [] + }); + const scheduledTasks = require('../scheduledTaskManager'); + + const outcome = await scheduledTasks.runNow('autoRemovalFrequency', { trigger: 'manual' }); + const record = await outcome.completion; + + expect(record).toEqual(expect.objectContaining({ status: 'skipped', outcome: 'skipped', message: 'The downloads folder is empty.' })); + expect(mockVideoDeletionModule.cleanupOrphanDirectories).not.toHaveBeenCalled(); + }); + + test('records a skipped run when only the orphan sweep ran and it skipped an unmounted downloads folder', async () => { + // Auto-removal off: performAutomaticCleanup returns before its own check. + mockVideoDeletionModule.performAutomaticCleanup.mockResolvedValue({ totalDeleted: 0, freedBytes: 0, errors: [] }); + mockVideoDeletionModule.cleanupOrphanDirectories.mockResolvedValue({ + removed: [], errors: [], skipped: { reason: 'empty', message: 'The downloads folder is empty.' } + }); + const scheduledTasks = require('../scheduledTaskManager'); + + const outcome = await scheduledTasks.runNow('autoRemovalFrequency', { trigger: 'manual' }); + const record = await outcome.completion; + + expect(record).toEqual(expect.objectContaining({ status: 'skipped', outcome: 'skipped', message: 'The downloads folder is empty.' })); + }); + test('should log success when videos are deleted', async () => { mockVideoDeletionModule.performAutomaticCleanup.mockResolvedValue({ totalDeleted: 5, @@ -430,6 +460,26 @@ describe('CronJobs', () => { expect(mockLogger.info).toHaveBeenCalledWith({ trigger: 'scheduled' }, 'Starting video metadata backfill'); }); + test('never skips the downloads folder check on a scheduled run', async () => { + mockVideosModule.backfillVideoMetadata.mockResolvedValue({ timedOut: false }); + + await backfillCallback(); + + expect(mockVideosModule.backfillVideoMetadata).toHaveBeenCalledWith({ trigger: 'scheduled', ignoreLibraryCheck: false }); + }); + + test('passes a manual request to skip the downloads folder check through to the backfill', async () => { + mockVideosModule.backfillVideoMetadata.mockResolvedValue({ timedOut: false }); + const scheduledTasks = require('../scheduledTaskManager'); + + const outcome = await scheduledTasks.runNow('videoRescanFrequency', { + trigger: 'manual', enforceCooldown: false, args: { ignoreLibraryCheck: true }, + }); + await outcome.completion; + + expect(mockVideosModule.backfillVideoMetadata).toHaveBeenCalledWith({ trigger: 'manual', ignoreLibraryCheck: true }); + }); + test('should log success when backfill completes without timeout', async () => { const resolvedPromise = Promise.resolve({ timedOut: false }); mockVideosModule.backfillVideoMetadata.mockReturnValue(resolvedPromise); @@ -901,7 +951,7 @@ describe('CronJobs', () => { const scheduledTaskManager = require('../scheduledTaskManager'); const outcome = await scheduledTaskManager.runNow('videoRescanFrequency', { trigger: 'manual' }); await outcome.completion; - expect(mockVideosModule.backfillVideoMetadata).toHaveBeenCalledWith({ trigger: 'manual' }); + expect(mockVideosModule.backfillVideoMetadata).toHaveBeenCalledWith({ trigger: 'manual', ignoreLibraryCheck: false }); }); test('the rescan reports as running while the module lock is held', () => { diff --git a/server/modules/__tests__/fileCheckModule.test.js b/server/modules/__tests__/fileCheckModule.test.js index 75991d7f9..0abeffda0 100644 --- a/server/modules/__tests__/fileCheckModule.test.js +++ b/server/modules/__tests__/fileCheckModule.test.js @@ -6,10 +6,15 @@ jest.mock('fs', () => ({ } })); +// What the check writes when a file is gone, and when it is (back) on disk. +const MISSING_COLUMNS = { removed: true, removed_reason: 'not_found', removed_at: expect.any(Date), removed_detail: null }; +const PRESENT_COLUMNS = { removed: false, removed_reason: null, removed_at: null, removed_detail: null }; + describe('FileCheckModule', () => { let fileCheckModule; let mockFs; let mockVideo; + let mockLibraryRootCheck; beforeEach(() => { jest.resetModules(); @@ -25,9 +30,99 @@ describe('FileCheckModule', () => { Video: mockVideo })); + // The downloads folder looks mounted unless a test says otherwise. + mockLibraryRootCheck = { + ...jest.requireActual('../libraryRootCheck'), + checkLibraryRoot: jest.fn().mockResolvedValue({ ok: true }), + }; + jest.doMock('../libraryRootCheck', () => mockLibraryRootCheck); + jest.doMock('../configModule', () => ({ directoryPath: '/videos' })); + fileCheckModule = require('../fileCheckModule'); }); + describe('while the downloads folder looks unmounted', () => { + const failedCheck = { ok: false, reason: 'empty', path: '/videos', message: 'The downloads folder is empty.' }; + const presentRow = (id, youtubeId) => ({ + id, youtubeId, filePath: `/videos/channel/${youtubeId}.mp4`, fileSize: '1000', removed: false + }); + + test('holds back the missing mark and reports how many rows it held', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(failedCheck); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles([presentRow(1, 'abc123'), presentRow(2, 'def456')]); + + expect(result.updates).toEqual([]); + expect(result.videos.map((v) => v.removed)).toEqual([false, false]); + expect(result.libraryWarning).toEqual({ reason: 'empty', path: '/videos', message: failedCheck.message, heldBack: 2 }); + expect(mockLibraryRootCheck.checkLibraryRoot).toHaveBeenCalledTimes(1); + expect(mockLibraryRootCheck.checkLibraryRoot).toHaveBeenCalledWith('/videos'); + }); + + test('still records a present file\'s new size', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(failedCheck); + mockFs.stat.mockResolvedValue({ size: 2000 }); + + const result = await fileCheckModule.checkVideoFiles([presentRow(1, 'abc123')]); + + expect(result.updates).toEqual([{ id: 1, fileSize: 2000 }]); + expect(result.libraryWarning).toBeNull(); + }); + + describe('when only a subfolder looks unmounted', () => { + const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/videos/__Music', message: 'The subfolder __Music is empty.' }; + const rowAt = (id, filePath) => ({ id, youtubeId: `vid${id}`, filePath, fileSize: '1000', removed: false }); + + test('still marks a video stored outside that subfolder missing', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles([rowAt(1, '/videos/__Videos/channel/vid1.mp4')]); + + expect(result.updates).toEqual([{ id: 1, ...MISSING_COLUMNS }]); + expect(result.libraryWarning).toBeNull(); + }); + + test('holds back only the videos under that subfolder', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles([ + rowAt(1, '/videos/__Music/artist/vid1.mp4'), + rowAt(2, '/videos/__Videos/channel/vid2.mp4'), + ]); + + expect(result.updates).toEqual([{ id: 2, ...MISSING_COLUMNS }]); + expect(result.libraryWarning).toEqual(expect.objectContaining({ reason: 'subfolder-empty', heldBack: 1 })); + }); + + test('holds back the videos under every failing subfolder, not just the first', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue({ + ...emptySubfolder, paths: ['/videos/__Music', '/videos/__Kids'], message: 'The subfolders __Music and __Kids are empty.', + }); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles([ + rowAt(1, '/videos/__Music/artist/vid1.mp4'), + rowAt(2, '/videos/__Kids/channel/vid2.mp4'), + ]); + + expect(result.updates).toEqual([]); + expect(result.libraryWarning).toEqual(expect.objectContaining({ heldBack: 2 })); + }); + }); + + test('does not run the check when no row would be marked missing', async () => { + mockFs.stat.mockResolvedValue({ size: 1000 }); + + const result = await fileCheckModule.checkVideoFiles([presentRow(1, 'abc123')]); + + expect(mockLibraryRootCheck.checkLibraryRoot).not.toHaveBeenCalled(); + expect(result.libraryWarning).toBeNull(); + }); + }); + describe('checkVideoFiles', () => { test('should return unchanged videos when none have filePaths', async () => { const videos = [ @@ -61,12 +156,13 @@ describe('FileCheckModule', () => { id: 1, youtubeId: 'abc123', filePath: '/videos/channel/video.mp4', - removed: true + ...MISSING_COLUMNS } ]); expect(result.updates).toEqual([ - { id: 1, removed: true } + { id: 1, ...MISSING_COLUMNS } ]); + expect(result.libraryWarning).toBeNull(); expect(mockFs.stat).toHaveBeenCalledWith('/videos/channel/video.mp4'); }); @@ -89,6 +185,27 @@ describe('FileCheckModule', () => { expect(mockFs.stat).toHaveBeenCalledWith('/videos/channel/video.mp4'); }); + test('leaves a deleted video deleted while its file is still gone', async () => { + const videos = [ + { + id: 1, + youtubeId: 'abc123', + filePath: '/videos/channel/video.mp4', + removed: true, + removed_reason: 'user_deleted', + removed_at: new Date('2026-10-03T00:00:00Z'), + removed_detail: null + } + ]; + + mockFs.stat.mockRejectedValueOnce({ code: 'ENOENT' }); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.videos).toEqual(videos); + expect(result.updates).toEqual([]); + }); + test('should mark video as not removed when file exists', async () => { const videos = [ { @@ -110,15 +227,37 @@ describe('FileCheckModule', () => { youtubeId: 'abc123', filePath: '/videos/channel/video.mp4', fileSize: '1000', - removed: false + ...PRESENT_COLUMNS } ]); expect(result.updates).toEqual([ - { id: 1, removed: false } + { id: 1, ...PRESENT_COLUMNS } ]); expect(mockFs.stat).toHaveBeenCalledWith('/videos/channel/video.mp4'); }); + test('clears the recorded reason when a deleted video\'s file is back', async () => { + const videos = [ + { + id: 1, + youtubeId: 'abc123', + filePath: '/videos/channel/video.mp4', + fileSize: '1000', + removed: true, + removed_reason: 'user_deleted', + removed_at: new Date('2026-10-03T00:00:00Z'), + removed_detail: null + } + ]; + + mockFs.stat.mockResolvedValueOnce({ size: 1000 }); + + const result = await fileCheckModule.checkVideoFiles(videos); + + expect(result.updates).toEqual([{ id: 1, ...PRESENT_COLUMNS }]); + expect(result.videos[0]).toEqual(expect.objectContaining(PRESENT_COLUMNS)); + }); + test('should update fileSize when it has changed', async () => { const videos = [ { @@ -218,14 +357,14 @@ describe('FileCheckModule', () => { youtubeId: 'abc123', filePath: '/videos/channel/video1.mp4', fileSize: '1000', - removed: true + ...MISSING_COLUMNS }, { id: 2, youtubeId: 'def456', filePath: '/videos/channel/video2.mp4', fileSize: '2000', - removed: false + ...PRESENT_COLUMNS }, { id: 3, @@ -241,8 +380,8 @@ describe('FileCheckModule', () => { } ]); expect(result.updates).toEqual([ - { id: 1, removed: true }, - { id: 2, removed: false }, + { id: 1, ...MISSING_COLUMNS }, + { id: 2, ...PRESENT_COLUMNS }, { id: 4, fileSize: 4000 } ]); }); @@ -460,7 +599,7 @@ describe('FileCheckModule', () => { // Marked removed, but filePath/fileSize must NOT appear in the update expect(result.updates).toHaveLength(1); - expect(result.updates[0]).toEqual({ id: 1, removed: true }); + expect(result.updates[0]).toEqual({ id: 1, ...MISSING_COLUMNS }); expect(result.updates[0].filePath).toBeUndefined(); expect(result.updates[0].fileSize).toBeUndefined(); }); @@ -502,7 +641,7 @@ describe('FileCheckModule', () => { const result = await fileCheckModule.checkVideoFiles(videos); expect(result.updates).toHaveLength(1); - expect(result.updates[0]).toEqual({ id: 1, removed: true }); + expect(result.updates[0]).toEqual({ id: 1, ...MISSING_COLUMNS }); expect(result.updates[0].audioFilePath).toBeUndefined(); }); }); @@ -528,7 +667,7 @@ describe('FileCheckModule', () => { ); }); - test('should update removed status only', async () => { + test('marks missing only while the row is still present, so a delete landing meanwhile wins', async () => { const updates = [ { id: 1, removed: true } ]; @@ -538,11 +677,23 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( { removed: true }, - { where: { id: 1 } }, + { where: { id: 1, removed: false } }, ); }); - test('should update both fileSize and removed status', async () => { + test('writes the removal reason columns along with the flag', async () => { + const at = new Date('2026-10-03T00:00:00Z'); + await fileCheckModule.applyVideoUpdates([ + { id: 1, removed: true, removed_reason: 'not_found', removed_at: at, removed_detail: null } + ]); + + expect(mockVideo.update).toHaveBeenCalledWith( + { removed: true, removed_reason: 'not_found', removed_at: at, removed_detail: null }, + { where: { id: 1, removed: false } }, + ); + }); + + test('restores a row only while it is still removed', async () => { const updates = [ { id: 1, fileSize: 2000, removed: false } ]; @@ -552,7 +703,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( { fileSize: 2000, removed: false }, - { where: { id: 1 } }, + { where: { id: 1, removed: true } }, ); }); @@ -569,12 +720,12 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenNthCalledWith( 1, { fileSize: 2000, removed: false }, - { where: { id: 1 } }, + { where: { id: 1, removed: true } }, ); expect(mockVideo.update).toHaveBeenNthCalledWith( 2, { removed: true }, - { where: { id: 2 } }, + { where: { id: 2, removed: false } }, ); expect(mockVideo.update).toHaveBeenNthCalledWith( 3, @@ -592,7 +743,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledWith( { removed: false }, - { where: { id: 1 } }, + { where: { id: 1, removed: true } }, ); }); @@ -605,7 +756,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledWith( { removed: true }, - { where: { id: 1 } }, + { where: { id: 1, removed: false } }, ); }); @@ -668,7 +819,7 @@ describe('FileCheckModule', () => { expect(mockVideo.update).toHaveBeenCalledWith( { filePath: '/videos/channel/video [abc123].mkv', fileSize: 5000, removed: false }, - { where: { id: 1 } }, + { where: { id: 1, removed: true } }, ); }); diff --git a/server/modules/__tests__/jobModule.test.js b/server/modules/__tests__/jobModule.test.js index 8b906c66c..9083c879b 100644 --- a/server/modules/__tests__/jobModule.test.js +++ b/server/modules/__tests__/jobModule.test.js @@ -616,7 +616,11 @@ describe('JobModule', () => { expect(Video.create).toHaveBeenCalledWith(expect.objectContaining({ youtubeId: 'video-1', filePath: actualPath, - fileSize: '98765' + fileSize: '98765', + removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null })); }); @@ -629,6 +633,33 @@ describe('JobModule', () => { expect(Video.create).not.toHaveBeenCalled(); }); + test('leaves the removal columns alone when the recovered file is not found', async () => { + JobVideoDownload.findAll.mockResolvedValue([ + { job_id: 'job-1', youtube_id: 'video-1', status: 'completed' } + ]); + fsPromises.readFile.mockResolvedValue(JSON.stringify({ + id: 'video-1', + uploader: 'Channel 1', + title: 'Video 1', + duration: 100, + upload_date: '20240101', + channel_id: 'channel-1', + _actual_filepath: '/data/Channel 1/Channel 1 - Video 1 - video-1/Channel 1 - Video 1 [video-1].mp4' + })); + fsPromises.access.mockResolvedValue(); + fsPromises.stat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + Job.findOne.mockResolvedValue({ id: 'job-1' }); + Video.findOne.mockResolvedValue(null); + + await JobModule.recoverCompletedVideos('job-1'); + + expect(Video.create).toHaveBeenCalledTimes(1); + const created = Video.create.mock.calls[0][0]; + expect(created.fileSize).toBeNull(); + expect(created).not.toHaveProperty('removed'); + expect(created).not.toHaveProperty('removed_reason'); + }); + test('should skip videos with missing info.json files', async () => { JobVideoDownload.findAll.mockResolvedValue([ @@ -2477,6 +2508,14 @@ describe('JobModule', () => { details: { videosUpserts: 2, channelVideosUpserts: 2, failed: 0, unreadableOnDisk: 0, recreatedUnverified: 0 } })); expect(Video.create).toHaveBeenCalledTimes(2); + // Recreated without a file on disk: missing, not deleted. + expect(Video.create).toHaveBeenCalledWith(expect.objectContaining({ + youtubeId: 'video-1', + removed: true, + removed_reason: 'not_found', + removed_at: expect.any(Date), + removed_detail: null + })); expect(ChannelVideo.findOrCreate).toHaveBeenCalledTimes(2); expect(logger.info).toHaveBeenCalledWith( expect.objectContaining({ @@ -2639,7 +2678,10 @@ describe('JobModule', () => { youtubeId: 'video-1', filePath, fileSize: '123456789', - removed: false + removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null }) ); @@ -2693,7 +2735,7 @@ describe('JobModule', () => { expect(Video.create).not.toHaveBeenCalled(); expect(Video.update).toHaveBeenCalledWith( - { filePath: mp4Path, fileSize: '987654321', removed: false }, + { filePath: mp4Path, fileSize: '987654321', removed: false, removed_reason: null, removed_at: null, removed_detail: null }, { where: expect.objectContaining({ id: 7, removed: false, filePath: mp4Path, fileSize: null }) } ); }); @@ -2894,7 +2936,7 @@ describe('JobModule', () => { await JobModule.backfillFromCompleteList(); expect(Video.update).toHaveBeenCalledWith( - { filePath: `${base}.webm`, fileSize: '777', removed: false }, + { filePath: `${base}.webm`, fileSize: '777', removed: false, removed_reason: null, removed_at: null, removed_detail: null }, expect.anything() ); }); @@ -3526,4 +3568,37 @@ describe('JobModule', () => { expect(JobModule.jobs['job-4'].data).toEqual({ videos: [] }); }); }); + + describe('reloadJobVideos', () => { + beforeEach(() => { + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue(JSON.stringify({ plexApiKey: 'test-key' })); + JobModule = require('../jobModule'); + }); + + test('replaces each named job\'s videos with the rows still linked to it', async () => { + JobModule.jobs = { + 'job-1': { timeCreated: Date.now(), status: 'Complete', data: { videos: [{ id: 1 }, { id: 2 }] } }, + 'job-2': { timeCreated: Date.now(), status: 'Complete', data: { videos: [{ id: 3 }] } }, + }; + JobVideo.findAll.mockImplementation(async ({ where }) => (where.job_id === 'job-1' ? [{ video_id: 2 }] : [])); + Video.findOne.mockResolvedValue({ dataValues: { id: 2, youtubeId: 'b' } }); + + await JobModule.reloadJobVideos(['job-1', 'job-2']); + + expect(JobModule.jobs['job-1'].data.videos).toEqual([{ id: 2, youtubeId: 'b' }]); + expect(JobModule.jobs['job-2'].data.videos).toEqual([]); + }); + + test('skips jobs it does not hold and never throws', async () => { + JobModule.jobs = { 'job-1': { timeCreated: Date.now(), status: 'Complete', data: { videos: [{ id: 1 }] } } }; + JobVideo.findAll.mockRejectedValue(new Error('db down')); + + await expect(JobModule.reloadJobVideos(['job-1', 'unknown'])).resolves.toBeUndefined(); + + expect(JobModule.jobs['job-1'].data.videos).toEqual([{ id: 1 }]); + expect(JobVideo.findAll).toHaveBeenCalledWith({ where: { job_id: 'job-1' } }); + expect(JobVideo.findAll).not.toHaveBeenCalledWith({ where: { job_id: 'unknown' } }); + }); + }); }); diff --git a/server/modules/__tests__/libraryRootCheck.test.js b/server/modules/__tests__/libraryRootCheck.test.js new file mode 100644 index 000000000..16d1fa257 --- /dev/null +++ b/server/modules/__tests__/libraryRootCheck.test.js @@ -0,0 +1,295 @@ +/* eslint-env jest */ +const path = require('path'); +const { Op } = require('sequelize'); +const { + LIBRARY_CHECK_REASONS, checkLibraryRoot, describeLibraryCheck, isUnderLibraryCheckPath, isRowUnderLibraryCheck, libraryCheckCoversRows, +} = require('../libraryRootCheck'); + +const ROOT = '/data/youtube'; + +const dir = (name) => ({ name, isDirectory: () => true }); +const file = (name) => ({ name, isDirectory: () => false }); +const fsError = (code) => Object.assign(new Error(code), { code }); + +// A fake disk: readdir answers per path, with a thrown error code or a listing. +const diskWith = (listings) => async (dirPath) => { + const entry = listings[dirPath]; + if (entry === undefined) throw fsError('ENOENT'); + if (typeof entry === 'string') throw fsError(entry); + return entry; +}; + +describe('checkLibraryRoot', () => { + test('passes for a root that has files and non-empty subfolders', async () => { + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__Music'), file('.keep')], + [path.join(ROOT, '__Music')]: [dir('Artist')], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); + }); + + test('fails as missing when the root does not exist', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({}) }); + expect(result).toEqual({ + ok: false, + reason: LIBRARY_CHECK_REASONS.MISSING, + path: ROOT, + message: 'The downloads folder does not exist.', + }); + }); + + test('fails as missing when the root path is not a directory', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({ [ROOT]: 'ENOTDIR' }) }); + expect(result).toEqual(expect.objectContaining({ ok: false, reason: 'missing', path: ROOT })); + }); + + test('fails as unreadable when the root cannot be listed', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({ [ROOT]: 'EACCES' }) }); + expect(result).toEqual({ + ok: false, + reason: 'unreadable', + path: ROOT, + message: 'The downloads folder could not be read.', + }); + }); + + test('fails as empty when the root has no entries at all', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({ [ROOT]: [] }) }); + expect(result).toEqual({ + ok: false, + reason: 'empty', + path: ROOT, + message: 'The downloads folder is empty.', + }); + }); + + test('reports empty when the root holds only Youtarr\'s temp folder', async () => { + const result = await checkLibraryRoot(ROOT, { readdir: diskWith({ [ROOT]: [dir('.youtarr_tmp')] }) }); + expect(result).toEqual(expect.objectContaining({ ok: false, reason: 'empty', path: ROOT })); + }); + + test('reports empty when the root holds only entries Youtarr itself creates', async () => { + const readdir = diskWith({ + [ROOT]: [dir('.youtarr_tmp'), dir('__playlists__'), file('.youtarr_healthcheck_1234')], + [path.join(ROOT, '__playlists__')]: [], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual( + expect.objectContaining({ ok: false, reason: 'empty', path: ROOT }) + ); + }); + + test('does not treat the playlists folder as a library subfolder', async () => { + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__playlists__')], + [path.join(ROOT, '__playlists__')]: [], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); + }); + + test('fails when a __subfolder exists but is empty, naming that subfolder', async () => { + const music = path.join(ROOT, '__Music'); + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__Music')], + [music]: [], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ + ok: false, + reason: 'subfolder-empty', + path: music, + paths: [music], + message: 'The subfolder __Music is empty.', + }); + }); + + // Two shares can be down at once (a NAS reboot); each must stay protected. + test('reports every failing subfolder, not just the first', async () => { + const music = path.join(ROOT, '__Music'); + const kids = path.join(ROOT, '__Kids'); + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__Music'), dir('__Kids')], + [music]: [], + [kids]: [], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ + ok: false, + reason: 'subfolder-empty', + path: music, + paths: [music, kids], + message: 'The subfolders __Music and __Kids are empty.', + }); + }); + + // An unreadable share must never be looked past as "emptied on purpose". + test('reports the strictest reason when one subfolder is empty and another unreadable', async () => { + const music = path.join(ROOT, '__Music'); + const kids = path.join(ROOT, '__Kids'); + const readdir = diskWith({ + [ROOT]: [dir('__Music'), dir('__Kids')], + [music]: [], + [kids]: 'EIO', + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ + ok: false, + reason: 'subfolder-unreadable', + path: kids, + paths: [music, kids], + message: 'The subfolder __Music is empty. The subfolder __Kids could not be read.', + }); + }); + + test('only checks __ folders: an empty channel folder is fine', async () => { + const readdir = diskWith({ + [ROOT]: [dir('Channel A'), dir('__Music')], + [path.join(ROOT, 'Channel A')]: [], + [path.join(ROOT, '__Music')]: [file('song [abc].mp3')], + }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); + }); + + test('ignores a __subfolder that vanished between the two listings', async () => { + const readdir = diskWith({ [ROOT]: [dir('__Music'), file('a [abc].mp4')] }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ ok: true }); + }); + + test('fails as subfolder-unreadable when a __subfolder cannot be listed, naming that subfolder', async () => { + const music = path.join(ROOT, '__Music'); + const readdir = diskWith({ [ROOT]: [dir('__Music')], [music]: 'EIO' }); + await expect(checkLibraryRoot(ROOT, { readdir })).resolves.toEqual({ + ok: false, + reason: 'subfolder-unreadable', + path: music, + paths: [music], + message: 'The subfolder __Music could not be read.', + }); + }); + + test('treats no configured folder as missing', async () => { + const readdir = jest.fn(); + await expect(checkLibraryRoot('', { readdir })).resolves.toEqual( + expect.objectContaining({ ok: false, reason: 'missing' }) + ); + expect(readdir).not.toHaveBeenCalled(); + }); +}); + +describe('describeLibraryCheck', () => { + // The container path (/usr/src/app/data) means nothing on the host, so the + // root is "the downloads folder" and a subfolder goes by its own name. + test('describes each reason in one sentence, without the container path', () => { + expect(describeLibraryCheck({ reason: 'missing', path: '/d' })).toBe('The downloads folder does not exist.'); + expect(describeLibraryCheck({ reason: 'empty', path: '/d' })).toBe('The downloads folder is empty.'); + expect(describeLibraryCheck({ reason: 'unreadable', path: '/d' })).toBe('The downloads folder could not be read.'); + expect(describeLibraryCheck({ reason: 'subfolder-empty', path: '/d/__Music' })).toBe('The subfolder __Music is empty.'); + expect(describeLibraryCheck({ reason: 'subfolder-unreadable', path: '/d/__Music' })).toBe('The subfolder __Music could not be read.'); + }); +}); + +describe('isUnderLibraryCheckPath', () => { + const music = path.join(ROOT, '__Music'); + + test('a failing root covers every file path', () => { + expect(isUnderLibraryCheckPath({ ok: false, reason: 'empty', path: ROOT }, '/data/youtube/Channel/v [abc].mp4')).toBe(true); + expect(isUnderLibraryCheckPath({ ok: false, reason: 'missing', path: ROOT }, '/elsewhere/v [abc].mp4')).toBe(true); + }); + + test('a failing subfolder covers only the files under it', () => { + const check = { ok: false, reason: 'subfolder-empty', path: music }; + expect(isUnderLibraryCheckPath(check, path.join(music, 'Artist', 'song [abc].mp3'))).toBe(true); + expect(isUnderLibraryCheckPath(check, path.join(ROOT, '__Music2', 'song [abc].mp3'))).toBe(false); + expect(isUnderLibraryCheckPath(check, path.join(ROOT, 'Channel', 'v [abc].mp4'))).toBe(false); + }); + + test('every failing subfolder covers the files under it', () => { + const kids = path.join(ROOT, '__Kids'); + const check = { ok: false, reason: 'subfolder-empty', path: music, paths: [music, kids] }; + expect(isUnderLibraryCheckPath(check, path.join(kids, 'Channel', 'v [abc].mp4'))).toBe(true); + expect(isUnderLibraryCheckPath(check, path.join(music, 'Artist', 'song [abc].mp3'))).toBe(true); + expect(isUnderLibraryCheckPath(check, path.join(ROOT, '__Videos', 'Channel', 'v [abc].mp4'))).toBe(false); + }); + + test('a passing check and a video without a file path cover nothing', () => { + expect(isUnderLibraryCheckPath({ ok: true }, '/data/youtube/Channel/v [abc].mp4')).toBe(false); + expect(isUnderLibraryCheckPath({ ok: false, reason: 'empty', path: ROOT }, null)).toBe(false); + }); +}); + +describe('isRowUnderLibraryCheck', () => { + const music = path.join(ROOT, '__Music'); + const check = { ok: false, reason: 'subfolder-empty', path: music }; + + test('covers a row whose video file is under the failing subfolder', () => { + expect(isRowUnderLibraryCheck(check, { filePath: path.join(music, 'Artist', 'v [abc].mp4'), audioFilePath: null })).toBe(true); + }); + + test('covers a row whose audio file is under the failing subfolder', () => { + expect(isRowUnderLibraryCheck(check, { filePath: null, audioFilePath: path.join(music, 'Artist', 'v [abc].mp3') })).toBe(true); + }); + + test('leaves a row stored elsewhere alone', () => { + expect(isRowUnderLibraryCheck(check, { filePath: path.join(ROOT, '__Videos', 'Channel', 'v [abc].mp4'), audioFilePath: null })).toBe(false); + }); +}); + +describe('libraryCheckCoversRows', () => { + const music = path.join(ROOT, '__Music'); + const present = { removed: false }; + const videoModel = (count) => ({ count: jest.fn().mockResolvedValue(count) }); + + test('a passing check covers nothing and reads no rows', async () => { + const Video = videoModel(5); + await expect(libraryCheckCoversRows({ ok: true }, Video, present)).resolves.toBe(false); + expect(Video.count).not.toHaveBeenCalled(); + }); + + test('a failing root covers every row the filter matches', async () => { + const Video = videoModel(3); + await expect(libraryCheckCoversRows({ ok: false, reason: 'empty', path: ROOT }, Video, present)).resolves.toBe(true); + expect(Video.count).toHaveBeenCalledWith({ where: present }); + }); + + test('a failing root with no matching rows covers nothing', async () => { + await expect(libraryCheckCoversRows({ ok: false, reason: 'empty', path: ROOT }, videoModel(0), present)).resolves.toBe(false); + }); + + test('a failing subfolder covers only rows stored under it, matching its name literally', async () => { + const Video = videoModel(0); + const check = { ok: false, reason: 'subfolder-empty', path: music }; + + await expect(libraryCheckCoversRows(check, Video, present)).resolves.toBe(false); + + // `_` is a LIKE wildcard, and every subfolder name starts with two of them. + const prefix = `${ROOT}/\\_\\_Music/%`; + expect(Video.count).toHaveBeenCalledWith({ + where: { + [Op.and]: [ + present, + { [Op.or]: [{ filePath: { [Op.like]: prefix } }, { audioFilePath: { [Op.like]: prefix } }] }, + ], + }, + }); + }); + + test('covers rows under any of several failing subfolders', async () => { + const Video = videoModel(1); + const kids = path.join(ROOT, '__Kids'); + const check = { ok: false, reason: 'subfolder-empty', path: music, paths: [music, kids] }; + + await expect(libraryCheckCoversRows(check, Video, present)).resolves.toBe(true); + + const musicPrefix = `${ROOT}/\\_\\_Music/%`; + const kidsPrefix = `${ROOT}/\\_\\_Kids/%`; + expect(Video.count).toHaveBeenCalledWith({ + where: { + [Op.and]: [ + present, + { + [Op.or]: [ + { filePath: { [Op.like]: musicPrefix } }, { audioFilePath: { [Op.like]: musicPrefix } }, + { filePath: { [Op.like]: kidsPrefix } }, { audioFilePath: { [Op.like]: kidsPrefix } }, + ], + }, + ], + }, + }); + }); +}); diff --git a/server/modules/__tests__/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__/playlistDownloadModule.test.js b/server/modules/__tests__/playlistDownloadModule.test.js index 716c1bdcd..305112024 100644 --- a/server/modules/__tests__/playlistDownloadModule.test.js +++ b/server/modules/__tests__/playlistDownloadModule.test.js @@ -1,7 +1,9 @@ jest.mock('../../logger', () => ({ error: jest.fn() })); jest.mock('../download/videoActivity', () => ({ isActive: jest.fn(() => false) })); +jest.mock('../archiveOnlyIds', () => ({ findArchiveOnlyIds: jest.fn() })); const playlistDownloads = require('../playlistDownloadModule'); const videoActivity = require('../download/videoActivity'); +const { findArchiveOnlyIds } = require('../archiveOnlyIds'); const baseline = new Date('2026-09-01T00:00:00Z'); const playlist = { playlist_id: 'PL1', auto_download: true, auto_download_baseline_at: baseline, auto_download_baseline_id: 10 }; @@ -25,6 +27,32 @@ describe('playlistDownloadModule', () => { beforeEach(() => { jest.clearAllMocks(); videoActivity.isActive.mockReturnValue(false); + findArchiveOnlyIds.mockResolvedValue(new Set()); + }); + + test('leaves out videos the download archive lists without a row, such as emptied from the trash', async () => { + findArchiveOnlyIds.mockResolvedValue(new Set(['old'])); + const { candidates } = await playlistDownloads.getTrackedVideos('PL1', dependencies()); + expect(candidates.map((row) => row.youtube_id)).toEqual(['new']); + }); + + test('looks up the archive only for videos without a row', async () => { + const deps = dependencies(); + deps.Video.findAll.mockResolvedValue([{ youtubeId: 'old' }]); + await playlistDownloads.getTrackedVideos('PL1', deps); + expect(findArchiveOnlyIds).toHaveBeenCalledWith(['new']); + }); + + test('keeps archived videos when re-downloading is allowed', async () => { + findArchiveOnlyIds.mockResolvedValue(new Set(['old'])); + const { candidates } = await playlistDownloads.getTrackedVideos('PL1', dependencies(), { allowRedownload: true }); + expect(candidates.map((row) => row.youtube_id)).toEqual(['old', 'new']); + }); + + test('leaves archived videos out of the not-downloaded count', async () => { + findArchiveOnlyIds.mockResolvedValue(new Set(['new'])); + expect(await playlistDownloads.getCounts(playlist, dependencies())) + .toMatchObject({ not_downloaded_count: 1 }); }); test('keeps channel attribution for all download paths', async () => { diff --git a/server/modules/__tests__/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__/rescanRowUpdate.test.js b/server/modules/__tests__/rescanRowUpdate.test.js index 4a2420621..9e2aa75d5 100644 --- a/server/modules/__tests__/rescanRowUpdate.test.js +++ b/server/modules/__tests__/rescanRowUpdate.test.js @@ -5,10 +5,17 @@ const MP4 = '/videos/Channel/Video [abc].mp4'; const MP3 = '/videos/Channel/Video [abc].mp3'; const MOVED_MP4 = '/videos/Other/Video [abc].mp4'; +// What the rescan writes when a row loses its files, and when they are (back) on disk. +const MISSING = { removed: true, removed_reason: 'not_found', removed_at: expect.any(Date), removed_detail: null }; +const PRESENT = { removed: false, removed_reason: null, removed_at: null, removed_detail: null }; + const row = (overrides = {}) => ({ id: 1, youtubeId: 'abc', removed: 0, + removed_reason: null, + removed_at: null, + removed_detail: null, filePath: null, fileSize: null, audioFilePath: null, @@ -69,7 +76,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '1000', audioFilePath: MP3, audioFileSize: '500', video_resolution: '1920x1080' }), undefined, null, diskWith({}) ); - expect(update).toEqual({ removed: true }); + expect(update).toEqual(MISSING); }); test('writes nothing for a row already missing whose files are still gone', async () => { @@ -88,7 +95,21 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '1000', removed: 1 }), { videoFilePath: MP4, videoFileSize: 1000 }, null, diskWith({ [MP4]: 1000 }) ); - expect(update).toEqual({ removed: false }); + expect(update).toEqual(PRESENT); + }); + + test('restores a deleted row whose file is back, clearing why it was removed', async () => { + const update = await resolveRescanUpdate( + row({ filePath: MP4, fileSize: '1000', removed: 1, removed_reason: 'marked_deleted', removed_at: new Date('2026-10-03T00:00:00Z') }), + { videoFilePath: MP4, videoFileSize: 1000 }, null, diskWith({ [MP4]: 1000 }) + ); + expect(update).toEqual(PRESENT); + }); + + test('leaves a deleted row deleted while its files are still gone', async () => { + expect(await resolveRescanUpdate( + row({ filePath: MP4, removed: 1, removed_reason: 'user_deleted' }), undefined, null, diskWith({}) + )).toBeNull(); }); test('records a moved file at the path the walk found, with its fresh size', async () => { @@ -96,7 +117,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '1000' }), { videoFilePath: MOVED_MP4, videoFileSize: 1000 }, null, diskWith({ [MOVED_MP4]: 1200 }) ); - expect(update).toEqual({ filePath: MOVED_MP4, fileSize: 1200, removed: false }); + expect(update).toEqual({ filePath: MOVED_MP4, fileSize: 1200, ...PRESENT }); }); test('falls back to the stored path when the walked path is gone', async () => { @@ -112,7 +133,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '1000', audioFilePath: MP3, audioFileSize: '500', video_resolution: '1920x1080' }), { audioFilePath: MP3, audioFileSize: 500 }, null, diskWith({ [MP3]: 500 }) ); - expect(update).toEqual({ filePath: null, fileSize: null, video_resolution: null, removed: false }); + expect(update).toEqual({ filePath: null, fileSize: null, video_resolution: null, ...PRESENT }); }); test('keeps a format that appeared after the walk', async () => { @@ -128,7 +149,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '900' }), { videoFilePath: MP4, videoFileSize: 1000 }, null, diskWith({ [MP4]: 1100 }) ); - expect(update).toEqual({ fileSize: 1100, removed: false }); + expect(update).toEqual({ fileSize: 1100, ...PRESENT }); }); test('stores a probed resolution only while the probed file is unchanged', async () => { @@ -139,8 +160,8 @@ describe('resolveRescanUpdate', () => { const replaced = await resolveRescanUpdate( row({ filePath: MP4, fileSize: '1000' }), fileInfo, '1920x1080', diskWith({ [MP4]: 2000 }) ); - expect(unchanged).toEqual({ video_resolution: '1920x1080', removed: false }); - expect(replaced).toEqual({ fileSize: 2000, removed: false }); + expect(unchanged).toEqual({ video_resolution: '1920x1080', ...PRESENT }); + expect(replaced).toEqual({ fileSize: 2000, ...PRESENT }); }); test('leaves the row alone when its only file cannot be checked', async () => { @@ -154,14 +175,14 @@ describe('resolveRescanUpdate', () => { const update = await resolveRescanUpdate( row({ filePath: MP4, fileSize: '1000' }), undefined, null, diskWith({}, { [MP4]: 'ENOTDIR' }) ); - expect(update).toEqual({ removed: true }); + expect(update).toEqual(MISSING); }); test('does not record a directory at the stored path as the file', async () => { const update = await resolveRescanUpdate( row({ filePath: MP4, fileSize: '1000' }), undefined, null, diskWith({}, {}, [MP4]) ); - expect(update).toEqual({ removed: true }); + expect(update).toEqual(MISSING); }); test('neither clears nor marks missing while one format cannot be checked', async () => { @@ -177,7 +198,7 @@ describe('resolveRescanUpdate', () => { row({ filePath: MP4, fileSize: '900', audioFilePath: MP3, audioFileSize: '500' }), undefined, null, diskWith({ [MP4]: 1000 }, { [MP3]: 'EIO' }) ); - expect(update).toEqual({ fileSize: 1000, removed: false }); + expect(update).toEqual({ fileSize: 1000, ...PRESENT }); }); test('restores a missing row whose video is back even though its audio cannot be checked', async () => { @@ -185,7 +206,7 @@ describe('resolveRescanUpdate', () => { row({ removed: 1, filePath: MP4, fileSize: '1000', audioFilePath: MP3, audioFileSize: '500' }), { videoFilePath: MP4, videoFileSize: 1000 }, null, diskWith({ [MP4]: 1000 }, { [MP3]: 'EIO' }) ); - expect(update).toEqual({ removed: false }); + expect(update).toEqual(PRESENT); }); // videoRowGuard can't tell a no-op write from a conflict (Sequelize connects diff --git a/server/modules/__tests__/rescanRunSummary.test.js b/server/modules/__tests__/rescanRunSummary.test.js index e37b3c955..317c9b516 100644 --- a/server/modules/__tests__/rescanRunSummary.test.js +++ b/server/modules/__tests__/rescanRunSummary.test.js @@ -21,10 +21,31 @@ describe('rescanRunSummary', () => { videosKept: 0, videosSkipped: 0, videosFailed: 0, + videosNotMarkedMissing: 0, }, }); }); + test('says when the downloads folder check was skipped on request', () => { + const record = toRunRecord({ status: 'completed', processed: 5, updated: 1, removed: 2, libraryCheckSkipped: true }); + expect(record.status).toBe('success'); + expect(record.message).toBe('Scanned 5 videos: 1 updated, 2 marked missing. The downloads folder check was skipped.'); + expect(record.details.libraryCheckSkipped).toBe(true); + }); + + test('records a scan that held back missing marks behind a library check as partial, naming the cause', () => { + const record = toRunRecord({ + status: 'completed', processed: 10, updated: 1, removed: 0, + notMarkedMissing: 2, libraryCheckMessage: 'The subfolder __Music is empty.', + }); + expect(record).toEqual(expect.objectContaining({ + status: 'error', + outcome: 'partial', + message: 'Scanned 10 videos: 1 updated, 0 marked missing, 2 not marked missing (The subfolder __Music is empty).', + })); + expect(record.details.videosNotMarkedMissing).toBe(2); + }); + test('reports rows skipped because they changed during the scan without treating the run as skipped', () => { const record = toRunRecord({ status: 'completed', processed: 10, updated: 1, removed: 0, skippedChanged: 2 }); expect(record).toEqual(expect.objectContaining({ @@ -128,6 +149,11 @@ describe('rescanRunSummary', () => { }); }); + test('carries a skipped downloads folder check through to the maintenance page', () => { + const skippedRun = { ...run, details: { ...run.details, libraryCheckSkipped: true } }; + expect(fromRunRecord(skippedRun).libraryCheckSkipped).toBe(true); + }); + test('reports an interrupted run as an error with its message', () => { expect(fromRunRecord({ ...run, status: 'interrupted', outcome: null, message: 'Interrupted by a restart.', details: null })).toEqual( expect.objectContaining({ status: 'error', errorMessage: 'Interrupted by a restart.', videosScanned: 0 }) diff --git a/server/modules/__tests__/videoDeletionModule.test.js b/server/modules/__tests__/videoDeletionModule.test.js index d105207cd..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 }) @@ -17,6 +21,20 @@ jest.mock('../storageGuard', () => ({ // beforeEach installs persists across resetModules and only has `promises`, // which fs-extra (required by directoryManager) cannot load against. const { isVideoDirectoryFor } = jest.requireActual('../filesystem/directoryManager'); +// The real path predicates; only the check itself is mocked. +const { isUnderLibraryCheckPath, libraryCheckCoversRows } = jest.requireActual('../libraryRootCheck'); +const { Op } = jest.requireActual('sequelize'); +// A Video.count for a library of `total` videos with none stored under a +// failing subfolder: a count scoped to the subfolder (an Op.and) finds nothing. +const countNoneUnderSubfolder = (total) => async ({ where }) => (where[Op.and] ? 0 : total); + +// What a delete in the app writes on the row: removed, by the user, now. +const USER_DELETED_COLUMNS = expect.objectContaining({ + removed: true, + removed_reason: 'user_deleted', + removed_at: expect.any(Date), + removed_detail: null +}); describe('VideoDeletionModule', () => { let VideoDeletionModule; @@ -25,6 +43,8 @@ describe('VideoDeletionModule', () => { let mockLogger; let mockFilesystem; let m3uGenerator; + let playlistOutputRefresh; + let mockCheckLibraryRoot; beforeEach(() => { jest.resetModules(); @@ -34,13 +54,23 @@ describe('VideoDeletionModule', () => { mockLogger = require('../../logger'); m3uGenerator = require('../m3uGenerator'); + playlistOutputRefresh = require('../playlistOutputRefresh'); // Mock the Video model mockVideo = { findByPk: jest.fn(), - findOne: jest.fn() + findOne: jest.fn(), + count: jest.fn() }; + // The downloads folder looks mounted unless a test says otherwise. + mockCheckLibraryRoot = jest.fn().mockResolvedValue({ ok: true }); + jest.doMock('../libraryRootCheck', () => ({ + checkLibraryRoot: mockCheckLibraryRoot, + isUnderLibraryCheckPath, + libraryCheckCoversRows + })); + // Mock fs.promises (only the methods videoDeletionModule still calls directly, // i.e. flat-mode readdir/unlink). The nested-mode rm path now goes through // filesystem.removeDirectoryResilient and is mocked there. @@ -101,10 +131,11 @@ describe('VideoDeletionModule', () => { expect(mockFilesystem.removeDirectoryResilient).toHaveBeenCalledWith( '/test/output/Channel Name/Channel Name - Video Title - abc123' ); - expect(mockVideoRecord.update).toHaveBeenCalledWith({ removed: true }); + expect(mockVideoRecord.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); expect(result).toEqual({ success: true, videoId: 1, + youtubeId: 'abc123', message: 'Video deleted successfully' }); expect(mockLogger.info).toHaveBeenCalledWith( @@ -113,6 +144,26 @@ describe('VideoDeletionModule', () => { ); }); + test('records the removal reason and detail it is given', async () => { + const mockVideoRecord = { + id: 1, + youtubeId: 'abc123', + filePath: '/test/output/Channel Name/Channel Name - Video Title - abc123/video.mp4', + removed: false, + update: jest.fn().mockResolvedValue() + }; + mockVideo.findByPk.mockResolvedValue(mockVideoRecord); + + await VideoDeletionModule.deleteVideoById(1, { reason: 'auto_removal', detail: 'older than 30 days' }); + + expect(mockVideoRecord.update).toHaveBeenCalledWith(expect.objectContaining({ + removed: true, + removed_reason: 'auto_removal', + removed_at: expect.any(Date), + removed_detail: 'older than 30 days' + })); + }); + test('should return error when video not found', async () => { mockVideo.findByPk.mockResolvedValue(null); @@ -159,11 +210,12 @@ describe('VideoDeletionModule', () => { const result = await VideoDeletionModule.deleteVideoById(1); - expect(mockVideoRecord.update).toHaveBeenCalledWith({ removed: true }); + expect(mockVideoRecord.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); expect(mockFilesystem.removeDirectoryResilient).not.toHaveBeenCalled(); expect(result).toEqual({ success: true, videoId: 1, + youtubeId: 'abc123', message: 'Video marked as removed (no file path)' }); }); @@ -273,7 +325,7 @@ describe('VideoDeletionModule', () => { const result = await VideoDeletionModule.deleteVideoById(1); expect(result.success).toBe(true); - expect(record.update).toHaveBeenCalledWith({ removed: true }); + expect(record.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); }); }); @@ -336,10 +388,11 @@ describe('VideoDeletionModule', () => { expect(mockFilesystem.removeDirectoryResilient).toHaveBeenCalledWith( '/test/output/Channel/Channel - Video - abc123' ); - expect(mockVideoRecord.update).toHaveBeenCalledWith({ removed: true }); + expect(mockVideoRecord.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); expect(result).toEqual({ success: true, videoId: 1, + youtubeId: 'abc123', message: 'Video deleted successfully' }); }); @@ -739,6 +792,50 @@ describe('VideoDeletionModule', () => { }); }); + describe('while the downloads folder looks unmounted', () => { + const emptyRoot = { ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder is empty.' }; + const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/test/output/__Music', message: 'The subfolder __Music is empty.' }; + const video = (id, youtubeId, filePath) => ({ id, youtubeId, filePath, removed: false, update: jest.fn().mockResolvedValue() }); + + test('refuses every video with the check message and touches nothing', async () => { + mockCheckLibraryRoot.mockResolvedValue(emptyRoot); + const mockVideo1 = video(1, 'abc123', '/test/output/Channel/Channel - Video1 - abc123/video.mp4'); + const mockVideo2 = video(2, 'def456', '/test/output/Channel/Channel - Video2 - def456/video.mp4'); + mockVideo.findByPk.mockResolvedValueOnce(mockVideo1).mockResolvedValueOnce(mockVideo2); + + const result = await VideoDeletionModule.deleteVideos([1, 2]); + + const error = 'The downloads folder is empty. Nothing was deleted; check that the drive is mounted.'; + expect(result).toEqual({ success: false, deleted: [], failed: [{ videoId: 1, error }, { videoId: 2, error }] }); + expect(mockVideo1.update).not.toHaveBeenCalled(); + expect(mockFilesystem.removeDirectoryResilient).not.toHaveBeenCalled(); + }); + + test('refuses only the videos under an empty subfolder', async () => { + mockCheckLibraryRoot.mockResolvedValue(emptySubfolder); + const inMusic = video(1, 'abc123', '/test/output/__Music/Channel/Channel - Video1 - abc123/video.mp4'); + const inRoot = video(2, 'def456', '/test/output/Channel/Channel - Video2 - def456/video.mp4'); + mockVideo.findByPk.mockResolvedValueOnce(inMusic).mockResolvedValueOnce(inRoot); + + const result = await VideoDeletionModule.deleteVideos([1, 2]); + + expect(result.deleted).toEqual([2]); + expect(result.failed).toEqual([{ videoId: 1, error: 'The subfolder __Music is empty. Nothing was deleted; check that the drive is mounted.' }]); + expect(inMusic.update).not.toHaveBeenCalled(); + expect(inRoot.update).toHaveBeenCalledWith(USER_DELETED_COLUMNS); + }); + + test('checks the downloads folder once per batch', async () => { + mockVideo.findByPk + .mockResolvedValueOnce(video(1, 'abc123', '/test/output/Channel/Channel - Video1 - abc123/video.mp4')) + .mockResolvedValueOnce(video(2, 'def456', '/test/output/Channel/Channel - Video2 - def456/video.mp4')); + + await VideoDeletionModule.deleteVideos([1, 2]); + + expect(mockCheckLibraryRoot).toHaveBeenCalledTimes(1); + }); + }); + test('should handle all videos failing', async () => { mockVideo.findByPk.mockResolvedValue(null); @@ -1013,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; @@ -1393,9 +1534,57 @@ describe('VideoDeletionModule', () => { jest.resetModules(); mockLogger = require('../../logger'); m3uGenerator = require('../m3uGenerator'); + playlistOutputRefresh = require('../playlistOutputRefresh'); VideoDeletionModule = require('../videoDeletionModule'); }); + describe('while the downloads folder looks unmounted', () => { + const emptyRoot = { ok: false, reason: 'empty', path: '/test', message: 'The downloads folder is empty.' }; + + beforeEach(() => { + mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: true, autoRemovalVideoAgeThreshold: '30' }); + mockCheckLibraryRoot.mockResolvedValue(emptyRoot); + mockSequelize.query.mockResolvedValue([]); + }); + + test('skips the run with the check message while downloaded videos are recorded', async () => { + mockVideo.count.mockResolvedValue(12); + + const result = await VideoDeletionModule.performAutomaticCleanup(); + + expect(result.skipped).toEqual({ reason: 'empty', message: 'The downloads folder is empty.' }); + expect(result.totalDeleted).toBe(0); + expect(mockAutoRemovalQueries.getChannelKeepRecentIds).not.toHaveBeenCalled(); + expect(mockSequelize.query).not.toHaveBeenCalled(); + }); + + test('runs normally when nothing downloaded is recorded, as on a fresh install', async () => { + mockVideo.count.mockResolvedValue(0); + + const result = await VideoDeletionModule.performAutomaticCleanup(); + + expect(result.skipped).toBeUndefined(); + expect(mockAutoRemovalQueries.getChannelKeepRecentIds).toHaveBeenCalled(); + }); + + test('runs normally when only an empty subfolder no video is stored in fails the check', async () => { + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'subfolder-empty', path: '/test/__Kids', message: 'The subfolder __Kids is empty.' }); + mockVideo.count.mockImplementation(countNoneUnderSubfolder(12)); + + const result = await VideoDeletionModule.performAutomaticCleanup(); + + expect(result.skipped).toBeUndefined(); + expect(mockAutoRemovalQueries.getChannelKeepRecentIds).toHaveBeenCalled(); + }); + + test('does not consult the check for a dry run', async () => { + const result = await VideoDeletionModule.performAutomaticCleanup({ dryRun: true }); + + expect(mockCheckLibraryRoot).not.toHaveBeenCalled(); + expect(result.skipped).toBeUndefined(); + }); + }); + test('should skip cleanup when auto-removal is disabled', async () => { mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: false, @@ -1502,6 +1691,11 @@ describe('VideoDeletionModule', () => { expect(result.freedBytes).toBeGreaterThan(0); expect(result.plan.ageStrategy.deletedCount).toBe(1); expect(mockFilesystem.removeDirectoryResilient).toHaveBeenCalled(); + expect(mockVideoRecord.update).toHaveBeenCalledWith(expect.objectContaining({ + removed: true, + removed_reason: 'auto_removal', + removed_detail: 'older than 30 days' + })); }); test('should perform space-based cleanup in dry-run mode', async () => { @@ -1580,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 () => { @@ -2117,6 +2313,26 @@ describe('VideoDeletionModule', () => { expect(result.deletedByUsage).toBe(2); }); + test('records the usage limit as the reason on each deleted video', async () => { + const records = {}; + mockVideo.findByPk.mockImplementation((id) => { + records[id] = deletableRecord(id); + return Promise.resolve(records[id]); + }); + mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: true, autoRemovalUsageLimit: '10GB' }); + mockStorageUsage.getDownloadedBytes.mockResolvedValue(11 * GB); + mockSequelize.query.mockResolvedValueOnce([ + { id: 1, youtubeId: 'yt1', fileSize: String(2 * GB) } + ]); + + await VideoDeletionModule.performAutomaticCleanup(); + + expect(records[1].update).toHaveBeenCalledWith(expect.objectContaining({ + removed_reason: 'auto_removal', + removed_detail: 'over the 10GB total size limit' + })); + }); + test('does nothing while usage is within the limit', async () => { mockConfigModule.getConfig.mockReturnValue({ autoRemovalEnabled: true, autoRemovalUsageLimit: '10GB' }); mockStorageUsage.getDownloadedBytes.mockResolvedValue(10 * GB); @@ -2341,6 +2557,42 @@ describe('VideoDeletionModule', () => { expect(result).toEqual({ removed: [], errors: [] }); }); + test('skips the sweep while the downloads folder looks unmounted and videos are recorded', async () => { + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder is empty.' }); + mockVideo.count.mockResolvedValue(3); + mockFilesystem.listSubdirectories.mockResolvedValue(['/test/output/__Music']); + + const result = await VideoDeletionModule.cleanupOrphanDirectories(); + + expect(result).toEqual({ removed: [], errors: [], skipped: { reason: 'empty', message: 'The downloads folder is empty.' } }); + expect(mockFilesystem.listSubdirectories).not.toHaveBeenCalled(); + expect(mockFilesystem.cleanupEmptyParents).not.toHaveBeenCalled(); + }); + + test('sweeps normally when the folder looks unmounted but nothing downloaded is recorded', async () => { + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'empty', path: '/test/output', message: 'The downloads folder is empty.' }); + mockVideo.count.mockResolvedValue(0); + mockFilesystem.listSubdirectories.mockResolvedValue([]); + + const result = await VideoDeletionModule.cleanupOrphanDirectories(); + + expect(result).toEqual({ removed: [], errors: [] }); + expect(mockFilesystem.listSubdirectories).toHaveBeenCalledWith('/test/output'); + }); + + test('sweeps an empty subfolder that no recorded video is stored in, so the check passes again', async () => { + mockCheckLibraryRoot.mockResolvedValue({ ok: false, reason: 'subfolder-empty', path: '/test/output/__Kids', message: 'The subfolder __Kids is empty.' }); + mockVideo.count.mockImplementation(countNoneUnderSubfolder(3)); + mockFilesystem.listSubdirectories + .mockResolvedValueOnce(['/test/output/__Kids']) + .mockResolvedValueOnce([]); + + const result = await VideoDeletionModule.cleanupOrphanDirectories(); + + expect(result.skipped).toBeUndefined(); + expect(mockFilesystem.cleanupEmptyParents).toHaveBeenCalledWith('/test/output/__Kids', '/test/output'); + }); + test('should clean up root-level empty channel directories', async () => { mockFilesystem.listSubdirectories.mockResolvedValue([ '/test/output/Empty Channel', diff --git a/server/modules/__tests__/videoLocalStatus.test.js b/server/modules/__tests__/videoLocalStatus.test.js index ef67cb9fe..0c873c5c3 100644 --- a/server/modules/__tests__/videoLocalStatus.test.js +++ b/server/modules/__tests__/videoLocalStatus.test.js @@ -1,8 +1,8 @@ /* eslint-env jest */ -jest.mock('../../models', () => ({ Video: { findAll: jest.fn() } })); +jest.mock('../../models', () => ({ Video: { findAll: jest.fn() }, Channel: { findAll: jest.fn() } })); jest.mock('../../models/channelvideo', () => ({ findAll: jest.fn() })); jest.mock('../archiveModule', () => ({ filterArchivedVideoIds: jest.fn() })); -const { Video } = require('../../models'); +const { Video, Channel } = require('../../models'); const ChannelVideo = require('../../models/channelvideo'); const archiveModule = require('../archiveModule'); const { applyLocalVideoStatus } = require('../videoLocalStatus'); @@ -12,6 +12,7 @@ const pending = (...youtubeIds) => youtubeIds.map(youtubeId => ({ youtubeId, sta beforeEach(() => { jest.clearAllMocks(); Video.findAll.mockResolvedValue([]); + Channel.findAll.mockResolvedValue([]); ChannelVideo.findAll.mockResolvedValue([]); archiveModule.filterArchivedVideoIds.mockReturnValue(new Set()); }); @@ -26,17 +27,33 @@ it('merges downloaded and removed records while leaving unknown videos alone', a { id: 1, youtubeId: 'aaaaaaaaaaa', removed: false, filePath: '/video.mp4', fileSize: 123, audioFilePath: '/audio.mp3', audioFileSize: 45, last_downloaded_at: '2026-09-01T00:00:00Z', protected: true, normalized_rating: 'PG', rating_source: 'manual' }, - { id: 2, youtubeId: 'bbbbbbbbbbb', removed: true, last_downloaded_at: null, protected: false }, + { id: 2, youtubeId: 'bbbbbbbbbbb', removed: true, removed_reason: 'not_found', removed_at: '2026-10-03T00:00:00Z', + removed_detail: null, last_downloaded_at: null, protected: false }, ]); const results = pending('aaaaaaaaaaa', 'bbbbbbbbbbb', 'ccccccccccc'); await applyLocalVideoStatus(results); expect(results[0]).toMatchObject({ status: 'downloaded', databaseId: 1, filePath: '/video.mp4', fileSize: 123, audioFilePath: '/audio.mp3', audioFileSize: 45, addedAt: '2026-09-01T00:00:00.000Z', - isProtected: true, normalizedRating: 'PG', ratingSource: 'manual' }); - expect(results[1]).toMatchObject({ status: 'missing', databaseId: 2, addedAt: null, isProtected: false }); + isProtected: true, normalizedRating: 'PG', ratingSource: 'manual', removedReason: null, removedAt: null, removedDetail: null }); + expect(results[1]).toMatchObject({ status: 'missing', databaseId: 2, addedAt: null, isProtected: false, + removedReason: 'not_found', removedAt: '2026-10-03T00:00:00.000Z', removedDetail: null }); expect(results[2]).toEqual({ youtubeId: 'ccccccccccc', status: 'never_downloaded', inArchive: false }); }); +it('reports a video Youtarr removed as deleted, with why', async () => { + Video.findAll.mockResolvedValue([ + { id: 3, youtubeId: 'ddddddddddd', removed: true, removed_reason: 'auto_removal', + removed_at: new Date('2026-10-03T00:00:00Z'), removed_detail: 'older than 30 days' }, + { id: 4, youtubeId: 'eeeeeeeeeee', removed: true, removed_reason: null, removed_at: null, removed_detail: null }, + ]); + const results = pending('ddddddddddd', 'eeeeeeeeeee'); + await applyLocalVideoStatus(results); + expect(results[0]).toMatchObject({ status: 'deleted', removedReason: 'auto_removal', + removedAt: '2026-10-03T00:00:00.000Z', removedDetail: 'older than 30 days' }); + // Removed before the reason was recorded: deleted, with nothing to explain it. + expect(results[1]).toMatchObject({ status: 'deleted', removedReason: null, removedAt: null, removedDetail: null }); +}); + describe('inArchive', () => { // The mock answers the way the real lookup does: only for the ids it is asked about. const archiveContains = (...archivedIds) => { @@ -88,3 +105,30 @@ describe('inArchive', () => { expect(ChannelVideo.findAll).not.toHaveBeenCalled(); }); }); + +describe('channelSubscribed', () => { + const withChannel = (youtubeId, channelId) => ({ youtubeId, channelId, status: 'never_downloaded' }); + + it('is true for results whose channel is an enabled subscription and false otherwise', async () => { + Channel.findAll.mockResolvedValue([{ channel_id: 'UCsubscribed' }]); + const results = [withChannel('aaaaaaaaaaa', 'UCsubscribed'), withChannel('bbbbbbbbbbb', 'UCother')]; + await applyLocalVideoStatus(results); + expect(results.map(r => r.channelSubscribed)).toEqual([true, false]); + }); + + it('asks only for enabled channels among the distinct channel ids in the results', async () => { + const results = [withChannel('aaaaaaaaaaa', 'UCone'), withChannel('bbbbbbbbbbb', 'UCone'), withChannel('ccccccccccc', null)]; + await applyLocalVideoStatus(results); + expect(Channel.findAll).toHaveBeenCalledWith({ + where: { channel_id: ['UCone'], enabled: true }, + attributes: ['channel_id'], + }); + }); + + it('leaves the flag unset and skips the lookup when no result carries a channel id', async () => { + const results = pending('aaaaaaaaaaa'); + await applyLocalVideoStatus(results); + expect(Channel.findAll).not.toHaveBeenCalled(); + expect(results[0]).not.toHaveProperty('channelSubscribed'); + }); +}); diff --git a/server/modules/__tests__/videoPersistence.test.js b/server/modules/__tests__/videoPersistence.test.js index 0a2422def..46e63e17b 100644 --- a/server/modules/__tests__/videoPersistence.test.js +++ b/server/modules/__tests__/videoPersistence.test.js @@ -52,10 +52,29 @@ describe('videoPersistence', () => { fileSize: '1000', }, true); - expect(data.removed).toBe(false); + expect(data).toEqual(expect.objectContaining({ + removed: false, removed_reason: null, removed_at: null, removed_detail: null, + })); expect(data.last_downloaded_at).toBeInstanceOf(Date); }); + test('drops every removal column on update when nothing is verified', () => { + const data = videoPersistence.prepareVideoDataForSave({ + youtubeId: 'abc123', + filePath: null, + fileSize: null, + removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null, + }, false); + + expect(data).not.toHaveProperty('removed'); + expect(data).not.toHaveProperty('removed_reason'); + expect(data).not.toHaveProperty('removed_at'); + expect(data).not.toHaveProperty('removed_detail'); + }); + test('does not set last_downloaded_at when no file is verified', () => { const data = videoPersistence.prepareVideoDataForSave({ youtubeId: 'abc123', diff --git a/server/modules/__tests__/videoRemovalState.test.js b/server/modules/__tests__/videoRemovalState.test.js new file mode 100644 index 000000000..f49b7b3c5 --- /dev/null +++ b/server/modules/__tests__/videoRemovalState.test.js @@ -0,0 +1,100 @@ +/* eslint-env jest */ +const { Op } = require('sequelize'); +const { + REMOVAL_REASONS, + REMOVAL_STATES, + REMOVAL_COLUMNS, + removalState, + removedColumns, + presentColumns, + stateWhere, + notStateWhere, +} = require('../videoRemovalState'); + +describe('videoRemovalState', () => { + describe('removalState', () => { + test('is present for a row that is not removed', () => { + expect(removalState({ removed: 0 })).toBe(REMOVAL_STATES.PRESENT); + expect(removalState({ removed: false, removed_reason: 'not_found' })).toBe(REMOVAL_STATES.PRESENT); + }); + + test('is missing for a removed row whose file was not found', () => { + expect(removalState({ removed: 1, removed_reason: 'not_found' })).toBe(REMOVAL_STATES.MISSING); + }); + + test('is deleted for a removed row with any other reason', () => { + expect(removalState({ removed: true, removed_reason: 'user_deleted' })).toBe(REMOVAL_STATES.DELETED); + expect(removalState({ removed: true, removed_reason: 'auto_removal' })).toBe(REMOVAL_STATES.DELETED); + expect(removalState({ removed: true, removed_reason: 'marked_deleted' })).toBe(REMOVAL_STATES.DELETED); + }); + + test('treats a removed row without a recorded reason as deleted', () => { + expect(removalState({ removed: 1, removed_reason: null })).toBe(REMOVAL_STATES.DELETED); + expect(removalState({ removed: 1 })).toBe(REMOVAL_STATES.DELETED); + }); + }); + + describe('removedColumns', () => { + test('marks the row removed with the reason and a timestamp', () => { + const columns = removedColumns(REMOVAL_REASONS.NOT_FOUND); + expect(columns).toEqual({ + removed: true, + removed_reason: 'not_found', + removed_at: expect.any(Date), + removed_detail: null, + }); + }); + + test('records the detail and timestamp it is given', () => { + const at = new Date('2026-10-03T12:00:00Z'); + expect(removedColumns(REMOVAL_REASONS.AUTO_REMOVAL, { detail: 'older than 30 days', at })).toEqual({ + removed: true, + removed_reason: 'auto_removal', + removed_at: at, + removed_detail: 'older than 30 days', + }); + }); + + test('rejects an unknown reason', () => { + expect(() => removedColumns('bogus')).toThrow('Unknown removal reason: bogus'); + }); + }); + + test('presentColumns clears the removed flag and every reason column', () => { + expect(presentColumns()).toEqual({ + removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null, + }); + expect(REMOVAL_COLUMNS).toEqual(['removed', 'removed_reason', 'removed_at', 'removed_detail']); + }); + + describe('stateWhere / notStateWhere', () => { + test('missing is removed with the not_found reason', () => { + expect(stateWhere('missing')).toEqual({ removed: true, removed_reason: 'not_found' }); + expect(notStateWhere('missing')).toEqual({ + [Op.or]: [ + { removed: false }, + { removed_reason: null }, + { removed_reason: { [Op.ne]: 'not_found' } }, + ], + }); + }); + + test('deleted is removed with any other reason, including none', () => { + expect(stateWhere('deleted')).toEqual({ + removed: true, + [Op.or]: [{ removed_reason: null }, { removed_reason: { [Op.ne]: 'not_found' } }], + }); + expect(notStateWhere('deleted')).toEqual({ + [Op.or]: [{ removed: false }, { removed_reason: 'not_found' }], + }); + }); + + test('rejects a state that is not deleted or missing', () => { + expect(() => stateWhere('present')).toThrow('Unknown removal state: present'); + expect(() => notStateWhere('bogus')).toThrow('Unknown removal state: bogus'); + }); + }); +}); diff --git a/server/modules/__tests__/videoSearchModule.test.js b/server/modules/__tests__/videoSearchModule.test.js index 1c8650f2e..e855ca9db 100644 --- a/server/modules/__tests__/videoSearchModule.test.js +++ b/server/modules/__tests__/videoSearchModule.test.js @@ -4,6 +4,7 @@ jest.mock('../download/ytdlpCommandBuilder', () => ({ })); jest.mock('../../models', () => ({ Video: { findAll: jest.fn().mockResolvedValue([]) }, + Channel: { findAll: jest.fn().mockResolvedValue([]) }, })); jest.mock('../../models/channelvideo', () => ({ findAll: jest.fn().mockResolvedValue([]) })); jest.mock('../archiveModule', () => ({ filterArchivedVideoIds: jest.fn(() => new Set()) })); @@ -106,7 +107,7 @@ describe('videoSearchModule', () => { test('marks results as downloaded, missing, or never_downloaded based on DB state', async () => { Video.findAll.mockResolvedValueOnce([ { youtubeId: 'aaaaaaaaaaa', removed: false }, - { youtubeId: 'bbbbbbbbbbb', removed: true }, + { youtubeId: 'bbbbbbbbbbb', removed: true, removed_reason: 'not_found' }, ]); const ndjson = @@ -244,8 +245,8 @@ describe('videoSearchModule', () => { }); }); - describe('sorting', () => { - test('sorts results newest-to-oldest by publishedAt, with nulls last', async () => { + describe('ordering', () => { + test('keeps results in the order yt-dlp returned them (YouTube relevance), whatever their dates', async () => { const ndjson = JSON.stringify({ id: 'old', title: 'Old', timestamp: 1_600_000_000 }) + '\n' + JSON.stringify({ id: 'nodate', title: 'NoDate' }) + '\n' + @@ -254,7 +255,21 @@ describe('videoSearchModule', () => { ytDlpRunner.run.mockResolvedValueOnce(ndjson); const results = await videoSearchModule.searchVideos('test', 10, {}); - expect(results.map(r => r.youtubeId)).toEqual(['new', 'mid', 'old', 'nodate']); + expect(results.map(r => r.youtubeId)).toEqual(['old', 'nodate', 'new', 'mid']); + }); + }); + + describe('live streams', () => { + test('drops live and upcoming streams but keeps a video that merely lacks a duration', async () => { + const ndjson = + JSON.stringify({ id: 'live0000000', title: 'Radio', live_status: 'is_live', duration: null }) + '\n' + + JSON.stringify({ id: 'soon0000000', title: 'Premiere', live_status: 'is_upcoming', duration: null }) + '\n' + + JSON.stringify({ id: 'nodur000000', title: 'No duration', live_status: null, duration: null }) + '\n' + + JSON.stringify({ id: 'vod00000000', title: 'VOD', live_status: 'was_live', duration: 3600 }) + '\n'; + ytDlpRunner.run.mockResolvedValueOnce(ndjson); + + const results = await videoSearchModule.searchVideos('radio', 10, {}); + expect(results.map(r => r.youtubeId)).toEqual(['nodur000000', 'vod00000000']); }); }); }); @@ -285,6 +300,7 @@ describe('videoSearchModule.searchVideos - API-first path', () => { jest.doMock('../configModule', () => ({ getCookiesPath: jest.fn(() => null) })); jest.doMock('../../models', () => ({ Video: { findAll: jest.fn().mockResolvedValue([]) }, + Channel: { findAll: jest.fn().mockResolvedValue([]) }, })); jest.doMock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn(), @@ -294,6 +310,20 @@ describe('videoSearchModule.searchVideos - API-first path', () => { videoSearchModule = require('../videoSearchModule'); }); + test('keeps the API results in the order YouTube ranked them', async () => { + const apiResult = (youtubeId, publishedAt) => ({ + youtubeId, title: youtubeId, channelName: 'C', channelId: null, duration: 60, + thumbnailUrl: null, publishedAt, viewCount: null, status: 'never_downloaded', + }); + youtubeApi.client.searchVideos.mockResolvedValueOnce([ + apiResult('older000000', '2020-01-01T00:00:00.000Z'), + apiResult('newer000000', '2025-01-01T00:00:00.000Z'), + ]); + + const results = await videoSearchModule.searchVideos('test', 10); + expect(results.map(r => r.youtubeId)).toEqual(['older000000', 'newer000000']); + }); + test('uses API when available; yt-dlp is not called', async () => { const ytDlpRunner = require('../ytDlpRunner'); youtubeApi.client.searchVideos.mockResolvedValueOnce([ diff --git a/server/modules/__tests__/videoTrashModule.test.js b/server/modules/__tests__/videoTrashModule.test.js new file mode 100644 index 000000000..a3c0160a3 --- /dev/null +++ b/server/modules/__tests__/videoTrashModule.test.js @@ -0,0 +1,581 @@ +/* eslint-env jest */ +const { Op } = require('sequelize'); + +// `mock` prefix: jest hoists the factory above this declaration and only lets it see mock* variables. +const mockModels = { + Video: { count: jest.fn(), update: jest.fn(), findAll: jest.fn(), destroy: jest.fn() }, + JobVideo: { findAll: jest.fn(), destroy: jest.fn() }, + VideoWatchStatus: { destroy: jest.fn() }, + PlaylistVideo: { update: jest.fn() }, +}; + +jest.mock('../../models', () => mockModels); +jest.mock('../../db.js', () => ({ + sequelize: { transaction: jest.fn(async (fn) => fn('tx')) }, +})); +jest.mock('../libraryRootCheck', () => ({ ...jest.requireActual('../libraryRootCheck'), checkLibraryRoot: jest.fn() })); +jest.mock('../configModule', () => ({ + directoryPath: '/data/youtube', + getJobsPath: () => '/app/jobs', + getImagePath: () => '/app/images', +})); +jest.mock('../messageEmitter', () => ({ emitMessage: jest.fn() })); +jest.mock('../archiveModule', () => ({ removeVideosFromArchive: jest.fn() })); +jest.mock('fs', () => ({ promises: { unlink: jest.fn() } })); +jest.mock('../../logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() })); + +const { Video, JobVideo, VideoWatchStatus, PlaylistVideo } = require('../../models'); +const { sequelize } = require('../../db.js'); +const { checkLibraryRoot } = require('../libraryRootCheck'); +const messageEmitter = require('../messageEmitter'); +const archiveModule = require('../archiveModule'); +const archiveAccess = require('../download/archiveAccess'); +const fs = require('fs'); +const { stateWhere } = require('../videoRemovalState'); +const videoTrashModule = require('../videoTrashModule'); +const { LibraryUnavailableError, PurgeRunningError } = videoTrashModule; + +const LIBRARY_OK = { ok: true }; +const LIBRARY_EMPTY = { ok: false, reason: 'empty', path: '/data/youtube', message: 'The downloads folder is empty.' }; +const LIBRARY_MISSING = { ok: false, reason: 'missing', path: '/data/youtube', message: 'The downloads folder does not exist.' }; +const SUBFOLDER_EMPTY = { ok: false, reason: 'subfolder-empty', path: '/data/youtube/__Kids', message: 'The subfolder __Kids is empty.' }; +// A Video.count for a library of `total` videos with none stored under a +// failing subfolder: a count scoped to the subfolder (an Op.and) finds nothing. +const countNoneUnderSubfolder = (total) => async ({ where }) => (where[Op.and] ? 0 : total); +// A Video.count for a failing subfolder that holds present videos but no +// missing ones: a subfolder-scoped count of missing rows finds nothing. +const countOnlyPresentUnderSubfolder = (total) => async ({ where }) => { + if (!where[Op.and]) return total; + const [filter] = where[Op.and]; + return filter.removed_reason === 'not_found' ? 0 : 2; +}; + +beforeEach(() => { + jest.clearAllMocks(); + checkLibraryRoot.mockResolvedValue(LIBRARY_OK); + Video.update.mockResolvedValue([0]); +}); + +describe('getRemovedSummary', () => { + test('counts deleted, missing, and legacy rows and reports the library check', async () => { + Video.count + .mockResolvedValueOnce(12) // deleted + .mockResolvedValueOnce(4) // missing + .mockResolvedValueOnce(3); // legacy + + const summary = await videoTrashModule.getRemovedSummary(); + + expect(summary).toEqual({ + deleted: 12, + missing: 4, + legacy: 3, + libraryCheck: { ok: true, reason: null, path: null, message: null }, + missingActionsBlocked: false, + downloadsActive: false, + }); + expect(Video.count).toHaveBeenNthCalledWith(1, { where: stateWhere('deleted') }); + expect(Video.count).toHaveBeenNthCalledWith(2, { where: stateWhere('missing') }); + expect(Video.count).toHaveBeenNthCalledWith(3, { where: { removed: true, removed_reason: null } }); + }); + + test('says when a download is active, so the archive choice can be disabled up front', async () => { + Video.count.mockResolvedValue(0); + + await archiveAccess.withDownload(async () => { + const summary = await videoTrashModule.getRemovedSummary(); + expect(summary.downloadsActive).toBe(true); + }); + }); + + test('passes a failed library check through so the page can disable the missing actions', async () => { + Video.count.mockResolvedValue(2); + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + const summary = await videoTrashModule.getRemovedSummary(); + + expect(summary.libraryCheck).toEqual({ ok: false, reason: 'empty', path: '/data/youtube', message: LIBRARY_EMPTY.message }); + expect(summary.missingActionsBlocked).toBe(true); + expect(checkLibraryRoot).toHaveBeenCalledWith('/data/youtube'); + }); + + // The warning still shows (the Videos page banner sends people to Rescan + // anyway), but marking and emptying missing videos stay open. + test('warns about a failing subfolder that holds only present videos without blocking the missing actions', async () => { + Video.count.mockImplementation(countOnlyPresentUnderSubfolder(5)); + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + + const summary = await videoTrashModule.getRemovedSummary(); + + expect(summary.libraryCheck.ok).toBe(false); + expect(summary.missingActionsBlocked).toBe(false); + }); + + test('reports the folder as fine when the failing subfolder holds no present or missing video', async () => { + Video.count.mockImplementation(countNoneUnderSubfolder(5)); + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + + const summary = await videoTrashModule.getRemovedSummary(); + + expect(summary.libraryCheck).toEqual({ ok: true, reason: null, path: null, message: null }); + }); +}); + +describe('markMissingAsDeleted', () => { + beforeEach(() => { + Video.count.mockReset().mockResolvedValue(3); // videos on record, so a failing check covers some + }); + + test('marks only the selected rows that are missing, leaving present and deleted ones alone', async () => { + Video.update.mockResolvedValue([2]); + + const result = await videoTrashModule.markMissingAsDeleted({ videoIds: [1, 2, 3] }); + + expect(result).toEqual({ updated: 2 }); + expect(Video.update).toHaveBeenCalledWith( + expect.objectContaining({ removed: true, removed_reason: 'marked_deleted', removed_at: expect.any(Date), removed_detail: null }), + { where: { removed: true, removed_reason: 'not_found', id: [1, 2, 3] } }, + ); + }); + + test('marks every missing row when asked for all', async () => { + Video.update.mockResolvedValue([40]); + + const result = await videoTrashModule.markMissingAsDeleted({ all: true }); + + expect(result).toEqual({ updated: 40 }); + expect(Video.update).toHaveBeenCalledWith( + expect.objectContaining({ removed_reason: 'marked_deleted' }), + { where: { removed: true, removed_reason: 'not_found' } }, + ); + }); + + test('refuses to mark all while the downloads folder looks unmounted', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true })).rejects.toBeInstanceOf(LibraryUnavailableError); + await expect(videoTrashModule.markMissingAsDeleted({ all: true })).rejects.toMatchObject({ + code: 'LIBRARY_UNAVAILABLE', + message: LIBRARY_EMPTY.message, + libraryCheck: LIBRARY_EMPTY, + }); + expect(Video.update).not.toHaveBeenCalled(); + }); + + test('marks all while only an empty subfolder no video is stored in fails the check', async () => { + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + Video.count.mockImplementation(countNoneUnderSubfolder(3)); + Video.update.mockResolvedValue([2]); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true })).resolves.toEqual({ updated: 2 }); + }); + + test('marks all without asking while the failing subfolder holds present videos but no missing ones', async () => { + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + Video.count.mockImplementation(countOnlyPresentUnderSubfolder(3)); + Video.update.mockResolvedValue([2]); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true })).resolves.toEqual({ updated: 2 }); + }); + + test('marks all anyway when asked to, for a folder the user emptied on purpose', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + Video.update.mockResolvedValue([5]); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true, ignoreLibraryCheck: true })).resolves.toEqual({ updated: 5 }); + }); + + test('still refuses to mark all when the folder is gone, even when asked to ignore the check', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_MISSING); + + await expect(videoTrashModule.markMissingAsDeleted({ all: true, ignoreLibraryCheck: true })) + .rejects.toBeInstanceOf(LibraryUnavailableError); + expect(Video.update).not.toHaveBeenCalled(); + }); + + test('still marks a chosen selection while the downloads folder looks unmounted', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + Video.update.mockResolvedValue([1]); + + await expect(videoTrashModule.markMissingAsDeleted({ videoIds: [7] })).resolves.toEqual({ updated: 1 }); + }); + + test('tells listing pages to refresh when rows changed, and stays quiet when none did', async () => { + Video.update.mockResolvedValueOnce([3]).mockResolvedValueOnce([0]); + + await videoTrashModule.markMissingAsDeleted({ videoIds: [1, 2, 3] }); + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'videosUpdated', { source: 'mark-deleted', updated: 3 }); + + messageEmitter.emitMessage.mockClear(); + await videoTrashModule.markMissingAsDeleted({ videoIds: [9] }); + expect(messageEmitter.emitMessage).not.toHaveBeenCalled(); + }); + + test('exposes the Sequelize operators it builds its where with', () => { + // The "deleted" count must include rows without a recorded reason. + expect(stateWhere('deleted')[Op.or]).toEqual(expect.arrayContaining([{ removed_reason: null }])); + }); +}); + +describe('purgeRemoved', () => { + const { Op } = require('sequelize'); + const rows = [ + { id: 1, youtubeId: 'aaaaaaaaaaa' }, + { id: 2, youtubeId: 'bbbbbbbbbbb' }, + ]; + const enoent = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + + beforeEach(() => { + // mockReset: a queued chunk left over by a test that never reached findAll must not leak. + // First call: the chunk's candidates; second: the same rows re-read under lock; then empty. + Video.findAll.mockReset(); + Video.findAll.mockResolvedValueOnce(rows).mockResolvedValueOnce(rows).mockResolvedValue([]); + sequelize.transaction.mockReset().mockImplementation(async (fn) => fn('tx')); + JobVideo.findAll.mockResolvedValue([{ job_id: 'job-1' }, { job_id: 'job-2' }, { job_id: 'job-1' }]); + VideoWatchStatus.destroy.mockResolvedValue(2); + JobVideo.destroy.mockResolvedValue(3); + PlaylistVideo.update.mockResolvedValue([1]); + Video.destroy.mockResolvedValue(2); + fs.promises.unlink.mockResolvedValue(undefined); + archiveModule.removeVideosFromArchive.mockResolvedValue(2); + Video.count.mockReset().mockResolvedValue(3); // videos on record, so a failing check covers some + }); + + test('selects deleted rows only, including ones removed before the reason was recorded', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + const where = Video.findAll.mock.calls[0][0].where; + expect(where[Op.or]).toEqual([stateWhere('deleted')]); + expect(Video.findAll.mock.calls[0][0]).toEqual(expect.objectContaining({ attributes: ['id', 'youtubeId'], limit: 500 })); + }); + + test('selects both states when asked', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted', 'missing'], removeFromArchive: false }); + + expect(Video.findAll.mock.calls[0][0].where[Op.or]).toEqual([stateWhere('deleted'), stateWhere('missing')]); + }); + + test('forgets every trace of the rows, in an order the foreign keys allow', async () => { + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + expect(sequelize.transaction).toHaveBeenCalledTimes(1); + expect(VideoWatchStatus.destroy).toHaveBeenCalledWith({ where: { video_id: [1, 2] }, transaction: 'tx' }); + expect(JobVideo.destroy).toHaveBeenCalledWith({ where: { video_id: [1, 2] }, transaction: 'tx' }); + expect(PlaylistVideo.update).toHaveBeenCalledWith( + { downloaded_at: null }, + { where: { youtube_id: ['aaaaaaaaaaa', 'bbbbbbbbbbb'] }, transaction: 'tx' } + ); + // The destroy keeps the state predicate, so a row that became present in between survives. + expect(Video.destroy).toHaveBeenCalledWith({ where: { id: [1, 2], [Op.or]: [stateWhere('deleted')] }, transaction: 'tx' }); + const order = [VideoWatchStatus.destroy, JobVideo.destroy, Video.destroy].map((fn) => fn.mock.invocationCallOrder[0]); + expect(order).toEqual([...order].sort((a, b) => a - b)); + expect(result).toEqual({ purged: 2, archiveEntriesRemoved: 0, jobIds: ['job-1', 'job-2'] }); + }); + + test('re-reads the chunk under lock inside the transaction before touching anything', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + expect(Video.findAll.mock.calls[1][0]).toEqual(expect.objectContaining({ + where: { id: [1, 2], [Op.or]: [stateWhere('deleted')] }, + transaction: 'tx', + lock: true, + })); + expect(Video.findAll.mock.invocationCallOrder[1]).toBeLessThan(VideoWatchStatus.destroy.mock.invocationCallOrder[0]); + }); + + test('keeps a video that came back (re-downloaded or restored) between the select and the transaction', async () => { + Video.findAll.mockReset(); + Video.findAll + .mockResolvedValueOnce(rows) // candidates + .mockResolvedValueOnce([rows[0]]) // under lock, bbbbbbbbbbb is present again + .mockResolvedValue([]); + Video.destroy.mockResolvedValue(1); + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }); + + expect(VideoWatchStatus.destroy).toHaveBeenCalledWith({ where: { video_id: [1] }, transaction: 'tx' }); + expect(JobVideo.destroy).toHaveBeenCalledWith({ where: { video_id: [1] }, transaction: 'tx' }); + expect(Video.destroy).toHaveBeenCalledWith({ where: { id: [1], [Op.or]: [stateWhere('deleted')] }, transaction: 'tx' }); + const unlinked = fs.promises.unlink.mock.calls.map(([p]) => p); + expect(unlinked).toHaveLength(3); + expect(unlinked.every((p) => p.includes('aaaaaaaaaaa'))).toBe(true); + expect(archiveModule.removeVideosFromArchive).toHaveBeenCalledWith(['aaaaaaaaaaa']); + expect(result.purged).toBe(1); + }); + + test('walks the trash in chunks until the selection comes back empty', async () => { + const more = [{ id: 3, youtubeId: 'ccccccccccc' }, { id: 4, youtubeId: 'ddddddddddd' }]; + Video.findAll.mockReset(); + Video.findAll + .mockResolvedValueOnce(rows).mockResolvedValueOnce(rows) + .mockResolvedValueOnce(more).mockResolvedValueOnce(more) + .mockResolvedValue([]); + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + expect(sequelize.transaction).toHaveBeenCalledTimes(2); + expect(Video.destroy).toHaveBeenNthCalledWith(2, { where: { id: [3, 4], [Op.or]: [stateWhere('deleted')] }, transaction: 'tx' }); + expect(result.purged).toBe(4); + }); + + test('stops when a chunk has nothing left to forget under lock', async () => { + Video.findAll.mockReset(); + Video.findAll.mockResolvedValueOnce(rows).mockResolvedValue([]); // under lock: every candidate is present again + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }); + + expect(Video.destroy).not.toHaveBeenCalled(); + expect(fs.promises.unlink).not.toHaveBeenCalled(); + expect(archiveModule.removeVideosFromArchive).not.toHaveBeenCalled(); + expect(result).toEqual({ purged: 0, archiveEntriesRemoved: 0, jobIds: [] }); + }); + + test('refreshes the download history before telling it to refetch', async () => { + const reloadJobVideos = jest.fn().mockResolvedValue(undefined); + + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }, { reloadJobVideos }); + + expect(reloadJobVideos).toHaveBeenCalledWith(['job-1', 'job-2']); + const jobsUpdatedCall = messageEmitter.emitMessage.mock.calls.findIndex(([, , , type]) => type === 'jobsUpdated'); + expect(jobsUpdatedCall).toBeGreaterThanOrEqual(0); + expect(reloadJobVideos.mock.invocationCallOrder[0]).toBeLessThan(messageEmitter.emitMessage.mock.invocationCallOrder[jobsUpdatedCall]); + }); + + test('still broadcasts when refreshing the download history fails', async () => { + const reloadJobVideos = jest.fn().mockRejectedValue(new Error('boom')); + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }, { reloadJobVideos }); + + expect(result.purged).toBe(2); + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'jobsUpdated', { source: 'purge' }); + }); + + test('removes the cached info.json and thumbnails, so library repair cannot recreate the rows', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + const unlinked = fs.promises.unlink.mock.calls.map(([p]) => p); + expect(unlinked).toEqual(expect.arrayContaining([ + '/app/jobs/info/aaaaaaaaaaa.info.json', + '/app/images/videothumb-aaaaaaaaaaa.jpg', + '/app/images/videothumb-aaaaaaaaaaa-small.jpg', + '/app/jobs/info/bbbbbbbbbbb.info.json', + ])); + expect(unlinked).toHaveLength(6); + }); + + test('ignores files that are already gone', async () => { + fs.promises.unlink.mockRejectedValue(enoent); + + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).resolves.toEqual( + expect.objectContaining({ purged: 2 }) + ); + }); + + test('leaves the download archive alone unless asked', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + expect(archiveModule.removeVideosFromArchive).not.toHaveBeenCalled(); + }); + + test('removes the archive entries for every purged video when asked', async () => { + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }); + + expect(archiveModule.removeVideosFromArchive).toHaveBeenCalledTimes(1); + expect(archiveModule.removeVideosFromArchive).toHaveBeenCalledWith(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + expect(result.archiveEntriesRemoved).toBe(2); + }); + + test('tells the listing and history pages to refresh', async () => { + await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'videosUpdated', { source: 'purge', purged: 2 }); + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'jobsUpdated', { source: 'purge' }); + }); + + test('refuses to purge missing videos while the downloads folder looks unmounted, before touching anything', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: false })).rejects.toBeInstanceOf(LibraryUnavailableError); + expect(Video.destroy).not.toHaveBeenCalled(); + expect(sequelize.transaction).not.toHaveBeenCalled(); + }); + + test('purges missing videos while only an empty subfolder no video is stored in fails the check', async () => { + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + Video.count.mockImplementation(countNoneUnderSubfolder(3)); + + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: false })).resolves.toEqual( + expect.objectContaining({ purged: 2 }) + ); + }); + + test('purges missing videos while the failing subfolder holds present videos but no missing ones', async () => { + checkLibraryRoot.mockResolvedValue(SUBFOLDER_EMPTY); + Video.count.mockImplementation(countOnlyPresentUnderSubfolder(3)); + + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: false })).resolves.toEqual( + expect.objectContaining({ purged: 2 }) + ); + }); + + test('still purges deleted videos while the downloads folder looks unmounted', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).resolves.toEqual( + expect.objectContaining({ purged: 2 }) + ); + }); + + test('runs one purge at a time', async () => { + let release; + sequelize.transaction.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; })); + + const first = videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }); + await new Promise((resolve) => setImmediate(resolve)); + expect(videoTrashModule.isPurgeRunning()).toBe(true); + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).rejects.toBeInstanceOf(PurgeRunningError); + + release([]); // the stalled transaction resolves to an empty confirmed chunk + await first; + expect(videoTrashModule.isPurgeRunning()).toBe(false); + }); + + test('runs one purge at a time while the first is still checking the downloads folder', async () => { + let finishCheck; + checkLibraryRoot.mockImplementationOnce(() => new Promise((resolve) => { finishCheck = resolve; })); + + const first = videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: true }); + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: true })).rejects.toBeInstanceOf(PurgeRunningError); + + finishCheck(LIBRARY_OK); + await first; + }); + + test('releases the lock when the downloads folder looks unmounted', async () => { + checkLibraryRoot.mockResolvedValue(LIBRARY_EMPTY); + + await expect(videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: false })).rejects.toBeInstanceOf(LibraryUnavailableError); + expect(videoTrashModule.isPurgeRunning()).toBe(false); + }); + + test('releases the lock when a purge fails', async () => { + sequelize.transaction.mockRejectedValueOnce(new Error('db down')); + + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).rejects.toThrow('db down'); + expect(videoTrashModule.isPurgeRunning()).toBe(false); + }); + + describe('when a later chunk fails', () => { + const more = [{ id: 3, youtubeId: 'ccccccccccc' }]; + + beforeEach(() => { + Video.findAll.mockReset(); + Video.findAll.mockResolvedValueOnce(rows).mockResolvedValueOnce(rows).mockResolvedValueOnce(more).mockResolvedValue([]); + sequelize.transaction + .mockImplementationOnce(async (fn) => fn('tx')) + .mockRejectedValueOnce(new Error('lock wait timeout')); + }); + + test('still removes the committed chunks from the download archive, then reports the failure', async () => { + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true })).rejects.toThrow('lock wait timeout'); + + expect(archiveModule.removeVideosFromArchive).toHaveBeenCalledWith(['aaaaaaaaaaa', 'bbbbbbbbbbb']); + }); + + test('still refreshes the download history for the committed chunks', async () => { + const reloadJobVideos = jest.fn().mockResolvedValue(undefined); + + await expect( + videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false }, { reloadJobVideos }) + ).rejects.toThrow('lock wait timeout'); + + expect(reloadJobVideos).toHaveBeenCalledWith(['job-1', 'job-2']); + }); + + test('still tells the listing and history pages to refresh', async () => { + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })).rejects.toThrow('lock wait timeout'); + + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'videosUpdated', { source: 'purge', purged: 2 }); + expect(messageEmitter.emitMessage).toHaveBeenCalledWith('broadcast', null, 'videos', 'jobsUpdated', { source: 'purge' }); + }); + }); + + test('rolls back an archive failure so the same rows can be purged on retry', async () => { + // Model transaction rollback explicitly: failed transactions restore rows. + let remaining = [...rows]; + let inTransaction = false; + Video.findAll.mockReset().mockImplementation(async () => [...remaining]); + Video.destroy.mockImplementation(async () => { remaining = []; return 2; }); + sequelize.transaction.mockImplementation(async (fn) => { + const before = [...remaining]; + inTransaction = true; + try { + return await fn('tx'); + } catch (err) { + remaining = before; + throw err; + } finally { + inTransaction = false; + } + }); + archiveModule.removeVideosFromArchive.mockImplementationOnce(async () => { + expect(inTransaction).toBe(true); + throw new Error('EACCES'); + }); + const reloadJobVideos = jest.fn().mockResolvedValue(undefined); + + await expect( + videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }, { reloadJobVideos }) + ).rejects.toThrow('EACCES'); + + expect(fs.promises.unlink).not.toHaveBeenCalled(); + const result = await videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true }, { reloadJobVideos }); + expect(result).toEqual({ purged: 2, archiveEntriesRemoved: 2, jobIds: ['job-1', 'job-2'] }); + expect(archiveModule.removeVideosFromArchive).toHaveBeenNthCalledWith(2, ['aaaaaaaaaaa', 'bbbbbbbbbbb']); + }); + + test('refuses archive removal during a download before deleting any rows', async () => { + await archiveAccess.withDownload(async () => { + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true })) + .rejects.toMatchObject({ code: 'ARCHIVE_BUSY' }); + expect(Video.destroy).not.toHaveBeenCalled(); + }); + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: true })) + .resolves.toMatchObject({ purged: 2 }); + }); + + test('allows a purge that keeps the archive during a download', async () => { + await archiveAccess.withDownload(async () => { + await expect(videoTrashModule.purgeRemoved({ states: ['deleted'], removeFromArchive: false })) + .resolves.toMatchObject({ purged: 2 }); + }); + }); + + test('releases waiting downloads after a purge fails its library check', async () => { + let finishCheck; + checkLibraryRoot.mockImplementationOnce(() => new Promise((resolve) => { finishCheck = resolve; })); + const purge = videoTrashModule.purgeRemoved({ states: ['missing'], removeFromArchive: true }); + const rejected = expect(purge).rejects.toBeInstanceOf(LibraryUnavailableError); + const run = jest.fn().mockResolvedValue(undefined); + const download = archiveAccess.withDownload(run); + try { + expect(run).not.toHaveBeenCalled(); + } finally { + // Always let the purge finish, or it would hold the lock for the rest of the file. + finishCheck(LIBRARY_EMPTY); + } + await rejected; + await download; + expect(run).toHaveBeenCalledTimes(1); + }); + + test('does nothing when the trash is empty', async () => { + Video.findAll.mockReset(); + Video.findAll.mockResolvedValue([]); + + const result = await videoTrashModule.purgeRemoved({ states: ['deleted', 'missing'], removeFromArchive: true }); + + expect(result).toEqual({ purged: 0, archiveEntriesRemoved: 0, jobIds: [] }); + expect(sequelize.transaction).not.toHaveBeenCalled(); + expect(archiveModule.removeVideosFromArchive).not.toHaveBeenCalled(); + expect(messageEmitter.emitMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/server/modules/__tests__/videosModule.test.js b/server/modules/__tests__/videosModule.test.js index 0b7181e59..8fdc6caa2 100644 --- a/server/modules/__tests__/videosModule.test.js +++ b/server/modules/__tests__/videosModule.test.js @@ -1,10 +1,18 @@ /* eslint-env jest */ const { Sequelize } = require('sequelize'); +const { stateWhere, notStateWhere } = require('../videoRemovalState'); + +const { Op } = Sequelize; + +// What the rescan and the page-load check write when a file is gone or (back) on disk. +const MISSING_COLUMNS = { removed: true, removed_reason: 'not_found', removed_at: expect.any(Date), removed_detail: null }; +const PRESENT_COLUMNS = { removed: false, removed_reason: null, removed_at: null, removed_detail: null }; describe('VideosModule', () => { let VideosModule; let mockSequelize; let mockVideo; + let mockLibraryRootCheck; let mockWatchStatusQueries; let mockFs; let mockConfigModule; @@ -130,6 +138,13 @@ describe('VideosModule', () => { jest.doMock('../youtubeExistenceCheck', () => mockYoutubeExistenceCheck); + // The downloads folder looks mounted unless a test says otherwise. + mockLibraryRootCheck = { + ...jest.requireActual('../libraryRootCheck'), + checkLibraryRoot: jest.fn().mockResolvedValue({ ok: true }), + }; + jest.doMock('../libraryRootCheck', () => mockLibraryRootCheck); + jest.doMock('../nfoGenerator', () => mockNfoGenerator); jest.doMock('../messageEmitter', () => mockMessageEmitter); @@ -216,7 +231,9 @@ describe('VideosModule', () => { videos: mockVideos, total: 2, page: 1, - totalPages: 1 + totalPages: 1, + libraryWarning: null, + hiddenDeletedCount: null }); // Count query + videos query + getAllUniqueChannels query + no update queries needed expect(mockVideo.count).toHaveBeenCalledTimes(1); @@ -448,32 +465,65 @@ describe('VideosModule', () => { })); }); - test('should apply missingFilter=only to the WHERE clause', async () => { + // The removal-state filters land in the WHERE's AND list; read it back from the count call. + const countWhereAnd = (call = 0) => mockVideo.count.mock.calls[call][0].where[Op.and]; + + test('missingFilter=only keeps only rows whose file was not found, not deleted ones', async () => { mockVideo.count.mockResolvedValue(0); mockVideo.findAll.mockResolvedValue([]); mockSequelize.query.mockResolvedValueOnce([]); await VideosModule.getVideosPaginated({ missingFilter: 'only' }); - expect(mockVideo.count).toHaveBeenCalledWith(expect.objectContaining({ - where: { - removed: true, - }, - })); + expect(countWhereAnd()).toEqual([stateWhere('missing')]); }); - test('should apply missingFilter=exclude to the WHERE clause', async () => { + test('missingFilter=exclude keeps deleted rows, dropping only the missing ones', async () => { mockVideo.count.mockResolvedValue(0); mockVideo.findAll.mockResolvedValue([]); mockSequelize.query.mockResolvedValueOnce([]); await VideosModule.getVideosPaginated({ missingFilter: 'exclude' }); - expect(mockVideo.count).toHaveBeenCalledWith(expect.objectContaining({ - where: { - removed: false, - }, - })); + expect(countWhereAnd()).toEqual([notStateWhere('missing')]); + }); + + test('deletedFilter=only keeps rows removed by Youtarr, including ones without a recorded reason', async () => { + mockVideo.count.mockResolvedValue(0); + mockVideo.findAll.mockResolvedValue([]); + mockSequelize.query.mockResolvedValueOnce([]); + + await VideosModule.getVideosPaginated({ deletedFilter: 'only' }); + + expect(countWhereAnd()).toEqual([stateWhere('deleted')]); + expect(countWhereAnd()[0][Op.or]).toEqual(expect.arrayContaining([{ removed_reason: null }])); + }); + + test('deletedFilter=exclude hides deleted rows and reports how many it hid', async () => { + mockVideo.count.mockResolvedValueOnce(10).mockResolvedValueOnce(3); + mockVideo.findAll.mockResolvedValue([]); + mockSequelize.query.mockResolvedValueOnce([]); + + const result = await VideosModule.getVideosPaginated({ deletedFilter: 'exclude', protectedFilter: 'only' }); + + expect(countWhereAnd(0)).toEqual([notStateWhere('deleted')]); + // The hidden count keeps every other filter and swaps in "deleted only". + expect(mockVideo.count).toHaveBeenCalledTimes(2); + expect(mockVideo.count.mock.calls[1][0].where.protected).toBe(true); + expect(countWhereAnd(1)).toEqual([stateWhere('deleted')]); + expect(result.total).toBe(10); + expect(result.hiddenDeletedCount).toBe(3); + }); + + test('does not count hidden deleted rows unless the deleted filter excludes them', async () => { + mockVideo.count.mockResolvedValue(0); + mockVideo.findAll.mockResolvedValue([]); + mockSequelize.query.mockResolvedValueOnce([]); + + const result = await VideosModule.getVideosPaginated({ deletedFilter: 'only' }); + + expect(mockVideo.count).toHaveBeenCalledTimes(1); + expect(result.hiddenDeletedCount).toBeNull(); }); test('should omit missingFilter from the WHERE clause by default', async () => { @@ -665,10 +715,11 @@ describe('VideosModule', () => { // Check update was called expect(mockVideo.update).toHaveBeenCalledTimes(2); + // Marked missing only while still present: a delete landing meanwhile keeps its reason. expect(mockVideo.update).toHaveBeenNthCalledWith( 1, - { removed: true }, - { where: { id: 1 } }, + { ...MISSING_COLUMNS }, + { where: { id: 1, removed: false } }, ); expect(mockVideo.update).toHaveBeenNthCalledWith( 2, @@ -677,6 +728,37 @@ describe('VideosModule', () => { ); }); + test('holds back the missing mark and reports a library warning while the downloads folder looks unmounted', async () => { + const mockVideos = [ + { + id: 1, + youtubeId: 'abc123', + youTubeChannelName: 'Test Channel', + youTubeVideoName: 'Test Video', + filePath: '/test/output/dir/Test Channel/video [abc123].mp4', + fileSize: '1000', + removed: false, + youtube_removed: false, + youtube_removed_checked_at: null + } + ]; + const failedCheck = { ok: false, reason: 'empty', path: '/test/output/dir', message: 'The downloads folder is empty.' }; + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(failedCheck); + mockVideo.count.mockResolvedValue(1); + mockVideo.findAll.mockResolvedValue(mockVideos); + mockVideo.aggregate.mockResolvedValue([]); + mockFs.stat.mockRejectedValue({ code: 'ENOENT' }); + + const result = await VideosModule.getVideosPaginated(); + + expect(result.videos[0].removed).toBe(false); + expect(result.libraryWarning).toEqual({ reason: 'empty', path: '/test/output/dir', message: failedCheck.message, heldBack: 1 }); + expect(mockVideo.update).not.toHaveBeenCalledWith( + expect.objectContaining({ removed: true }), + expect.anything() + ); + }); + test('should mark video as YouTube removed when validation fails', async () => { const mockVideos = [ { @@ -1185,7 +1267,7 @@ describe('VideosModule', () => { ); expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( - { removed: false, video_resolution: '1280x720' }, + { ...PRESENT_COLUMNS, video_resolution: '1280x720' }, { where: expect.objectContaining({ id: 1, video_resolution: null }) }, ); }); @@ -1294,7 +1376,7 @@ describe('VideosModule', () => { expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( - { removed: false, video_resolution: '0x0' }, + { ...PRESENT_COLUMNS, video_resolution: '0x0' }, { where: expect.objectContaining({ id: 1 }) }, ); }); @@ -1324,7 +1406,7 @@ describe('VideosModule', () => { expect(mockExecFile).not.toHaveBeenCalled(); expect(mockVideo.update).toHaveBeenCalledTimes(1); expect(mockVideo.update).toHaveBeenCalledWith( - { filePath: null, fileSize: null, video_resolution: null, removed: false }, + { filePath: null, fileSize: null, video_resolution: null, ...PRESENT_COLUMNS }, { where: expect.objectContaining({ id: 1 }) }, ); }); @@ -1408,11 +1490,132 @@ describe('VideosModule', () => { expect(result.updated).toBe(0); // Stored paths are kept so a file returning to its location is found again. expect(mockVideo.update).toHaveBeenCalledWith( - { removed: true }, + { ...MISSING_COLUMNS }, { where: expect.objectContaining({ id: 1, filePath: '/test/missing.mp4' }) }, ); }); + describe('when the downloads folder looks unmounted', () => { + const missingRoot = { ok: false, reason: 'missing', path: '/test/output/dir', message: 'The downloads folder does not exist.' }; + const emptySubfolder = { ok: false, reason: 'subfolder-empty', path: '/test/output/dir/__Music', message: 'The subfolder __Music is empty.' }; + + test('stops before the walk, changing nothing, while videos are present', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(missingRoot); + mockVideo.count.mockResolvedValueOnce(5); // present rows + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe('The downloads folder does not exist. Nothing was marked missing; is the drive mounted?'); + expect(result.libraryCheckMessage).toBe(missingRoot.message); + expect(mockFs.readdir).not.toHaveBeenCalled(); + expect(mockVideo.update).not.toHaveBeenCalled(); + }); + + test('runs normally on a fresh install with no present videos', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue({ ...missingRoot, reason: 'empty' }); + mockVideo.count.mockResolvedValueOnce(0); // present rows + mockVideo.count.mockResolvedValueOnce(0); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.notMarkedMissing).toBe(0); + expect(result.libraryCheckMessage).toBeNull(); + }); + + test('finishes the scan but holds back missing marks while a subfolder is empty, counting them', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + mockVideo.count.mockResolvedValueOnce(1); // present rows + mockVideo.count.mockResolvedValueOnce(1); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + mockVideo.findAll.mockResolvedValueOnce([ + dbRow({ youtubeId: 'missing123', filePath: '/test/output/dir/__Music/missing.mp4', fileSize: '1000' }) + ]); + mockFs.stat.mockRejectedValue(fsError('ENOENT')); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.removed).toBe(0); + expect(result.notMarkedMissing).toBe(1); + expect(result.libraryCheckMessage).toBe(emptySubfolder.message); + expect(mockVideo.update).not.toHaveBeenCalled(); + }); + + test('stays quiet about an empty subfolder no present video is stored in', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + // Present rows under the subfolder: none. Then the total row count. + mockVideo.count.mockImplementationOnce(async ({ where }) => (where[Op.and] ? 0 : 1)); + mockVideo.count.mockResolvedValueOnce(0); + mockFs.readdir.mockResolvedValueOnce([]); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.libraryCheckMessage).toBeNull(); + expect(mockLogger.warn).not.toHaveBeenCalledWith(expect.anything(), expect.stringMatching(/looks unmounted or unreadable/)); + }); + + test('still marks missing a video stored outside the empty subfolder', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(emptySubfolder); + mockVideo.count.mockResolvedValueOnce(1); // present rows + mockVideo.count.mockResolvedValueOnce(1); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + mockVideo.findAll.mockResolvedValueOnce([ + dbRow({ youtubeId: 'missing123', filePath: '/test/output/dir/__Videos/missing.mp4', fileSize: '1000' }) + ]); + mockFs.stat.mockRejectedValue(fsError('ENOENT')); + mockVideo.update.mockResolvedValue([1]); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.removed).toBe(1); + expect(result.notMarkedMissing).toBe(0); + }); + + test('finishes the scan but holds back missing marks while a subfolder cannot be read', async () => { + const unreadableSubfolder = { ok: false, reason: 'subfolder-unreadable', path: '/test/output/dir/__Music', message: 'The subfolder __Music could not be read.' }; + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(unreadableSubfolder); + mockVideo.count.mockResolvedValueOnce(1); // present rows + mockVideo.count.mockResolvedValueOnce(1); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + mockVideo.findAll.mockResolvedValueOnce([ + dbRow({ youtubeId: 'missing123', filePath: '/test/output/dir/__Music/missing.mp4', fileSize: '1000' }) + ]); + mockFs.stat.mockRejectedValue(fsError('ENOENT')); + + const result = await VideosModule.backfillVideoMetadata(); + + expect(result.status).toBe('completed'); + expect(result.removed).toBe(0); + expect(result.notMarkedMissing).toBe(1); + expect(result.libraryCheckMessage).toBe(unreadableSubfolder.message); + expect(mockVideo.update).not.toHaveBeenCalled(); + }); + + test('skips the folder check when a manual run asks to, marking missing and saying so', async () => { + mockLibraryRootCheck.checkLibraryRoot.mockResolvedValue(missingRoot); + mockVideo.count.mockResolvedValueOnce(1); // total rows + mockFs.readdir.mockResolvedValueOnce([]); + mockVideo.findAll.mockResolvedValueOnce([ + dbRow({ youtubeId: 'missing123', filePath: '/test/output/dir/Channel/missing.mp4', fileSize: '1000' }) + ]); + mockFs.stat.mockRejectedValue(fsError('ENOENT')); + mockVideo.update.mockResolvedValue([1]); + + const result = await VideosModule.backfillVideoMetadata({ trigger: 'manual', ignoreLibraryCheck: true }); + + expect(mockLibraryRootCheck.checkLibraryRoot).not.toHaveBeenCalled(); + expect(result.status).toBe('completed'); + expect(result.removed).toBe(1); + expect(result.notMarkedMissing).toBe(0); + expect(result.libraryCheckSkipped).toBe(true); + }); + }); + test('should clear removed flag when a previously-missing file reappears (raw row removed=1)', async () => { // Sequelize raw mode returns BOOLEAN columns as 0/1, not true/false. // This test exercises that path to ensure restoration is detected. @@ -1544,7 +1747,7 @@ describe('VideosModule', () => { await VideosModule.backfillVideoMetadata(); - expect(mockVideo.update).toHaveBeenCalledWith({ removed: false }, { + expect(mockVideo.update).toHaveBeenCalledWith(PRESENT_COLUMNS, { where: { id: 1, removed: true, diff --git a/server/modules/archiveModule.js b/server/modules/archiveModule.js index ec8bdf01c..a0bff6a24 100644 --- a/server/modules/archiveModule.js +++ b/server/modules/archiveModule.js @@ -138,6 +138,40 @@ async function removeVideoFromArchive(videoId) { } } +// Remove a purge chunk in one rewrite. The caller holds archiveAccess until +// the purge finishes, so yt-dlp cannot append to the file during replacement. +// Returns how many lines went; the file is left alone when none would. +async function removeVideosFromArchive(youtubeIds) { + const wanted = new Set((youtubeIds || []).filter(Boolean)); + if (wanted.size === 0) return 0; + + const lines = readCompleteListLines(); + const kept = lines.filter((line) => { + const parts = line.split(/\s+/).filter(Boolean); + return !(parts.length >= 2 && parts[0] === 'youtube' && wanted.has(parts[1])); + }); + const removed = lines.length - kept.length; + if (removed === 0) return 0; + + const content = kept.join('\n') + (kept.length > 0 ? '\n' : ''); + const archivePath = getArchivePath(); + const tempPath = `${archivePath}.purge.tmp`; + try { + // A disk-full/write error must leave the original archive intact for retry. + fs.writeFileSync(tempPath, content, 'utf-8'); + fs.renameSync(tempPath, archivePath); + } catch (err) { + try { + fs.unlinkSync(tempPath); + } catch (cleanupError) { + if (cleanupError.code !== 'ENOENT') logger.warn({ err: cleanupError }, 'Could not clean up temporary purge archive'); + } + throw err; + } + logger.info({ removed }, 'Removed videos from archive'); + return removed; +} + module.exports = { getArchivePath, readCompleteListLines, @@ -146,4 +180,5 @@ module.exports = { filterArchivedVideoIds, addVideoToArchive, removeVideoFromArchive, + removeVideosFromArchive, }; diff --git a/server/modules/archiveOnlyIds.js b/server/modules/archiveOnlyIds.js new file mode 100644 index 000000000..a64951f1e --- /dev/null +++ b/server/modules/archiveOnlyIds.js @@ -0,0 +1,24 @@ +const ChannelVideo = require('../models/channelvideo'); +const archiveModule = require('./archiveModule'); + +// IDs yt-dlp would skip as already downloaded even though no Videos row +// exists (e.g. emptied from the trash with the archive entries kept). Ignored +// videos are left out: ignoring writes the archive entry on purpose, and an +// explicit download removes it again. +// Requires the one model it needs, not the models index. Nothing in production +// breaks either way; the constraint is downloadModule.test.js, which swaps model +// files for plain objects (jest.doMock) and loads playlistDownloadModule, which +// requires this. The index's association setup throws on those ("JobVideo.belongsTo +// called with something that's not a subclass of Sequelize.Model"). +async function findArchiveOnlyIds(youtubeIds) { + const archived = archiveModule.filterArchivedVideoIds(youtubeIds); + if (archived.size === 0) return archived; + const ignored = await ChannelVideo.findAll({ + where: { youtube_id: [...archived], ignored: true }, + attributes: ['youtube_id'], + }); + for (const row of ignored) archived.delete(row.youtube_id); + return archived; +} + +module.exports = { findArchiveOnlyIds }; diff --git a/server/modules/channel/__tests__/channelVideoQuery.test.js b/server/modules/channel/__tests__/channelVideoQuery.test.js index 7aacf17b2..faac2ca26 100644 --- a/server/modules/channel/__tests__/channelVideoQuery.test.js +++ b/server/modules/channel/__tests__/channelVideoQuery.test.js @@ -48,7 +48,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2', 'video3'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -95,7 +96,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -103,6 +105,22 @@ describe('channelVideoQuery', () => { expect(result[1].removed).toBe(false); }); + test('normalizes the raw 0/1 flags of plain rows to booleans', async () => { + const Video = require('../../../models/video'); + const videos = [{ youtube_id: 'video1' }, { youtube_id: 'video2' }]; + Video.findAll = jest.fn().mockResolvedValue([ + { youtubeId: 'video1', removed: 1, protected: 1 }, + { youtubeId: 'video2', removed: 0, protected: 0 } + ]); + + const result = await channelVideoQuery.enrichVideosWithDownloadStatus(videos); + + expect(result[0].removed).toBe(true); + expect(result[0].protected).toBe(true); + expect(result[1].removed).toBe(false); + expect(result[1].protected).toBe(false); + }); + test('should correctly handle removed videos', async () => { const Video = require('../../../models/video'); @@ -152,7 +170,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -181,7 +200,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2', 'video3'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); // Video1 - not downloaded @@ -209,7 +229,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: [] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result).toEqual([]); }); @@ -373,7 +394,8 @@ describe('channelVideoQuery', () => { where: { youtubeId: ['video1', 'video2'] }, - attributes: ['id', 'youtubeId', 'removed', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'] + attributes: ['id', 'youtubeId', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', 'audioFileSize', 'normalized_rating', 'rating_source', 'protected', 'last_downloaded_at', 'video_resolution'], + raw: true }); expect(result[0].added).toBe(true); expect(result[0].removed).toBe(false); @@ -643,7 +665,7 @@ describe('channelVideoQuery', () => { ChannelVideo.findAll.mockResolvedValue(mockVideos); // video1: added and removed (missing); video2: added and present (downloaded); video3: never added. Video.findAll = jest.fn().mockResolvedValue([ - { id: 1, youtubeId: 'video1', removed: true, fileSize: null, filePath: '/path' }, + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', fileSize: null, filePath: '/path' }, { id: 2, youtubeId: 'video2', removed: false, fileSize: 1000, filePath: '/path' } ]); @@ -656,6 +678,91 @@ describe('channelVideoQuery', () => { expect(result[0].youtube_id).toBe('video1'); }); + test('missingFilter=only leaves out videos Youtarr deleted', async () => { + const Video = require('../../../models/video'); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'video1', title: 'Missing', publishedAt: '2024-01-03', toJSON() { return this; } }, + { youtube_id: 'video2', title: 'Deleted', publishedAt: '2024-01-02', toJSON() { return this; } }, + { youtube_id: 'video3', title: 'Legacy removed', publishedAt: '2024-01-01', toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([ + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', filePath: '/path' }, + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'user_deleted', filePath: '/path' }, + { id: 3, youtubeId: 'video3', removed: true, removed_reason: null, filePath: '/path' }, + ]); + + const result = await channelVideoQuery.fetchNewestVideosFromDb( + 'UC123', 50, 0, false, '', 'date', 'desc', false, 'video', + null, null, null, null, 'off', 'only', 'off' + ); + + expect(result.map(v => v.youtube_id)).toEqual(['video1']); + }); + + test('deletedFilter=only keeps videos Youtarr deleted, including ones removed before the reason was recorded', async () => { + const Video = require('../../../models/video'); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'video1', title: 'Missing', publishedAt: '2024-01-03', toJSON() { return this; } }, + { youtube_id: 'video2', title: 'Deleted', publishedAt: '2024-01-02', toJSON() { return this; } }, + { youtube_id: 'video3', title: 'Legacy removed', publishedAt: '2024-01-01', toJSON() { return this; } }, + { youtube_id: 'video4', title: 'Present', publishedAt: '2024-01-01', toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([ + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', filePath: '/path' }, + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'user_deleted', filePath: '/path' }, + { id: 3, youtubeId: 'video3', removed: true, removed_reason: null, filePath: '/path' }, + { id: 4, youtubeId: 'video4', removed: false, fileSize: 1000, filePath: '/path' }, + ]); + + const result = await channelVideoQuery.fetchNewestVideosFromDb( + 'UC123', 50, 0, false, '', 'date', 'desc', false, 'video', + null, null, null, null, 'off', 'off', 'off', 'off', null, 'only' + ); + + expect(result.map(v => v.youtube_id)).toEqual(['video2', 'video3']); + }); + + test('deletedFilter=exclude drops deleted videos but keeps missing and present ones', async () => { + const Video = require('../../../models/video'); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'video1', title: 'Missing', publishedAt: '2024-01-03', toJSON() { return this; } }, + { youtube_id: 'video2', title: 'Deleted', publishedAt: '2024-01-02', toJSON() { return this; } }, + { youtube_id: 'video4', title: 'Present', publishedAt: '2024-01-01', toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([ + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', filePath: '/path' }, + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'user_deleted', filePath: '/path' }, + { id: 4, youtubeId: 'video4', removed: false, fileSize: 1000, filePath: '/path' }, + ]); + + const result = await channelVideoQuery.fetchNewestVideosFromDb( + 'UC123', 50, 0, false, '', 'date', 'desc', false, 'video', + null, null, null, null, 'off', 'off', 'off', 'off', null, 'exclude' + ); + + expect(result.map(v => v.youtube_id)).toEqual(['video1', 'video4']); + }); + + test('carries why a video was removed into the listing row', async () => { + const Video = require('../../../models/video'); + const removedAt = new Date('2026-10-03T00:00:00Z'); + ChannelVideo.findAll.mockResolvedValue([ + { youtube_id: 'video2', title: 'Deleted', publishedAt: '2024-01-02', toJSON() { return this; } }, + ]); + Video.findAll = jest.fn().mockResolvedValue([ + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'auto_removal', removed_at: removedAt, removed_detail: 'older than 30 days', filePath: '/path' }, + ]); + + const result = await channelVideoQuery.fetchNewestVideosFromDb('UC123', 50, 0, false, '', 'date', 'desc', false); + + expect(result[0]).toEqual(expect.objectContaining({ + removed: true, + removed_reason: 'auto_removal', + removed_at: removedAt, + removed_detail: 'older than 30 days', + })); + }); + test('missingFilter=exclude drops videos that are downloaded and removed', async () => { const Video = require('../../../models/video'); const mockVideos = [ @@ -665,7 +772,7 @@ describe('channelVideoQuery', () => { ]; ChannelVideo.findAll.mockResolvedValue(mockVideos); Video.findAll = jest.fn().mockResolvedValue([ - { id: 1, youtubeId: 'video1', removed: true, fileSize: null, filePath: '/path' }, + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', fileSize: null, filePath: '/path' }, { id: 2, youtubeId: 'video2', removed: false, fileSize: 1000, filePath: '/path' } ]); @@ -789,8 +896,8 @@ describe('channelVideoQuery', () => { ]; ChannelVideo.findAll.mockResolvedValue(mockVideos); Video.findAll = jest.fn().mockResolvedValue([ - { id: 1, youtubeId: 'video1', removed: true, fileSize: null, filePath: '/path' }, - { id: 2, youtubeId: 'video2', removed: true, fileSize: null, filePath: '/path' } + { id: 1, youtubeId: 'video1', removed: true, removed_reason: 'not_found', fileSize: null, filePath: '/path' }, + { id: 2, youtubeId: 'video2', removed: true, removed_reason: 'not_found', fileSize: null, filePath: '/path' } ]); const result = await channelVideoQuery.fetchNewestVideosFromDb( diff --git a/server/modules/channel/channelVideoQuery.js b/server/modules/channel/channelVideoQuery.js index e650d7fa2..2275ba755 100644 --- a/server/modules/channel/channelVideoQuery.js +++ b/server/modules/channel/channelVideoQuery.js @@ -3,6 +3,7 @@ const watchStatusQueries = require('../mediaServers/watchStatusQueries'); const fileCheckModule = require('../fileCheckModule'); const ratingMapper = require('../ratingMapper'); const { PUBLISHED_AT_SOURCE } = require('../constants/publishedAtSource'); +const { REMOVAL_STATES, removalState } = require('../videoRemovalState'); class ChannelVideoQuery { /** @@ -26,6 +27,9 @@ class ChannelVideoQuery { 'id', 'youtubeId', 'removed', + 'removed_reason', + 'removed_at', + 'removed_detail', 'fileSize', 'filePath', 'audioFilePath', @@ -35,7 +39,11 @@ class ChannelVideoQuery { 'protected', 'last_downloaded_at', 'video_resolution' - ] + ], + // Plain rows: the file check spreads these ({ ...video }) to build its + // updated copies, and spreading a Sequelize instance drops the attribute + // getters, so the updated rows would never match the status map below. + raw: true }); // Create Maps for O(1) lookup of download status @@ -43,16 +51,20 @@ class ChannelVideoQuery { const videosToCheck = []; downloadedVideos.forEach(v => { + // Plain rows carry TINYINT flags as 0/1; keep the listing's booleans. downloadStatusMap.set(v.youtubeId, { id: v.id, added: true, - removed: v.removed, + removed: Boolean(v.removed), + removed_reason: v.removed_reason ?? null, + removed_at: v.removed_at ?? null, + removed_detail: v.removed_detail ?? null, fileSize: v.fileSize, filePath: v.filePath, audioFilePath: v.audioFilePath, audioFileSize: v.audioFileSize, normalized_rating: v.normalized_rating, - protected: v.protected, + protected: Boolean(v.protected), last_downloaded_at: v.last_downloaded_at, video_resolution: v.video_resolution }); @@ -71,7 +83,10 @@ class ChannelVideoQuery { checkedVideos.forEach(v => { const status = downloadStatusMap.get(v.youtubeId); if (status) { - status.removed = v.removed; + status.removed = Boolean(v.removed); + status.removed_reason = v.removed_reason ?? null; + status.removed_at = v.removed_at ?? null; + status.removed_detail = v.removed_detail ?? null; status.fileSize = v.fileSize; status.audioFileSize = v.audioFileSize; } @@ -100,6 +115,9 @@ class ChannelVideoQuery { // Video exists in database plainVideoObject.added = true; plainVideoObject.removed = status.removed; + plainVideoObject.removed_reason = status.removed_reason; + plainVideoObject.removed_at = status.removed_at; + plainVideoObject.removed_detail = status.removed_detail; plainVideoObject.fileSize = status.fileSize; plainVideoObject.filePath = status.filePath; plainVideoObject.audioFilePath = status.audioFilePath; @@ -118,6 +136,9 @@ class ChannelVideoQuery { // Video never downloaded plainVideoObject.added = false; plainVideoObject.removed = false; + plainVideoObject.removed_reason = null; + plainVideoObject.removed_at = null; + plainVideoObject.removed_detail = null; plainVideoObject.fileSize = null; plainVideoObject.filePath = null; plainVideoObject.audioFilePath = null; @@ -202,9 +223,11 @@ class ChannelVideoQuery { } /** - * Apply tri-state status filters (protected / missing / ignored / watched). + * Apply tri-state status filters (protected / missing / ignored / watched / deleted). * Mode is 'off' (no filtering), 'only' (keep matches), or 'exclude' (drop matches). - * Missing is "previously downloaded, file no longer present". + * Missing is "previously downloaded, a file was expected and not found"; + * deleted is "previously downloaded, removed by Youtarr or marked as deleted" + * (see videoRemovalState). * Watched is "has a watched-by entry under the configured watched rule"; * never-downloaded videos are never watched. * @param {Array} videos - Array of enriched videos @@ -212,9 +235,10 @@ class ChannelVideoQuery { * @param {string} missingMode - 'off' | 'only' | 'exclude' * @param {string} ignoredMode - 'off' | 'only' | 'exclude' * @param {string} watchedMode - 'off' | 'only' | 'exclude' + * @param {string} deletedMode - 'off' | 'only' | 'exclude' * @returns {Array} - Filtered array of videos */ - _applyStatusFilters(videos, protectedMode, missingMode, ignoredMode, watchedMode) { + _applyStatusFilters(videos, protectedMode, missingMode, ignoredMode, watchedMode, deletedMode = 'off') { let filtered = videos; if (protectedMode === 'only') { @@ -223,10 +247,13 @@ class ChannelVideoQuery { filtered = filtered.filter(video => !video.protected); } - if (missingMode === 'only') { - filtered = filtered.filter(video => video.added === true && video.removed === true); - } else if (missingMode === 'exclude') { - filtered = filtered.filter(video => !(video.added === true && video.removed === true)); + const inState = (video, state) => video.added === true && removalState(video) === state; + for (const [mode, state] of [[missingMode, REMOVAL_STATES.MISSING], [deletedMode, REMOVAL_STATES.DELETED]]) { + if (mode === 'only') { + filtered = filtered.filter(video => inState(video, state)); + } else if (mode === 'exclude') { + filtered = filtered.filter(video => !inState(video, state)); + } } if (ignoredMode === 'only') { @@ -260,9 +287,10 @@ class ChannelVideoQuery { * @param {number|null} maxDuration - Maximum duration in seconds (default null) * @param {string|null} dateFrom - Filter videos from this date (ISO string, default null) * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) + * @param {string} deletedFilter - Tri-state filter on videos Youtarr deleted: 'off' | 'only' | 'exclude' (default 'off') * @returns {Promise<Array>} - Array of video objects with download status */ - async fetchNewestVideosFromDb(channelId, limit = 50, offset = 0, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', checkFiles = false, mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null) { + async fetchNewestVideosFromDb(channelId, limit = 50, offset = 0, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', checkFiles = false, mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null, deletedFilter = 'off') { // First get all videos to enrich with download status const allChannelVideos = await ChannelVideo.findAll({ where: { @@ -295,7 +323,7 @@ class ChannelVideoQuery { // Apply duration and date filters filteredVideos = this._applyDurationAndDateFilters(filteredVideos, minDuration, maxDuration, dateFrom, dateTo); - filteredVideos = this._applyStatusFilters(filteredVideos, protectedFilter, missingFilter, ignoredFilter, watchedFilter); + filteredVideos = this._applyStatusFilters(filteredVideos, protectedFilter, missingFilter, ignoredFilter, watchedFilter, deletedFilter); filteredVideos = this._applyRatingFilter(filteredVideos, ratingFilter); @@ -350,6 +378,9 @@ class ChannelVideoQuery { if (checkedVideos[i]) { paginatedVideos[i].added = checkedVideos[i].added; paginatedVideos[i].removed = checkedVideos[i].removed; + paginatedVideos[i].removed_reason = checkedVideos[i].removed_reason; + paginatedVideos[i].removed_at = checkedVideos[i].removed_at; + paginatedVideos[i].removed_detail = checkedVideos[i].removed_detail; paginatedVideos[i].fileSize = checkedVideos[i].fileSize; paginatedVideos[i].thumbnail = checkedVideos[i].thumbnail; } @@ -381,11 +412,12 @@ class ChannelVideoQuery { * @param {number|null} maxDuration - Maximum duration in seconds (default null) * @param {string|null} dateFrom - Filter videos from this date (ISO string, default null) * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) + * @param {string} deletedFilter - Tri-state filter on videos Youtarr deleted: 'off' | 'only' | 'exclude' (default 'off') * @returns {Promise<Object>} - Object with totalCount and oldestVideoDate */ - async getChannelVideoStats(channelId, downloadedFilter = 'off', searchQuery = '', mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null) { + async getChannelVideoStats(channelId, downloadedFilter = 'off', searchQuery = '', mediaType = 'video', minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', ratingFilter = null, deletedFilter = 'off') { // If we have search or filter, we need to get all videos - if (downloadedFilter !== 'off' || searchQuery || minDuration !== null || maxDuration !== null || dateFrom || dateTo || protectedFilter !== 'off' || missingFilter !== 'off' || ignoredFilter !== 'off' || watchedFilter !== 'off' || ratingFilter) { + if (downloadedFilter !== 'off' || searchQuery || minDuration !== null || maxDuration !== null || dateFrom || dateTo || protectedFilter !== 'off' || missingFilter !== 'off' || ignoredFilter !== 'off' || watchedFilter !== 'off' || ratingFilter || deletedFilter !== 'off') { // Need to filter by download status and/or search const allChannelVideos = await ChannelVideo.findAll({ where: { @@ -417,7 +449,7 @@ class ChannelVideoQuery { // Apply duration and date filters filteredVideos = this._applyDurationAndDateFilters(filteredVideos, minDuration, maxDuration, dateFrom, dateTo); - filteredVideos = this._applyStatusFilters(filteredVideos, protectedFilter, missingFilter, ignoredFilter, watchedFilter); + filteredVideos = this._applyStatusFilters(filteredVideos, protectedFilter, missingFilter, ignoredFilter, watchedFilter, deletedFilter); filteredVideos = this._applyRatingFilter(filteredVideos, ratingFilter); diff --git a/server/modules/channel/channelVideosService.js b/server/modules/channel/channelVideosService.js index 16bd8c631..e7115d029 100644 --- a/server/modules/channel/channelVideosService.js +++ b/server/modules/channel/channelVideosService.js @@ -134,7 +134,7 @@ class ChannelVideosService { * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise<Object>} - Response object with videos and metadata */ - async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null) { + async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null, deletedFilter = 'off') { const channel = await Channel.findOne({ where: { channel_id: channelId }, }); @@ -203,7 +203,7 @@ class ChannelVideosService { // Now fetch the requested page of videos with file checking enabled const offset = (page - 1) * pageSize; - const paginatedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const paginatedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, deletedFilter); // Check if videos still exist on YouTube and mark as removed if they don't const updates = []; @@ -275,7 +275,7 @@ class ChannelVideosService { } // Get stats for the response - const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, deletedFilter); return { ...this.buildChannelVideosResponse(paginatedVideos, channel, 'cache', stats, autoDownloadsEnabled, mediaType), @@ -285,8 +285,8 @@ class ChannelVideosService { } catch (error) { logger.error({ err: error, channelId }, 'Error fetching channel videos'); const offset = (page - 1) * pageSize; - const cachedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); - const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter); + const cachedVideos = await channelVideoQuery.fetchNewestVideosFromDb(channelId, pageSize, offset, downloadedFilter, searchQuery, sortBy, sortOrder, true, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, deletedFilter); + const stats = await channelVideoQuery.getChannelVideoStats(channelId, downloadedFilter, searchQuery, mediaType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, ratingFilter, deletedFilter); const response = this.buildChannelVideosResponse(cachedVideos, channel, 'cache', stats, autoDownloadsEnabled, mediaType); // Only surface a user-visible error when we have nothing to show. // Silent recovery when cached results exist; the filter-aware empty diff --git a/server/modules/channelDownloadAllModule.js b/server/modules/channelDownloadAllModule.js index f1e05b54a..6073e0939 100644 --- a/server/modules/channelDownloadAllModule.js +++ b/server/modules/channelDownloadAllModule.js @@ -5,6 +5,7 @@ const { channelDownloadAllJobLabel } = require('./download/jobTypes'); const { MEDIA_TAB_TYPE_MAP } = require('./tabsUtils'); const logger = require('../logger'); const videoActivity = require('./download/videoActivity'); +const { findArchiveOnlyIds } = require('./archiveOnlyIds'); const WATCH_URL_PREFIX = 'https://www.youtube.com/watch?v='; @@ -46,9 +47,14 @@ class ChannelDownloadAllModule { // those stay in the yt-dlp archive, so yt-dlp would skip them and the // preview count would overstate. const downloaded = new Set(existing.map((video) => video.youtubeId)); + // So is anything the archive lists without a row (e.g. emptied from the + // trash with its archive entry kept): yt-dlp skips those too. + const archiveOnly = await findArchiveOnlyIds( + candidates.map((row) => row.youtube_id).filter((id) => !downloaded.has(id)) + ); return candidates - .filter((row) => !downloaded.has(row.youtube_id) && !videoActivity.isActive(row.youtube_id)) + .filter((row) => !downloaded.has(row.youtube_id) && !archiveOnly.has(row.youtube_id) && !videoActivity.isActive(row.youtube_id)) .map((row) => ({ youtube_id: row.youtube_id, duration: row.duration })); } diff --git a/server/modules/channelModule.js b/server/modules/channelModule.js index 56c9c32e3..606c10bda 100644 --- a/server/modules/channelModule.js +++ b/server/modules/channelModule.js @@ -201,8 +201,8 @@ class ChannelModule { * @param {string|null} dateTo - Filter videos to this date (ISO string, default null) * @returns {Promise<Object>} - Response object with videos and metadata */ - async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null) { - return channelVideosService.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating); + async getChannelVideos(channelId, page = 1, pageSize = 50, downloadedFilter = 'off', searchQuery = '', sortBy = 'date', sortOrder = 'desc', tabType = TAB_TYPES.VIDEOS, minDuration = null, maxDuration = null, dateFrom = null, dateTo = null, protectedFilter = 'off', missingFilter = 'off', ignoredFilter = 'off', watchedFilter = 'off', maxRating = null, deletedFilter = 'off') { + return channelVideosService.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating, deletedFilter); } /** diff --git a/server/modules/cronJobs.js b/server/modules/cronJobs.js index b81901f1c..7502aa160 100644 --- a/server/modules/cronJobs.js +++ b/server/modules/cronJobs.js @@ -78,6 +78,9 @@ function initialize(deps = {}) { let failure = null; try { result = await videoDeletionModule.performAutomaticCleanup(); + // Skipped while the downloads folder looks unmounted: nothing to report + // or tidy, and the run history should say why nothing happened. + if (result.skipped) return skippedRun(result.skipped.message); if (result.totalDeleted > 0) { logger.info({ @@ -105,11 +108,18 @@ function initialize(deps = {}) { let orphans = NO_ORPHAN_RESULT; try { const removal = await videoDeletionModule.cleanupOrphanDirectories(); - orphans = { removed: (removal && removal.removed) || [], errors: (removal && removal.errors) || [] }; + orphans = { + removed: (removal && removal.removed) || [], + errors: (removal && removal.errors) || [], + skipped: (removal && removal.skipped) || null, + }; } catch (error) { logger.error({ err: error }, 'Error during orphan directory cleanup'); orphans = { removed: [], errors: [error.message || 'Unknown error'] }; } + // With auto-removal off, the sweep is the run's only work, so its skip + // (downloads folder looks unmounted) is the run's outcome. + if (!failure && orphans.skipped && result.totalDeleted === 0) return skippedRun(orphans.skipped.message); return failure || describeCleanup(result, orphans); }; @@ -151,11 +161,15 @@ function initialize(deps = {}) { // ============================================================================ // VIDEO METADATA BACKFILL // ============================================================================ - jobs.videoRescanFrequency = async ({ trigger = 'scheduled' } = {}) => { + jobs.videoRescanFrequency = async ({ trigger = 'scheduled', ignoreLibraryCheck = false } = {}) => { logger.info({ trigger }, 'Starting video metadata backfill'); try { - // Await completion so another occurrence can't overlap this one. - const result = await videosModule.backfillVideoMetadata({ trigger }); + // Await completion so another occurrence can't overlap this one. Only a + // manual run (Rescan anyway) may skip the unmounted-drive check. + const result = await videosModule.backfillVideoMetadata({ + trigger, + ignoreLibraryCheck: trigger === 'manual' && ignoreLibraryCheck === true, + }); if (result && result.timedOut) { logger.info('Video metadata backfill reached time limit, will continue at the next scheduled run'); } else if (!result || result.status !== 'error') { diff --git a/server/modules/download/__tests__/archiveAccess.test.js b/server/modules/download/__tests__/archiveAccess.test.js new file mode 100644 index 000000000..ff51c1d55 --- /dev/null +++ b/server/modules/download/__tests__/archiveAccess.test.js @@ -0,0 +1,58 @@ +/* eslint-env jest */ +const { ArchiveAccess } = require('../archiveAccess'); + +describe('archive access', () => { + let access; + + beforeEach(() => { + access = new ArchiveAccess(); + }); + + test('refuses a purge until the entire download and finalization finish', async () => { + let finish; + const download = access.withDownload(() => new Promise((resolve) => { finish = resolve; })); + expect(() => access.acquirePurge()).toThrow(/active downloads/); + finish(); + await download; + const release = access.acquirePurge(); + release(); + }); + + test('waits for the purge before starting a new download', async () => { + const release = access.acquirePurge(); + const run = jest.fn().mockResolvedValue('complete'); + const download = access.withDownload(run); + await Promise.resolve(); + expect(run).not.toHaveBeenCalled(); + release(); + await expect(download).resolves.toBe('complete'); + expect(run).toHaveBeenCalledTimes(1); + }); + + test('reports whether a download is active', async () => { + let finish; + expect(access.isDownloading()).toBe(false); + const download = access.withDownload(() => new Promise((resolve) => { finish = resolve; })); + expect(access.isDownloading()).toBe(true); + finish(); + await download; + expect(access.isDownloading()).toBe(false); + }); + + test('releases access even when a download throws', async () => { + await expect(access.withDownload(async () => { throw new Error('failed'); })).rejects.toThrow('failed'); + const release = access.acquirePurge(); + release(); + }); + + test('keeps the archive occupied until every overlapping download finishes', async () => { + let finish; + const first = access.withDownload(() => new Promise((resolve) => { finish = resolve; })); + await access.withDownload(async () => {}); + expect(() => access.acquirePurge()).toThrow(/active downloads/); + finish(); + await first; + const release = access.acquirePurge(); + release(); + }); +}); diff --git a/server/modules/download/__tests__/downloadExecutor.test.js b/server/modules/download/__tests__/downloadExecutor.test.js index b7448fa88..ab9308c06 100644 --- a/server/modules/download/__tests__/downloadExecutor.test.js +++ b/server/modules/download/__tests__/downloadExecutor.test.js @@ -125,6 +125,7 @@ const plexModule = require('../../plexModule'); const jobModule = require('../../jobModule'); const MessageEmitter = require('../../messageEmitter'); const archiveModule = require('../../archiveModule'); +const archiveAccess = require('../archiveAccess'); const notificationModule = require('../../notificationModule'); const channelModule = require('../../channelModule'); const DownloadProgressMonitor = require('../DownloadProgressMonitor'); @@ -300,6 +301,50 @@ describe('DownloadExecutor', () => { }); }); + describe('archive access during downloads', () => { + it('defers startup until the purge releases the archive', async () => { + const release = archiveAccess.acquirePurge(); + // Released in finally too: a failed assertion must not leave the lock + // held, or every later download in this file would wait forever. + let released = false; + const releasePurge = () => { + if (released) return; + released = true; + release(); + }; + try { + const download = executor.doDownload([], 'job-123', 'Channel Downloads'); + await Promise.resolve(); + expect(archiveModule.readCompleteListLines).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + releasePurge(); + await new Promise((resolve) => setImmediate(resolve)); + expect(mockSpawn).toHaveBeenCalled(); + endProcess(0, null); + await download; + } finally { + releasePurge(); + } + }); + + it('blocks archive removal through finalization and releases it afterward', async () => { + let finishMetadata; + VideoMetadataProcessor.processVideoMetadata.mockImplementationOnce( + () => new Promise((resolve) => { finishMetadata = resolve; }) + ); + const download = executor.doDownload([], 'job-123', 'Channel Downloads'); + await new Promise((resolve) => setImmediate(resolve)); + expect(() => archiveAccess.acquirePurge()).toThrow(/active downloads/); + endProcess(0, null); + await new Promise((resolve) => setImmediate(resolve)); + expect(() => archiveAccess.acquirePurge()).toThrow(/active downloads/); + finishMetadata([]); + await download; + const release = archiveAccess.acquirePurge(); + release(); + }); + }); + describe('stdio drain before finalization', () => { const mockArgs = ['--format', 'best', 'https://youtube.com/watch?v=test']; const mockJobId = 'job-123'; diff --git a/server/modules/download/__tests__/videoMetadataProcessor.test.js b/server/modules/download/__tests__/videoMetadataProcessor.test.js index f9c6b9a58..c4389a05d 100644 --- a/server/modules/download/__tests__/videoMetadataProcessor.test.js +++ b/server/modules/download/__tests__/videoMetadataProcessor.test.js @@ -149,6 +149,9 @@ describe('VideoMetadataProcessor', () => { audioFilePath: null, audioFileSize: null, removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null, video_resolution: null }); @@ -403,6 +406,9 @@ describe('VideoMetadataProcessor', () => { audioFilePath: null, audioFileSize: null, removed: false, + removed_reason: null, + removed_at: null, + removed_detail: null, video_resolution: null }); }); diff --git a/server/modules/download/archiveAccess.js b/server/modules/download/archiveAccess.js new file mode 100644 index 000000000..46f800cdf --- /dev/null +++ b/server/modules/download/archiveAccess.js @@ -0,0 +1,42 @@ +// A purge must not rewrite complete.list while yt-dlp is using it or while +// finalization still relies on the archive's starting line count. +class ArchiveAccess { + constructor() { + this.downloads = 0; + this.purgeFinished = null; + } + + async withDownload(run) { + while (this.purgeFinished) await this.purgeFinished; + this.downloads += 1; + try { + return await run(); + } finally { + this.downloads -= 1; + } + } + + // Whether a purge that removes archive entries would be refused right now. + isDownloading() { + return this.downloads > 0; + } + + // Claim synchronously, before the purge's first await. New downloads wait + // for release; a purge requested during a download is refused immediately. + acquirePurge() { + if (this.downloads > 0 || this.purgeFinished) { + const error = new Error('Wait for active downloads to finish before emptying trash with archive removal.'); + error.code = 'ARCHIVE_BUSY'; + throw error; + } + let finish; + this.purgeFinished = new Promise((resolve) => { finish = resolve; }); + return () => { + this.purgeFinished = null; + finish(); + }; + } +} + +module.exports = new ArchiveAccess(); +module.exports.ArchiveAccess = ArchiveAccess; diff --git a/server/modules/download/downloadExecutor.js b/server/modules/download/downloadExecutor.js index 5e4f70ae0..b11f18f35 100644 --- a/server/modules/download/downloadExecutor.js +++ b/server/modules/download/downloadExecutor.js @@ -17,6 +17,8 @@ const { buildYtdlpEnv } = require('./ytdlpEnvBuilder'); const DownloadTimeoutController = require('./DownloadTimeoutController'); const { YtdlpErrorTracker } = require('./YtdlpErrorTracker'); const downloadRunTracker = require('./downloadRunTracker'); +const archiveAccess = require('./archiveAccess'); +const { HEALTHCHECK_FILE_PREFIX } = require('../filesystem/constants'); // Tells the job's download run about a job that failed before yt-dlp ran, so // the run counts it instead of treating the job as quietly finished. @@ -122,7 +124,7 @@ class DownloadExecutor { const fsPromises = require('fs').promises; const crypto = require('crypto'); - const testFile = path.join(outputDir, `.youtarr_healthcheck_${crypto.randomUUID()}`); + const testFile = path.join(outputDir, `${HEALTHCHECK_FILE_PREFIX}${crypto.randomUUID()}`); let timer; const timeoutPromise = new Promise((_, reject) => { @@ -158,6 +160,12 @@ class DownloadExecutor { } async doDownload(args, jobId, jobType, urlCount = 0, originalUrls = null, allowRedownload = false, skipJobTransition = false, postProcessDirectives = {}) { + return archiveAccess.withDownload(() => this.runDownload( + args, jobId, jobType, urlCount, originalUrls, allowRedownload, skipJobTransition, postProcessDirectives + )); + } + + async runDownload(args, jobId, jobType, urlCount, originalUrls, allowRedownload, skipJobTransition, postProcessDirectives) { const subfolderOverride = (postProcessDirectives || {}).subfolderOverride ?? null; const initialCount = downloadResultProcessor.getCountOfDownloadedVideos(); const config = configModule.getConfig(); diff --git a/server/modules/download/tempPathManager.js b/server/modules/download/tempPathManager.js index 87c30fd6e..9e24e6d3f 100644 --- a/server/modules/download/tempPathManager.js +++ b/server/modules/download/tempPathManager.js @@ -3,9 +3,8 @@ const fs = require('fs-extra'); const configModule = require('../configModule'); const directoryManager = require('../filesystem/directoryManager'); const logger = require('../../logger'); - // Hidden temp directory name (dot-prefix hides from media servers like Plex/Jellyfin) -const LOCAL_TEMP_DIR_NAME = '.youtarr_tmp'; +const { LOCAL_TEMP_DIR_NAME } = require('../filesystem/constants'); /** * Manages temporary download paths and conversions between temp and final locations. diff --git a/server/modules/download/videoMetadataProcessor.js b/server/modules/download/videoMetadataProcessor.js index 9f962364b..b95d4020b 100644 --- a/server/modules/download/videoMetadataProcessor.js +++ b/server/modules/download/videoMetadataProcessor.js @@ -4,6 +4,7 @@ const path = require('path'); const configModule = require('../configModule'); const logger = require('../../logger'); const { probeVideoDimensions } = require('../resolutionTier'); +const { presentColumns } = require('../videoRemovalState'); class VideoMetadataProcessor { static normalizeChannelName(value) { @@ -184,7 +185,7 @@ class VideoMetadataProcessor { fileSize: null, audioFilePath: null, audioFileSize: null, - removed: false, + ...presentColumns(), video_resolution: null, }; diff --git a/server/modules/fileCheckModule.js b/server/modules/fileCheckModule.js index 51b31220b..e37d890e2 100644 --- a/server/modules/fileCheckModule.js +++ b/server/modules/fileCheckModule.js @@ -3,6 +3,9 @@ const path = require('path'); const { Video } = require('../models'); const { VIDEO_EXTENSIONS, AUDIO_EXTENSIONS } = require('./filesystem/constants'); const createLimiter = require('./subscriptionImport/concurrencyLimiter'); +const { REMOVAL_REASONS, REMOVAL_COLUMNS, removedColumns, presentColumns } = require('./videoRemovalState'); +const { checkLibraryRoot, isRowUnderLibraryCheck } = require('./libraryRootCheck'); +const configModule = require('./configModule'); // Per-video checks run concurrently up to this bound: each stat costs a full // round trip on network-backed mounts (NAS, WSL drvfs), so checking a @@ -59,6 +62,12 @@ class FileCheckModule { return { exists: false, statusKnown: true }; } + /** + * @returns {Promise<{videos: Array, updates: Array, libraryWarning: (Object|null)}>} + * libraryWarning is set when rows would have been marked missing but the + * downloads folder, or the subfolder they are stored in, looks unmounted + * (see libraryRootCheck): those rows are left as they are and counted in `heldBack`. + */ async checkVideoFiles(videos) { const updatedVideos = [...videos]; const limit = createLimiter(MAX_CONCURRENT_FILE_CHECKS); @@ -66,6 +75,16 @@ class FileCheckModule { // check finishes first. const updateSlots = new Array(updatedVideos.length).fill(null); + // An unmounted drive makes every file look gone, so the folder itself is + // checked before the first row is marked missing: once per pass, and only + // when a pass would mark something. + let libraryCheckPromise = null; + const getLibraryCheck = () => { + libraryCheckPromise ??= checkLibraryRoot(configModule.directoryPath); + return libraryCheckPromise; + }; + let heldBack = 0; + await Promise.all(updatedVideos.map((video, i) => limit(async () => { const update = { id: video.id }; let hasUpdates = false; @@ -120,11 +139,19 @@ class FileCheckModule { if (hasAnyPath && canDetermineRemovedStatus) { if (hasAnyFile && video.removed) { - update.removed = false; + Object.assign(update, presentColumns()); hasUpdates = true; } else if (!hasAnyFile && !video.removed) { - update.removed = true; - hasUpdates = true; + // A row that is already removed (deleted in Youtarr, or marked as + // deleted) is left as it is: only a present row becomes missing. + // A failing subfolder holds back only the rows stored under it. + const libraryCheck = await getLibraryCheck(); + if (!isRowUnderLibraryCheck(libraryCheck, video)) { + Object.assign(update, removedColumns(REMOVAL_REASONS.NOT_FOUND)); + hasUpdates = true; + } else { + heldBack++; + } } } @@ -136,12 +163,14 @@ class FileCheckModule { ...(update.fileSize !== undefined && { fileSize: update.fileSize.toString() }), ...(update.audioFilePath !== undefined && { audioFilePath: update.audioFilePath }), ...(update.audioFileSize !== undefined && { audioFileSize: update.audioFileSize.toString() }), - ...(update.removed !== undefined && { removed: update.removed }) + ...(update.removed !== undefined && Object.fromEntries(REMOVAL_COLUMNS.map((column) => [column, update[column]]))) }; } }))); - return { videos: updatedVideos, updates: updateSlots.filter(Boolean) }; + const libraryWarning = heldBack > 0 ? { ...(await getLibraryCheck()), heldBack } : null; + if (libraryWarning) delete libraryWarning.ok; + return { videos: updatedVideos, updates: updateSlots.filter(Boolean), libraryWarning }; } async applyVideoUpdates(updates) { @@ -152,10 +181,18 @@ class FileCheckModule { audioFilePath: update.audioFilePath, audioFileSize: update.audioFileSize, removed: update.removed, + removed_reason: update.removed_reason, + removed_at: update.removed_at, + removed_detail: update.removed_detail, }; if (Object.values(attributes).some((v) => v !== undefined)) { - await Video.update(attributes, { where: { id: update.id } }); + // Only a present row becomes missing and only a removed row is + // restored, so a delete that lands between the check's read and this + // write keeps its reason instead of being overwritten with not_found. + const where = { id: update.id }; + if (update.removed !== undefined) where.removed = !update.removed; + await Video.update(attributes, { where }); } } } diff --git a/server/modules/filesystem/constants.js b/server/modules/filesystem/constants.js index 1e83796e5..4071bc623 100644 --- a/server/modules/filesystem/constants.js +++ b/server/modules/filesystem/constants.js @@ -173,8 +173,22 @@ const M3U_FILE_PATTERN = /\.m3u(\.tmp)?$/i; */ const APPLEDOUBLE_FILE_PATTERN = /^\._/; +/** + * Entries Youtarr itself creates at the top of the downloads folder: the + * local temp folder, the playlist m3u folder, and the write test the download + * preflight leaves behind if it is interrupted. Youtarr may create them on a + * bare mount point while a share is down, so the library check ignores them + * when deciding whether the folder looks mounted. + */ +const LOCAL_TEMP_DIR_NAME = '.youtarr_tmp'; +const M3U_FOLDER_NAME = '__playlists__'; +const HEALTHCHECK_FILE_PREFIX = '.youtarr_healthcheck_'; + module.exports = { SUBFOLDER_PREFIX, + LOCAL_TEMP_DIR_NAME, + M3U_FOLDER_NAME, + HEALTHCHECK_FILE_PREFIX, GLOBAL_DEFAULT_SENTINEL, ROOT_SENTINEL, VIDEO_EXTENSIONS, diff --git a/server/modules/jobModule.js b/server/modules/jobModule.js index 3f7dffa55..93aaf9bc0 100644 --- a/server/modules/jobModule.js +++ b/server/modules/jobModule.js @@ -19,6 +19,7 @@ const downloadCleanup = require('./download/downloadCleanup'); const { serializeAuxData, parseAuxData } = require('./jobAuxData'); const storageGuard = require('./storageGuard'); const { unchangedSinceRead } = require('./videoRowGuard'); +const { REMOVAL_REASONS, removedColumns, presentColumns } = require('./videoRemovalState'); const videosModule = require('./videosModule'); const logger = require('../logger'); @@ -232,13 +233,14 @@ class JobModule { if (resolvedPath) { videoData.filePath = resolvedPath; videoData.fileSize = resolvedStats?.size !== undefined ? resolvedStats.size.toString() : null; - videoData.removed = false; + Object.assign(videoData, presentColumns()); } else { - // File not found, but we still want to record the expected metadata location + // File not found: record the expected location but leave the removal + // columns alone, so a video deleted in-app while its job ran stays + // deleted and a new row simply awaits the file check. const assumedPath = actualFilePath || `${fallbackBasePath}.mp4`; videoData.filePath = assumedPath; videoData.fileSize = null; - videoData.removed = false; } // Upsert video into Videos table and ensure JobVideo relationship exists @@ -973,7 +975,7 @@ class JobModule { fileSize: files ? sizeOf(files.videoFileSize) : null, audioFilePath: files ? files.audioFilePath : null, audioFileSize: files ? sizeOf(files.audioFileSize) : null, - removed: !files, + ...(files ? presentColumns() : removedColumns(REMOVAL_REASONS.NOT_FOUND)), }); videosUpserts += 1; if (!files && unreadableOnDisk > 0) recreatedUnverified += 1; @@ -991,7 +993,7 @@ class JobModule { // column, which the zero-rows conflict check relies on. if (foundFile) { const [affected] = await Video.update( - { filePath: foundFile.path, fileSize: foundFile.size, removed: false }, + { filePath: foundFile.path, fileSize: foundFile.size, ...presentColumns() }, { where: unchangedSinceRead(videoInstance) } ); if (affected === 0) { @@ -1161,6 +1163,45 @@ class JobModule { return this.jobs; } + // The videos still linked to a job, as Download History shows them. + async _loadJobVideos(jobId) { + const jobVideos = await JobVideo.findAll({ + where: { job_id: jobId } + }); + + const videos = []; + for (const jobVideo of jobVideos) { + const video = await Video.findOne({ where: { id: jobVideo.video_id } }); + if (video) { + videos.push(video.dataValues); + } + } + return videos; + } + + /** + * Refresh the in-memory video lists of the given jobs from the database, + * after their rows changed outside the download path (emptying the trash + * deletes jobvideos rows). Jobs not held in memory are skipped; a failure + * is logged and the job keeps its previous list. + * @param {string[]} jobIds + */ + async reloadJobVideos(jobIds) { + for (const jobId of jobIds || []) { + const job = this.jobs[jobId]; + if (!job) continue; + try { + const videos = await this._loadJobVideos(jobId); + if (!job.data) { + job.data = {}; + } + job.data.videos = videos; + } catch (err) { + logger.error({ err, jobId }, 'Error reloading videos for job'); + } + } + } + // Lets listing pages (e.g. Download History) refetch when a job is enqueued or starts. emitJobsUpdated(jobId, status) { MessageEmitter.emitMessage('broadcast', null, 'download', 'jobsUpdated', { @@ -1264,17 +1305,7 @@ class JobModule { // This is especially important for multi-group downloads where each group // updates the job with only its own videos, potentially losing earlier videos try { - const jobVideos = await JobVideo.findAll({ - where: { job_id: jobId } - }); - - const videos = []; - for (const jobVideo of jobVideos) { - const video = await Video.findOne({ where: { id: jobVideo.video_id } }); - if (video) { - videos.push(video.dataValues); - } - } + const videos = await this._loadJobVideos(jobId); // Update in-memory structure with complete video list from DB if (!job.data) { diff --git a/server/modules/libraryRootCheck.js b/server/modules/libraryRootCheck.js new file mode 100644 index 000000000..b69a2a4e6 --- /dev/null +++ b/server/modules/libraryRootCheck.js @@ -0,0 +1,197 @@ +// Is the downloads folder really there? An unmounted drive or network share +// leaves its mount point missing or empty, and every stored path under it +// would then look gone. Nothing may be marked missing while this check fails. +const fs = require('fs'); +const path = require('path'); +const { Op } = require('sequelize'); +// pathBuilder, not the filesystem index: the index loads fileOperations, which +// binds child_process at require time, and this check needs neither. +const { isSubfolderDirectory } = require('./filesystem/pathBuilder'); +const { LOCAL_TEMP_DIR_NAME, M3U_FOLDER_NAME, HEALTHCHECK_FILE_PREFIX } = require('./filesystem/constants'); + +const LIBRARY_CHECK_REASONS = { + MISSING: 'missing', + EMPTY: 'empty', + UNREADABLE: 'unreadable', + SUBFOLDER_EMPTY: 'subfolder-empty', + SUBFOLDER_UNREADABLE: 'subfolder-unreadable', +}; +const SUBFOLDER_REASONS = [LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE]; + +// ENOTDIR: the folder's path now leads to a regular file, so the folder is gone. +const ABSENT_CODES = ['ENOENT', 'ENOTDIR']; + +// Shown to users, so no paths: the container path means nothing on the host, +// and a subfolder goes by its own name. +function describeLibraryCheck({ reason, path: checkedPath }) { + const subfolder = checkedPath ? path.basename(checkedPath) : ''; + switch (reason) { + case LIBRARY_CHECK_REASONS.MISSING: + return 'The downloads folder does not exist.'; + case LIBRARY_CHECK_REASONS.EMPTY: + return 'The downloads folder is empty.'; + case LIBRARY_CHECK_REASONS.UNREADABLE: + return 'The downloads folder could not be read.'; + case LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY: + return `The subfolder ${subfolder} is empty.`; + case LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE: + return `The subfolder ${subfolder} could not be read.`; + default: + return 'The downloads folder could not be checked.'; + } +} + +function failure(reason, checkedPath) { + return { ok: false, reason, path: checkedPath, message: describeLibraryCheck({ reason, path: checkedPath }) }; +} + +const SUBFOLDER_FAILURE_WORDING = [ + // Strictest first: an unreadable subfolder can never be explained away as emptied on purpose. + { reason: LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE, one: 'could not be read', many: 'could not be read' }, + { reason: LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, one: 'is empty', many: 'are empty' }, +]; + +function listNames(names) { + if (names.length <= 2) return names.join(' and '); + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +} + +/** + * One result for every failing subfolder: `reason` is the strictest one (so an + * unreadable share is never treated as emptied on purpose) and `path` the first + * subfolder failing that way; `paths` lists them all, and `message` names them. + * @param {Array<{reason: string, path: string}>} failures - in listing order + */ +function subfolderFailure(failures) { + const strictest = SUBFOLDER_FAILURE_WORDING.find(({ reason }) => failures.some((f) => f.reason === reason)).reason; + const sentences = [...SUBFOLDER_FAILURE_WORDING].reverse().map(({ reason, one, many }) => { + const names = failures.filter((f) => f.reason === reason).map((f) => path.basename(f.path)); + if (names.length === 0) return null; + return names.length === 1 ? `The subfolder ${names[0]} ${one}.` : `The subfolders ${listNames(names)} ${many}.`; + }).filter(Boolean); + return { + ok: false, + reason: strictest, + path: failures.find((f) => f.reason === strictest).path, + paths: failures.map((f) => f.path), + message: sentences.join(' '), + }; +} + +// Entries Youtarr creates itself, possibly on a bare mount point while the +// share is down, so they say nothing about whether the library is there. +function isYoutarrManagedEntry(name) { + return name === LOCAL_TEMP_DIR_NAME || name === M3U_FOLDER_NAME || name.startsWith(HEALTHCHECK_FILE_PREFIX); +} + +/** + * The root must exist and hold something; every `__subfolder` in it (a share + * of its own, on some setups) must hold something too. A root failure is + * reported alone; every failing subfolder is reported (see subfolderFailure), + * since each one only covers the files under it and two shares can be down at once. + * Entries Youtarr creates itself do not count (see isYoutarrManagedEntry). + * @param {string} baseDir - The downloads root + * @param {{ readdir?: Function }} [deps] - fs.promises.readdir, injectable for tests + * @returns {Promise<{ok: true}|{ok: false, reason: string, path: string, paths?: string[], message: string}>} + * paths: every failing subfolder, for subfolder reasons + */ +async function checkLibraryRoot(baseDir, { readdir = fs.promises.readdir } = {}) { + if (!baseDir) return failure(LIBRARY_CHECK_REASONS.MISSING, baseDir || ''); + + let entries; + try { + entries = (await readdir(baseDir, { withFileTypes: true })).filter((entry) => !isYoutarrManagedEntry(entry.name)); + } catch (err) { + const reason = ABSENT_CODES.includes(err.code) ? LIBRARY_CHECK_REASONS.MISSING : LIBRARY_CHECK_REASONS.UNREADABLE; + return failure(reason, baseDir); + } + if (entries.length === 0) return failure(LIBRARY_CHECK_REASONS.EMPTY, baseDir); + + const failures = []; + for (const entry of entries) { + if (!entry.isDirectory() || !isSubfolderDirectory(entry.name)) continue; + const subfolder = path.join(baseDir, entry.name); + let children; + try { + children = await readdir(subfolder, { withFileTypes: true }); + } catch (err) { + // Gone since the root was listed: not a mount problem. + if (ABSENT_CODES.includes(err.code)) continue; + failures.push({ reason: LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE, path: subfolder }); + continue; + } + if (children.length === 0) failures.push({ reason: LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY, path: subfolder }); + } + + return failures.length > 0 ? subfolderFailure(failures) : { ok: true }; +} + +// The failing subfolders of a subfolder-reason check. +function failingSubfolders(check) { + return check.paths ?? [check.path]; +} + +const withTrailingSep = (dir) => (dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`); + +/** + * Whether a failing check puts the given file in doubt: a root failure covers + * every file, a subfolder failure only the files under the failing subfolders. + * @param {{ok: boolean, reason?: string, path?: string, paths?: string[]}} check - a checkLibraryRoot result + * @param {string|null|undefined} filePath + * @returns {boolean} + */ +function isUnderLibraryCheckPath(check, filePath) { + if (!check || check.ok || !filePath) return false; + if (!SUBFOLDER_REASONS.includes(check.reason)) return true; + return failingSubfolders(check).some((dir) => filePath.startsWith(withTrailingSep(dir))); +} + +/** + * Whether a failing check covers a videos row: either of its files is in doubt. + * @param {{ok: boolean, reason?: string, path?: string}} check - a checkLibraryRoot result + * @param {{filePath?: (string|null), audioFilePath?: (string|null)}} row + * @returns {boolean} + */ +function isRowUnderLibraryCheck(check, row) { + return isUnderLibraryCheckPath(check, row.filePath) || isUnderLibraryCheckPath(check, row.audioFilePath); +} + +// A LIKE pattern matching every path under `dir`, with LIKE's own wildcards +// escaped: every subfolder name starts with two `_`. +function pathPrefixPattern(dir) { + return `${withTrailingSep(dir).replace(/[\\%_]/g, '\\$&')}%`; +} + +/** + * Whether a failing check covers any videos row the filter matches: for a + * root failure every such row, for a subfolder failure only the rows stored + * under the failing subfolders. An empty subfolder nothing was ever downloaded to (or + * that a channel move left behind) covers nothing, so it blocks nothing. + * @param {{ok: boolean, reason?: string, path?: string}} check - a checkLibraryRoot result + * @param {{count: Function}} Video - the Video model + * @param {Object} where - Sequelize filter for the rows that matter to the caller + * @returns {Promise<boolean>} + */ +async function libraryCheckCoversRows(check, Video, where) { + if (!check || check.ok) return false; + if (!SUBFOLDER_REASONS.includes(check.reason)) { + return (await Video.count({ where })) > 0; + } + const underSubfolder = { + [Op.or]: failingSubfolders(check).flatMap((dir) => { + const pattern = pathPrefixPattern(dir); + return [{ filePath: { [Op.like]: pattern } }, { audioFilePath: { [Op.like]: pattern } }]; + }), + }; + return (await Video.count({ where: { [Op.and]: [where, underSubfolder] } })) > 0; +} + +module.exports = { + LIBRARY_CHECK_REASONS, + checkLibraryRoot, + describeLibraryCheck, + isYoutarrManagedEntry, + isUnderLibraryCheckPath, + isRowUnderLibraryCheck, + libraryCheckCoversRows, +}; diff --git a/server/modules/m3uGenerator.js b/server/modules/m3uGenerator.js index 37d038853..9bdb2f786 100644 --- a/server/modules/m3uGenerator.js +++ b/server/modules/m3uGenerator.js @@ -9,8 +9,9 @@ const { resolveChannelFolderName, } = require('./filesystem/pathBuilder'); const configModule = require('./configModule'); +const { M3U_FOLDER_NAME } = require('./filesystem/constants'); +const { REMOVAL_STATES, removalState } = require('./videoRemovalState'); -const M3U_FOLDER_NAME = '__playlists__'; // Empty marker file that tells the Jellyfin and Emby library scanners to skip // this folder. Without it, those servers auto-import the .m3u files as duplicate // (and on Jellyfin, empty) playlists alongside the ones created via native API @@ -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/playlistDownloadModule.js b/server/modules/playlistDownloadModule.js index 937128f29..b0754bf5e 100644 --- a/server/modules/playlistDownloadModule.js +++ b/server/modules/playlistDownloadModule.js @@ -2,6 +2,7 @@ const { QueryTypes } = require('sequelize'); const playlistSelection = require('./download/playlistAutoSelection'); const logger = require('../logger'); const videoActivity = require('./download/videoActivity'); +const { findArchiveOnlyIds } = require('./archiveOnlyIds'); // Raw SQL: one grouped count for a whole page of playlists. "Downloaded" means // a usable file on disk, the same predicate as the playlist videos listing's @@ -34,8 +35,14 @@ class PlaylistDownloadModule { attributes: ['youtubeId', 'filePath', 'audioFilePath'], }) : []; const downloaded = new Set(existing.map((video) => video.youtubeId)); + // Without a re-download, yt-dlp also skips what the archive lists without a + // row (e.g. emptied from the trash with its archive entry kept); queued, such + // a video would never download and would be picked again on every sweep. + const archiveOnly = allowRedownload ? new Set() : await findArchiveOnlyIds( + candidates.map((row) => row.youtube_id).filter((id) => !downloaded.has(id)) + ); return { candidates: candidates.filter((row) => !downloaded.has(row.youtube_id) && - (!excludeActive || !videoActivity.isActive(row.youtube_id))), existing }; + !archiveOnly.has(row.youtube_id) && (!excludeActive || !videoActivity.isActive(row.youtube_id))), existing }; } /** diff --git a/server/modules/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/rescanRowUpdate.js b/server/modules/rescanRowUpdate.js index 76ecd6475..efe6c1fe3 100644 --- a/server/modules/rescanRowUpdate.js +++ b/server/modules/rescanRowUpdate.js @@ -6,6 +6,8 @@ // stat of each format's files at write time (resolveRescanUpdate), and the // caller writes it only if the row is still as read. +const { REMOVAL_REASONS, removedColumns, presentColumns } = require('./videoRemovalState'); + const PRESENT = 'present'; const MISSING = 'missing'; const UNKNOWN = 'unknown'; @@ -87,7 +89,9 @@ async function resolveRescanUpdate(row, fileInfo, probedResolution, stat) { // Nothing on disk and one format unchecked: can't tell, so write nothing. if (!statusKnown) return null; // Keep the stored paths: page-load checks look there for a returning file. - return row.removed ? null : { removed: true }; + // A row already removed (deleted in Youtarr, or marked as deleted) stays + // as recorded; only a present row becomes missing. + return row.removed ? null : removedColumns(REMOVAL_REASONS.NOT_FOUND); } const desired = {}; @@ -121,10 +125,10 @@ async function resolveRescanUpdate(row, fileInfo, probedResolution, stat) { } // A file is present, so the row isn't missing, even if the other format - // couldn't be checked (that format is left as stored above). + // couldn't be checked (that format is left as stored above). Clearing why + // it was removed comes with the flag. if (Object.keys(changes).length === 0 && !row.removed) return null; - changes.removed = false; - return changes; + return Object.assign(changes, presentColumns()); } module.exports = { isRescanCandidate, resolveRescanUpdate }; diff --git a/server/modules/rescanRunSummary.js b/server/modules/rescanRunSummary.js index d0ac28a78..af701b4f4 100644 --- a/server/modules/rescanRunSummary.js +++ b/server/modules/rescanRunSummary.js @@ -11,25 +11,33 @@ function count(value) { return Number(value) || 0; } -// Rows skipped because another writer changed them, and rows whose write -// failed; empty when there were none. +// Rows skipped because another writer changed them, rows whose write failed, +// and rows left unmarked because the downloads folder looked unmounted; +// empty when there were none. function describeRowProblems(result) { const parts = []; if (count(result.skippedChanged) > 0) parts.push(`${count(result.skippedChanged)} skipped (changed during the scan)`); if (count(result.failed) > 0) parts.push(`${count(result.failed)} failed`); + if (count(result.notMarkedMissing) > 0) { + // The check's message is a sentence; inside parentheses it drops its period. + const cause = result.libraryCheckMessage ? ` (${result.libraryCheckMessage.replace(/\.$/, '')})` : ''; + parts.push(`${count(result.notMarkedMissing)} not marked missing${cause}`); + } return parts; } function describe(outcome, result) { const scanned = count(result.processed).toLocaleString('en-US'); const problems = describeRowProblems(result); + // A manual "Rescan anyway" marked videos missing without the unmounted-drive check. + const skipped = result.libraryCheckSkipped ? ' The downloads folder check was skipped.' : ''; if (outcome === 'error') return result.errorMessage || 'Unknown error'; if (outcome === 'timed-out') { const suffix = problems.length > 0 ? ` (${problems.join(', ')})` : ''; - return `Reached the time limit after ${scanned} videos${suffix}; continues at the next run.`; + return `Reached the time limit after ${scanned} videos${suffix}; continues at the next run.${skipped}`; } const counts = [`${count(result.updated)} updated`, `${count(result.removed)} marked missing`, ...problems]; - return `Scanned ${scanned} videos: ${counts.join(', ')}.`; + return `Scanned ${scanned} videos: ${counts.join(', ')}.${skipped}`; } function toRunRecord(result = {}) { @@ -37,10 +45,11 @@ function toRunRecord(result = {}) { return { status: 'skipped', outcome: 'skipped', message: 'A rescan was already running.', details: null }; } let outcome = result.status || (result.timedOut ? 'timed-out' : 'completed'); - // A finished scan with failed row writes partly failed, like other scheduled - // tasks. A timed-out scan keeps its outcome so the Maintenance page still - // offers to continue it; its message carries the count. - if (outcome === 'completed' && count(result.failed) > 0) outcome = 'partial'; + // A finished scan with failed row writes, or with rows it could not mark + // missing because the downloads folder looked unmounted, partly failed, + // like other scheduled tasks. A timed-out scan keeps its outcome so the + // Maintenance page still offers to continue it; its message carries the count. + if (outcome === 'completed' && (count(result.failed) > 0 || count(result.notMarkedMissing) > 0)) outcome = 'partial'; return { status: outcome === 'error' || outcome === 'partial' ? 'error' : 'success', outcome, @@ -58,6 +67,9 @@ function toRunRecord(result = {}) { // Rows another writer changed after the rescan read them; left for a later run. videosSkipped: count(result.skippedChanged), videosFailed: count(result.failed), + // Rows left unmarked because the downloads folder looked unmounted. + videosNotMarkedMissing: count(result.notMarkedMissing), + ...(result.libraryCheckSkipped ? { libraryCheckSkipped: true } : {}), }, }; } @@ -78,6 +90,7 @@ function fromRunRecord(run) { videosScanned: count(details.videosScanned), filesFoundOnDisk: count(details.filesFoundOnDisk), errorMessage: status === 'error' ? (run.message || null) : null, + ...(details.libraryCheckSkipped ? { libraryCheckSkipped: true } : {}), }; } diff --git a/server/modules/videoDeletionModule.js b/server/modules/videoDeletionModule.js index 64a090102..f8ad0c680 100644 --- a/server/modules/videoDeletionModule.js +++ b/server/modules/videoDeletionModule.js @@ -5,9 +5,12 @@ 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'); +const { REMOVAL_REASONS, removedColumns } = require('./videoRemovalState'); +const { checkLibraryRoot, isUnderLibraryCheckPath, libraryCheckCoversRows } = require('./libraryRootCheck'); // Oldest-first removal (free-space and total-usage strategies) works in // batches and stops after a bounded number of them per run. @@ -15,6 +18,11 @@ const OLDEST_FIRST_BATCH_SIZE = 50; const OLDEST_FIRST_MAX_BATCHES = 10; const PLAN_SAMPLE_LIMIT = 10; +// What a delete records on the row unless the caller says otherwise: the +// user deleted it in the app. Automatic cleanup passes its own reason and +// the rule that selected the video. +const USER_DELETED_REMOVAL = { reason: REMOVAL_REASONS.USER_DELETED, detail: null }; + class VideoDeletionModule { constructor() {} @@ -95,9 +103,13 @@ class VideoDeletionModule { * Delete a single video by ID * Deletes the video directory from disk and marks the video as removed in the database * @param {number} videoId - The database ID of the video to delete + * @param {{reason: string, detail: (string|null)}} [removal] - Why the video is removed (see videoRemovalState) + * @param {{libraryCheck?: (Object|null)}} [options] - libraryCheck: a checkLibraryRoot result; when it fails + * and covers this video's file, the delete is refused rather than stamping a row whose file may be on an + * unmounted share * @returns {Promise<{success: boolean, videoId: number, error?: string}>} */ - async deleteVideoById(videoId) { + async deleteVideoById(videoId, removal = USER_DELETED_REMOVAL, { libraryCheck = null } = {}) { try { // Fetch video from database const video = await Video.findByPk(videoId); @@ -125,15 +137,24 @@ class VideoDeletionModule { if (!primaryPath) { // No file path, just mark as removed in database - await video.update({ removed: true }); + await video.update(removedColumns(removal.reason, { detail: removal.detail })); return { success: true, videoId, + youtubeId: video.youtubeId, channelId: video.channel_id, message: 'Video marked as removed (no file path)' }; } + if (libraryCheck && isUnderLibraryCheckPath(libraryCheck, primaryPath)) { + return { + success: false, + videoId, + error: `${libraryCheck.message} Nothing was deleted; check that the drive is mounted.` + }; + } + // Get the video directory path // Nested: filePath = /path/to/channel/channel - title - id/video.mp4 // Flat: filePath = /path/to/channel/video.mp4 @@ -200,7 +221,7 @@ class VideoDeletionModule { } // Mark video as removed in database - await video.update({ removed: true }); + await video.update(removedColumns(removal.reason, { detail: removal.detail })); // Best-effort cleanup of empty channel directory await this._tryCleanupChannelDirectory(primaryPath, flat); @@ -208,6 +229,7 @@ class VideoDeletionModule { return { success: true, videoId, + youtubeId: video.youtubeId, channelId: video.channel_id, message: 'Video deleted successfully' }; @@ -221,23 +243,48 @@ class VideoDeletionModule { } } + // One check per batch: a delete must not stamp a row whose file sits on an + // unmounted share. A check that itself fails does not block deletes. + async _checkLibraryForDeletes() { + try { + return await checkLibraryRoot(configModule.directoryPath); + } catch (err) { + logger.warn({ err }, 'Could not check the downloads folder before deleting; deleting anyway'); + return null; + } + } + + // Why the library must not be touched right now: the downloads folder (or a + // subfolder downloaded videos are stored in) looks unmounted (see + // libraryRootCheck). A fresh install with nothing recorded, or an empty + // subfolder no video is stored in, never blocks. Null when fine. + async _libraryBlock() { + const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + if (!(await libraryCheckCoversRows(libraryCheck, Video, { removed: false }))) return null; + return { reason: libraryCheck.reason, message: libraryCheck.message }; + } + /** * Delete multiple videos * @param {number[]} videoIds - Array of video IDs to delete + * @param {{reason: string, detail: (string|null)}} [removal] - Why the videos are removed * @returns {Promise<{success: boolean, deleted: number[], failed: Array<{videoId: number, error: string}>}>} */ - async deleteVideos(videoIds) { + async deleteVideos(videoIds, removal = USER_DELETED_REMOVAL) { const deleted = []; const failed = []; const affectedChannelIds = []; + const deletedYoutubeIds = []; + const libraryCheck = videoIds.length > 0 ? await this._checkLibraryForDeletes() : null; // Process deletions sequentially to avoid overwhelming the file system for (const videoId of videoIds) { - const result = await this.deleteVideoById(videoId); + const result = await this.deleteVideoById(videoId, removal, { libraryCheck }); if (result.success) { deleted.push(videoId); affectedChannelIds.push(result.channelId); + deletedYoutubeIds.push(result.youtubeId); } else { failed.push({ videoId, @@ -247,6 +294,7 @@ class VideoDeletionModule { } this._regenerateM3usForChannels(affectedChannelIds); + this._refreshPlaylistsForVideos(deletedYoutubeIds); if (deleted.length > 0) this._refreshDownloadPauseIfPaused(); return { @@ -259,12 +307,14 @@ class VideoDeletionModule { /** * Delete videos by YouTube IDs * @param {string[]} youtubeIds - Array of YouTube video IDs + * @param {{reason: string, detail: (string|null)}} [removal] - Why the videos are removed * @returns {Promise<{success: boolean, deleted: string[], failed: Array<{youtubeId: string, error: string}>}>} */ - async deleteVideosByYoutubeIds(youtubeIds) { + async deleteVideosByYoutubeIds(youtubeIds, removal = USER_DELETED_REMOVAL) { const deleted = []; const failed = []; const affectedChannelIds = []; + const libraryCheck = youtubeIds.length > 0 ? await this._checkLibraryForDeletes() : null; for (const youtubeId of youtubeIds) { try { @@ -282,7 +332,7 @@ class VideoDeletionModule { } // Delete using the database ID - const result = await this.deleteVideoById(video.id); + const result = await this.deleteVideoById(video.id, removal, { libraryCheck }); if (result.success) { deleted.push(youtubeId); @@ -302,6 +352,7 @@ class VideoDeletionModule { } this._regenerateM3usForChannels(affectedChannelIds); + this._refreshPlaylistsForVideos(deleted); if (deleted.length > 0) this._refreshDownloadPauseIfPaused(); return { @@ -323,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 @@ -444,7 +505,8 @@ ${excludeClause} ORDER BY timeCreated ASC * Unlike _tryCleanupChannelDirectory (which only runs after a video deletion), this * proactively finds directories that are already empty (or contain only ignorable files * like poster.jpg) and cleans them up. Handles both root-level and subfolder-level channels. - * @returns {Promise<{removed: string[], errors: string[]}>} + * @returns {Promise<{removed: string[], errors: string[], skipped?: {reason: string, message: string}}>} + * skipped: the sweep did not run because the downloads folder looked unmounted */ async cleanupOrphanDirectories() { const baseDir = configModule.directoryPath; @@ -456,6 +518,14 @@ ${excludeClause} ORDER BY timeCreated ASC return { removed, errors }; } + // Never tidy folders while the library looks unmounted: an empty subfolder + // mount point would read as an orphan. + const block = await this._libraryBlock(); + if (block) { + logger.warn({ libraryCheck: block }, '[Orphan Cleanup] The downloads folder looks unmounted; skipping the sweep'); + return { removed, errors, skipped: block }; + } + try { const topLevelDirs = await listSubdirectories(baseDir); @@ -516,15 +586,17 @@ ${excludeClause} ORDER BY timeCreated ASC * @param {object} options.bucket - The strategy's entry in result.plan * @param {string} options.deletedKey - Result counter to increment, e.g. 'deletedBySpace' * @param {string} options.label - Strategy name for log messages + * @param {{reason: string, detail: (string|null)}} options.removal - Recorded on each deleted row * @param {boolean} options.dryRun * @param {boolean} options.includeSamples * @param {object} options.result - The cleanup result being built * @returns {Promise<{selectedIds: number[]}>} * @private */ - async _removeOldestUntilFreed({ bytesToFree, excludeIds, bucket, deletedKey, label, dryRun, includeSamples, result }) { + async _removeOldestUntilFreed({ bytesToFree, excludeIds, bucket, deletedKey, label, removal, dryRun, includeSamples, result }) { const triedIds = new Set(); const affectedChannelIds = []; + const deletedYoutubeIds = []; let freedSoFar = 0; let iterations = 0; @@ -567,13 +639,14 @@ ${excludeClause} ORDER BY timeCreated ASC continue; } - const deleteResult = await this.deleteVideoById(video.id); + const deleteResult = await this.deleteVideoById(video.id, removal); if (deleteResult.success) { freedSoFar += videoSize; batchFreed += videoSize; batchDeletedCount += 1; affectedChannelIds.push(deleteResult.channelId); + deletedYoutubeIds.push(deleteResult.youtubeId); result[deletedKey] += 1; bucket.deletedCount += 1; @@ -609,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) }; @@ -730,6 +804,19 @@ ${excludeClause} ORDER BY timeCreated ASC return result; } + // An unmounted drive makes every file look gone, and a run then would stamp + // healthy videos "deleted by automatic cleanup". With downloaded videos on + // record and the folder looking unmounted, the run is skipped; a fresh + // install with nothing recorded runs normally. + if (!dryRun) { + const block = await this._libraryBlock(); + if (block) { + logger.warn({ libraryCheck: block }, '[Auto-Removal] The downloads folder looks unmounted; skipping cleanup'); + result.skipped = block; + return result; + } + } + // The N most recently downloaded videos are protected from every strategy. // If the guard query fails we abort: running without it would delete the // videos this setting exists to keep. @@ -801,7 +888,10 @@ ${excludeClause} ORDER BY timeCreated ASC } else if (oldVideos.length > 0) { logger.info({ count: oldVideos.length }, '[Auto-Removal] Deleting videos older than threshold'); const videoIds = oldVideos.map(v => v.id); - const deleteResult = await this.deleteVideos(videoIds); + const deleteResult = await this.deleteVideos(videoIds, { + reason: REMOVAL_REASONS.AUTO_REMOVAL, + detail: `older than ${thresholdDays} days` + }); result.deletedByAge = deleteResult.deleted.length; result.plan.ageStrategy.deletedCount = deleteResult.deleted.length; @@ -883,7 +973,10 @@ ${excludeClause} ORDER BY timeCreated ASC } else if (watchedVideos.length > 0) { logger.info({ count: watchedVideos.length }, '[Auto-Removal] Deleting watched videos'); const videoIds = watchedVideos.map(v => v.id); - const deleteResult = await this.deleteVideos(videoIds); + const deleteResult = await this.deleteVideos(videoIds, { + reason: REMOVAL_REASONS.AUTO_REMOVAL, + detail: minDaysSinceWatched > 0 ? `watched at least ${minDaysSinceWatched} days ago` : 'watched' + }); result.deletedByWatched = deleteResult.deleted.length; result.plan.watchedStrategy.deletedCount = deleteResult.deleted.length; @@ -954,6 +1047,10 @@ ${excludeClause} ORDER BY timeCreated ASC bucket: result.plan.spaceStrategy, deletedKey: 'deletedBySpace', label: 'space-based', + removal: { + reason: REMOVAL_REASONS.AUTO_REMOVAL, + detail: `to keep ${config.autoRemovalFreeSpaceThreshold} free` + }, dryRun, includeSamples, result @@ -1011,6 +1108,10 @@ ${excludeClause} ORDER BY timeCreated ASC bucket, deletedKey: 'deletedByUsage', label: 'usage-based', + removal: { + reason: REMOVAL_REASONS.AUTO_REMOVAL, + detail: `over the ${config.autoRemovalUsageLimit} total size limit` + }, dryRun, includeSamples, result diff --git a/server/modules/videoLocalStatus.js b/server/modules/videoLocalStatus.js index e882783eb..78487f2af 100644 --- a/server/modules/videoLocalStatus.js +++ b/server/modules/videoLocalStatus.js @@ -1,30 +1,38 @@ -const { Video } = require('../models'); -const ChannelVideo = require('../models/channelvideo'); -const archiveModule = require('./archiveModule'); +const { Video, Channel } = require('../models'); +const { findArchiveOnlyIds } = require('./archiveOnlyIds'); +const { REMOVAL_STATES, removalState } = require('./videoRemovalState'); -// IDs yt-dlp would skip as already downloaded even though no Videos row -// exists. Ignored videos are left out: ignoring writes the archive entry on -// purpose, and an explicit download removes it again. -async function findArchiveOnlyIds(youtubeIds) { - const archived = archiveModule.filterArchivedVideoIds(youtubeIds); - if (archived.size === 0) return archived; - const ignored = await ChannelVideo.findAll({ - where: { youtube_id: [...archived], ignored: true }, - attributes: ['youtube_id'], +const toIso = (value) => (value ? new Date(value).toISOString() : null); + +// Search results carry a channelId; the local-status endpoint's do not, so the +// flag is only set where it can be answered and a refresh never clears it. +async function applyChannelSubscribed(results) { + const withChannel = results.filter(r => typeof r.channelId === 'string' && r.channelId); + if (withChannel.length === 0) return; + const channelIds = [...new Set(withChannel.map(r => r.channelId))]; + const enabled = await Channel.findAll({ + where: { channel_id: channelIds, enabled: true }, + attributes: ['channel_id'], }); - for (const row of ignored) archived.delete(row.youtube_id); - return archived; + const subscribed = new Set(enabled.map(c => c.channel_id)); + for (const r of withChannel) { + r.channelSubscribed = subscribed.has(r.channelId); + } } async function applyLocalVideoStatus(results) { const youtubeIds = results.map(r => r.youtubeId).filter(Boolean); if (youtubeIds.length === 0) return; + await applyChannelSubscribed(results); const existing = await Video.findAll({ where: { youtubeId: youtubeIds }, attributes: [ 'id', 'youtubeId', 'removed', + 'removed_reason', + 'removed_at', + 'removed_detail', 'filePath', 'fileSize', 'audioFilePath', @@ -43,7 +51,11 @@ async function applyLocalVideoStatus(results) { r.inArchive = archiveOnlyIds.has(r.youtubeId); const record = recordByYoutubeId.get(r.youtubeId); if (!record) continue; - r.status = record.removed ? 'missing' : 'downloaded'; + const state = removalState(record); + r.status = state === REMOVAL_STATES.PRESENT ? 'downloaded' : state; + r.removedReason = record.removed_reason ?? null; + r.removedAt = toIso(record.removed_at); + r.removedDetail = record.removed_detail ?? null; r.databaseId = record.id; r.filePath = record.filePath; r.fileSize = record.fileSize; diff --git a/server/modules/videoPersistence.js b/server/modules/videoPersistence.js index 77e948301..1e192e04e 100644 --- a/server/modules/videoPersistence.js +++ b/server/modules/videoPersistence.js @@ -5,6 +5,7 @@ const ChannelVideo = require('../models/channelvideo'); const channelVideoReanchor = require('./channelVideoReanchor'); const { PUBLISHED_AT_SOURCE } = require('./constants/publishedAtSource'); const VideoMetadataProcessor = require('./download/videoMetadataProcessor'); +const { REMOVAL_COLUMNS, presentColumns } = require('./videoRemovalState'); const logger = require('../logger'); /** @@ -44,12 +45,13 @@ class VideoPersistence { } if (hasVerifiedFile) { - data.removed = false; + Object.assign(data, presentColumns()); data.last_downloaded_at = new Date(); logger.debug({ youtubeId: video.youtubeId, fileSize: video.fileSize, audioFileSize: video.audioFileSize, isNewVideo }, 'Setting last_downloaded_at - file verified'); } else { if (!isNewVideo) { - delete data.removed; + // Nothing verified: leave the row's removed state and reason alone. + for (const column of REMOVAL_COLUMNS) delete data[column]; } logger.debug({ youtubeId: video.youtubeId, filePath: video.filePath, fileSize: video.fileSize, isNewVideo }, 'NOT setting last_downloaded_at - hasVerifiedFile is false'); } diff --git a/server/modules/videoRemovalState.js b/server/modules/videoRemovalState.js new file mode 100644 index 000000000..9e4659d45 --- /dev/null +++ b/server/modules/videoRemovalState.js @@ -0,0 +1,95 @@ +// Why a videos row has no file, layered on the `removed` flag. +// +// `removed` stays the single "no file" flag every query reads. The reason +// columns say who removed it: a removed row whose reason is 'not_found' is +// MISSING (Youtarr expected a file and could not find it); a removed row with +// any other reason, including none (rows removed before the reason was +// recorded), is DELETED (Youtarr removed the file, or the user said it is gone). +const { Op } = require('sequelize'); + +const REMOVAL_REASONS = { + USER_DELETED: 'user_deleted', + AUTO_REMOVAL: 'auto_removal', + NOT_FOUND: 'not_found', + MARKED_DELETED: 'marked_deleted', +}; + +const REMOVAL_STATES = { + PRESENT: 'present', + DELETED: 'deleted', + MISSING: 'missing', +}; + +const REMOVAL_COLUMNS = ['removed', 'removed_reason', 'removed_at', 'removed_detail']; + +const KNOWN_REASONS = new Set(Object.values(REMOVAL_REASONS)); + +/** + * @param {Object} row - A videos row (raw or model instance); `removed` may be 0/1 or boolean + * @returns {string} One of REMOVAL_STATES + */ +function removalState(row) { + if (!row.removed) return REMOVAL_STATES.PRESENT; + return row.removed_reason === REMOVAL_REASONS.NOT_FOUND ? REMOVAL_STATES.MISSING : REMOVAL_STATES.DELETED; +} + +/** + * Columns to write when a row loses its file. + * @param {string} reason - One of REMOVAL_REASONS + * @param {{ detail?: string|null, at?: Date }} [options] - detail: the rule, for automatic cleanup + */ +function removedColumns(reason, { detail = null, at = new Date() } = {}) { + if (!KNOWN_REASONS.has(reason)) { + throw new Error(`Unknown removal reason: ${reason}`); + } + return { removed: true, removed_reason: reason, removed_at: at, removed_detail: detail }; +} + +/** Columns to write when a row's file is on disk (again). */ +function presentColumns() { + return { removed: false, removed_reason: null, removed_at: null, removed_detail: null }; +} + +function assertFilterableState(state) { + if (state !== REMOVAL_STATES.DELETED && state !== REMOVAL_STATES.MISSING) { + throw new Error(`Unknown removal state: ${state}`); + } +} + +/** Sequelize `where` matching rows in the given state ('deleted' | 'missing'). */ +function stateWhere(state) { + assertFilterableState(state); + if (state === REMOVAL_STATES.MISSING) { + return { removed: true, removed_reason: REMOVAL_REASONS.NOT_FOUND }; + } + return { + removed: true, + [Op.or]: [{ removed_reason: null }, { removed_reason: { [Op.ne]: REMOVAL_REASONS.NOT_FOUND } }], + }; +} + +/** Sequelize `where` matching every row NOT in the given state, for 'exclude' filters. */ +function notStateWhere(state) { + assertFilterableState(state); + if (state === REMOVAL_STATES.MISSING) { + return { + [Op.or]: [ + { removed: false }, + { removed_reason: null }, + { removed_reason: { [Op.ne]: REMOVAL_REASONS.NOT_FOUND } }, + ], + }; + } + return { [Op.or]: [{ removed: false }, { removed_reason: REMOVAL_REASONS.NOT_FOUND }] }; +} + +module.exports = { + REMOVAL_REASONS, + REMOVAL_STATES, + REMOVAL_COLUMNS, + removalState, + removedColumns, + presentColumns, + stateWhere, + notStateWhere, +}; diff --git a/server/modules/videoSearchModule.js b/server/modules/videoSearchModule.js index 6d3b5258a..465b06096 100644 --- a/server/modules/videoSearchModule.js +++ b/server/modules/videoSearchModule.js @@ -6,6 +6,7 @@ const youtubeApi = require('./youtubeApi'); const SEARCH_TIMEOUT_MS = 60_000; const ALLOWED_COUNTS = [10, 25, 50, 100]; +const LIVE_STATUSES_TO_SKIP = new Set(['is_live', 'is_upcoming']); class SearchCanceledError extends Error { constructor() { super('Search canceled'); this.name = 'SearchCanceledError'; } @@ -54,8 +55,10 @@ class VideoSearchModule { source = 'yt-dlp'; } + // Results stay in the order the source returned them: YouTube's relevance + // ranking is the one thing a client cannot recompute, so every other order + // is the client's to apply. if (results.length > 0) await this._applyLocalStatus(results); - this._sortByPublishedAtDesc(results); logger.info({ query, count, resultCount: results.length, source }, 'video search complete'); return results; } @@ -69,6 +72,8 @@ class VideoSearchModule { if (!trimmed) continue; try { const entry = JSON.parse(trimmed); + // The API path drops these too; a download would record a live stream indefinitely. + if (LIVE_STATUSES_TO_SKIP.has(entry.live_status)) continue; const normalized = this._normalize(entry); if (normalized.youtubeId && seenIds.has(normalized.youtubeId)) continue; if (normalized.youtubeId) seenIds.add(normalized.youtubeId); @@ -120,17 +125,6 @@ class VideoSearchModule { async _applyLocalStatus(results) { await applyLocalVideoStatus(results); } - - _sortByPublishedAtDesc(results) { - results.sort((a, b) => { - if (a.publishedAt && b.publishedAt) { - return b.publishedAt.localeCompare(a.publishedAt); - } - if (a.publishedAt) return -1; - if (b.publishedAt) return 1; - return 0; - }); - } } module.exports = new VideoSearchModule(); diff --git a/server/modules/videoTrashModule.js b/server/modules/videoTrashModule.js new file mode 100644 index 000000000..5e10bf4a0 --- /dev/null +++ b/server/modules/videoTrashModule.js @@ -0,0 +1,284 @@ +// The trash: videos that have no file any more (see videoRemovalState for +// deleted vs. missing). Counts them, turns missing videos into deleted ones, +// and (purge) forgets them entirely. +const path = require('path'); +const fs = require('fs'); +const { Op } = require('sequelize'); +const { sequelize } = require('../db.js'); +const { Video, JobVideo, VideoWatchStatus, PlaylistVideo } = require('../models'); +const configModule = require('./configModule'); +const messageEmitter = require('./messageEmitter'); +const archiveModule = require('./archiveModule'); +const archiveAccess = require('./download/archiveAccess'); +const logger = require('../logger'); +const { LIBRARY_CHECK_REASONS, checkLibraryRoot, libraryCheckCoversRows } = require('./libraryRootCheck'); +const createLimiter = require('./subscriptionImport/concurrencyLimiter'); +const { REMOVAL_REASONS, REMOVAL_STATES, stateWhere, notStateWhere, removedColumns } = require('./videoRemovalState'); + +// Rows are forgotten in chunks of this size, each in its own transaction. +const PURGE_CHUNK_SIZE = 500; +// Check results that can also mean the user emptied the folder on purpose, +// the only ones "mark all missing as deleted" may be told to look past. +const EMPTY_FOLDER_REASONS = [LIBRARY_CHECK_REASONS.EMPTY, LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY]; +const PASSING_CHECK = { ok: true }; +// Cached files (info.json, thumbnails) are unlinked this many at a time. +const UNLINK_CONCURRENCY = 16; + +// Thrown when an action over every missing video is asked for while the +// downloads folder looks unmounted: those videos may well be fine. +class LibraryUnavailableError extends Error { + constructor(libraryCheck) { + super(libraryCheck.message); + this.name = 'LibraryUnavailableError'; + this.code = 'LIBRARY_UNAVAILABLE'; + this.libraryCheck = libraryCheck; + } +} + +class PurgeRunningError extends Error { + constructor() { + super('A purge is already running'); + this.name = 'PurgeRunningError'; + this.code = 'PURGE_RUNNING'; + } +} + +function normalizeLibraryCheck(check) { + return { + ok: check.ok, + reason: check.reason ?? null, + path: check.path ?? null, + message: check.message ?? null, + }; +} + +class VideoTrashModule { + constructor() { + this._purgeRunning = false; + } + + /** + * @returns {Promise<{deleted: number, missing: number, legacy: number, libraryCheck: Object, + * missingActionsBlocked: boolean, downloadsActive: boolean}>} + * legacy: deleted rows removed before the reason was recorded (a subset of deleted); + * libraryCheck: failing while it covers present or missing videos (the Maintenance card warns and + * offers Rescan anyway), passing otherwise; missingActionsBlocked: it covers missing videos, so + * marking all of them as deleted or emptying them from the trash is refused; + * downloadsActive: a purge that removes archive entries would be refused right now + */ + async getRemovedSummary() { + const deleted = await Video.count({ where: stateWhere(REMOVAL_STATES.DELETED) }); + const missing = await Video.count({ where: stateWhere(REMOVAL_STATES.MISSING) }); + const legacy = await Video.count({ where: { removed: true, removed_reason: null } }); + const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + // Present videos count for the warning: their missing marks are held back, + // and Rescan anyway is how a user who emptied the folder releases them. + const coversRecorded = await libraryCheckCoversRows(libraryCheck, Video, notStateWhere(REMOVAL_STATES.DELETED)); + const missingActionsBlocked = coversRecorded + && await libraryCheckCoversRows(libraryCheck, Video, stateWhere(REMOVAL_STATES.MISSING)); + return { + deleted, + missing, + legacy, + libraryCheck: normalizeLibraryCheck(coversRecorded ? libraryCheck : PASSING_CHECK), + missingActionsBlocked, + downloadsActive: archiveAccess.isDownloading(), + }; + } + + // The failing library check that puts missing videos in doubt, or null. The + // missing-video actions touch nothing else: present videos under a failing + // folder are already held back by the file check and the rescan. + async _checkCoveringMissing() { + const libraryCheck = await checkLibraryRoot(configModule.directoryPath); + const covers = await libraryCheckCoversRows(libraryCheck, Video, stateWhere(REMOVAL_STATES.MISSING)); + return covers ? libraryCheck : null; + } + + /** + * Turn missing videos into deleted ones. Only rows that are missing change; + * present and deleted ids in the selection are left alone. + * @param {{videoIds?: (number[]|null), all?: boolean, ignoreLibraryCheck?: boolean}} options - + * ignoreLibraryCheck: the user emptied the downloads folder (or a subfolder) on purpose; honoured + * only while the check fails because a folder is empty, never because one is gone or unreadable + * @returns {Promise<{updated: number}>} + * @throws {LibraryUnavailableError} for `all` while the downloads folder looks unmounted + */ + async markMissingAsDeleted({ videoIds = null, all = false, ignoreLibraryCheck = false } = {}) { + if (all) { + const libraryCheck = await this._checkCoveringMissing(); + if (libraryCheck) { + if (!ignoreLibraryCheck || !EMPTY_FOLDER_REASONS.includes(libraryCheck.reason)) { + throw new LibraryUnavailableError(libraryCheck); + } + logger.warn({ libraryCheck }, 'Marking every missing video as deleted without the downloads folder check, by request'); + } + } + + const where = { ...stateWhere(REMOVAL_STATES.MISSING) }; + if (!all) where.id = videoIds; + + const [updated] = await Video.update(removedColumns(REMOVAL_REASONS.MARKED_DELETED), { where }); + if (updated > 0) { + messageEmitter.emitMessage('broadcast', null, 'videos', 'videosUpdated', { source: 'mark-deleted', updated }); + } + return { updated }; + } + + isPurgeRunning() { + return this._purgeRunning; + } + + /** + * Forget removed videos entirely: their videos row, job links, synced watch + * status, playlist download dates, cached info.json and thumbnails, and (if + * asked) their download archive entries. Nothing on disk under the + * downloads folder is touched. + * @param {{states: string[], removeFromArchive: boolean}} options - states: 'deleted' and/or 'missing' + * @param {{reloadJobVideos?: Function}} [deps] - Download History holds jobs in memory; given, it is + * called with the affected job ids before the pages are told to refetch, so they never see ghosts + * @returns {Promise<{purged: number, archiveEntriesRemoved: number, jobIds: string[]}>} + * @throws {PurgeRunningError} while another purge runs + * @throws {LibraryUnavailableError} for missing videos while the downloads folder looks unmounted + * @throws with code ARCHIVE_BUSY if archive removal would overlap a download + * @throws a chunk failure after refreshing history and listings for committed chunks; + * archive failures roll back that chunk's database changes so it can be retried + */ + async purgeRemoved({ states, removeFromArchive }, { reloadJobVideos = null } = {}) { + if (this._purgeRunning) throw new PurgeRunningError(); + // Taken before the library check awaits, so a second request cannot slip in meanwhile. + this._purgeRunning = true; + let releaseArchive = null; + try { + if (removeFromArchive) releaseArchive = archiveAccess.acquirePurge(); + if (states.includes(REMOVAL_STATES.MISSING)) { + const libraryCheck = await this._checkCoveringMissing(); + if (libraryCheck) throw new LibraryUnavailableError(libraryCheck); + } + + const jobIds = new Set(); + const purgedYoutubeIds = []; + const archiveProgress = { removed: 0 }; + let failure = null; + try { + await this._forgetInChunks(states.map((state) => stateWhere(state)), jobIds, purgedYoutubeIds, removeFromArchive, archiveProgress); + } catch (err) { + // Chunks that committed before the failure are gone for good, so the + // steps below still run for them; the failure is rethrown after. + failure = err; + } + + const purged = purgedYoutubeIds.length; + const archiveEntriesRemoved = archiveProgress.removed; + + // Refresh the in-memory jobs first: the pages refetch on the broadcast, + // and a refetch that lands before the reload would still show the ghosts. + if (jobIds.size > 0 && reloadJobVideos) { + try { + await reloadJobVideos([...jobIds]); + } catch (err) { + logger.error({ err, jobsAffected: jobIds.size }, 'Purged videos but could not refresh the download history'); + } + } + + if (purged > 0) { + logger.info( + { purged, archiveEntriesRemoved, states, jobsAffected: jobIds.size }, + failure ? 'Emptied part of the trash before a failure' : 'Emptied the trash' + ); + messageEmitter.emitMessage('broadcast', null, 'videos', 'videosUpdated', { source: 'purge', purged }); + messageEmitter.emitMessage('broadcast', null, 'videos', 'jobsUpdated', { source: 'purge' }); + } + if (failure) throw failure; + return { purged, archiveEntriesRemoved, jobIds: [...jobIds] }; + } finally { + if (releaseArchive) releaseArchive(); + this._purgeRunning = false; + } + } + + /** + * Forget the matching rows chunk by chunk, each chunk in its own transaction. + * Each chunk's job ids and YouTube ids are added to the given collections + * once it commits, so the caller still has them if a later chunk throws. + * @param {Object[]} stateClauses - stateWhere() clauses, any of which a row may match + * @param {Set<string>} jobIds - Jobs that lost videos + * @param {string[]} purgedYoutubeIds - Videos forgotten so far + * @param {boolean} removeFromArchive - Remove archive entries before committing each chunk + * @param {{removed: number}} archiveProgress - Archive entries removed by committed chunks + */ + async _forgetInChunks(stateClauses, jobIds, purgedYoutubeIds, removeFromArchive, archiveProgress) { + const where = { [Op.or]: stateClauses }; + for (;;) { + // Each chunk's rows are gone once it commits, so the same query finds the next chunk. + const candidates = await Video.findAll({ where, attributes: ['id', 'youtubeId'], limit: PURGE_CHUNK_SIZE, raw: true }); + if (candidates.length === 0) return; + + let linkedJobIds = []; + let archiveEntriesRemoved = 0; + const confirmed = await sequelize.transaction(async (transaction) => { + // Re-read under lock with the state predicate: a row re-downloaded or + // restored since the select is present again and must stay. + const rows = await Video.findAll({ + where: { id: candidates.map((row) => row.id), [Op.or]: stateClauses }, + attributes: ['id', 'youtubeId'], + transaction, + lock: true, + raw: true, + }); + if (rows.length === 0) return rows; + const ids = rows.map((row) => row.id); + const youtubeIds = rows.map((row) => row.youtubeId); + + const links = await JobVideo.findAll({ where: { video_id: ids }, attributes: ['job_id'], transaction, raw: true }); + linkedJobIds = links.map((link) => link.job_id); + // jobvideos.video_id has a restricting foreign key: children first. + await VideoWatchStatus.destroy({ where: { video_id: ids }, transaction }); + await JobVideo.destroy({ where: { video_id: ids }, transaction }); + await PlaylistVideo.update({ downloaded_at: null }, { where: { youtube_id: youtubeIds }, transaction }); + await Video.destroy({ where: { id: ids, [Op.or]: stateClauses }, transaction }); + // An archive error must abort the transaction: otherwise its IDs would + // be lost and another purge could never retry the requested cleanup. + // If the subsequent commit fails, rows remain and removing their + // already-absent archive entries on retry is harmless. + if (removeFromArchive) { + archiveEntriesRemoved = await archiveModule.removeVideosFromArchive(youtubeIds); + } + return rows; + }); + + if (confirmed.length === 0) { + logger.warn({ count: candidates.length }, 'Purge chunk had nothing left to forget; stopping'); + return; + } + for (const jobId of linkedJobIds) jobIds.add(jobId); + archiveProgress.removed += archiveEntriesRemoved; + const youtubeIds = confirmed.map((row) => row.youtubeId); + purgedYoutubeIds.push(...youtubeIds); + // The cached info.json is what library repair would recreate the row from. + await this._removeCachedFiles(youtubeIds); + } + } + + async _removeCachedFiles(youtubeIds) { + const infoDir = path.join(configModule.getJobsPath(), 'info'); + const imageDir = configModule.getImagePath(); + const limit = createLimiter(UNLINK_CONCURRENCY); + const paths = youtubeIds.flatMap((youtubeId) => [ + path.join(infoDir, `${youtubeId}.info.json`), + path.join(imageDir, `videothumb-${youtubeId}.jpg`), + path.join(imageDir, `videothumb-${youtubeId}-small.jpg`), + ]); + await Promise.all(paths.map((filePath) => limit(async () => { + try { + await fs.promises.unlink(filePath); + } catch (err) { + if (err.code !== 'ENOENT') { + logger.warn({ err, filePath }, 'Could not remove a purged video\'s cached file'); + } + } + }))); + } +} + +module.exports = Object.assign(new VideoTrashModule(), { LibraryUnavailableError, PurgeRunningError }); diff --git a/server/modules/videosModule.js b/server/modules/videosModule.js index 3a27c1068..dd0f84c94 100644 --- a/server/modules/videosModule.js +++ b/server/modules/videosModule.js @@ -18,6 +18,8 @@ const createLimiter = require('./subscriptionImport/concurrencyLimiter'); const { isRescanCandidate, resolveRescanUpdate } = require('./rescanRowUpdate'); const { unchangedSinceRead, GUARDED_COLUMNS } = require('./videoRowGuard'); const youtubeExistenceCheck = require('./youtubeExistenceCheck'); +const { LIBRARY_CHECK_REASONS, checkLibraryRoot, isRowUnderLibraryCheck, libraryCheckCoversRows } = require('./libraryRootCheck'); +const { REMOVAL_STATES, stateWhere, notStateWhere } = require('./videoRemovalState'); const { EXISTENCE } = youtubeExistenceCheck; @@ -52,6 +54,7 @@ class VideosModule { channelFilter = '', protectedFilter = 'off', missingFilter = 'off', + deletedFilter = 'off', watchedFilter = 'off', maxRating = null, } = options; @@ -90,10 +93,18 @@ class VideosModule { where.protected = false; } - if (missingFilter === 'only') { - where.removed = true; - } else if (missingFilter === 'exclude') { - where.removed = false; + // Missing (file not found) and deleted (removed by Youtarr, or before + // the reason was recorded) are both `removed` rows; see videoRemovalState. + const stateFilter = (mode, state) => { + if (mode !== 'only' && mode !== 'exclude') return null; + return mode === 'only' ? stateWhere(state) : notStateWhere(state); + }; + const missingWhere = stateFilter(missingFilter, REMOVAL_STATES.MISSING); + const deletedWhere = stateFilter(deletedFilter, REMOVAL_STATES.DELETED); + for (const clause of [missingWhere, deletedWhere]) { + if (!clause) continue; + where[Sequelize.Op.and] ??= []; + where[Sequelize.Op.and].push(clause); } if (watchedFilter === 'only' || watchedFilter === 'exclude') { @@ -134,6 +145,18 @@ class VideosModule { distinct: true, }); + // How many deleted videos the default view hides, for the "N deleted + // hidden" note: the same filters with "deleted only" in place of the + // exclusion. + let hiddenDeletedCount = null; + if (deletedFilter === 'exclude') { + const hiddenWhere = { + ...where, + [Sequelize.Op.and]: where[Sequelize.Op.and].map((clause) => (clause === deletedWhere ? stateWhere(REMOVAL_STATES.DELETED) : clause)), + }; + hiddenDeletedCount = await Video.count({ ...options, where: hiddenWhere, distinct: true }); + } + // Define attributes options.attributes = { include: [ @@ -172,7 +195,7 @@ class VideosModule { // Real-time file check for videos that have a known file path // Only check videos with an existing filePath to avoid incorrectly marking videos as removed // Videos without a filePath will be handled by the backfill process - const { videos: checkedVideos, updates } = await fileCheckModule.checkVideoFiles(videos); + const { videos: checkedVideos, updates, libraryWarning } = await fileCheckModule.checkVideoFiles(videos); // Update the videos array with the checked results for (let i = 0; i < videos.length; i++) { @@ -270,7 +293,10 @@ class VideosModule { page, totalPages: Math.ceil(total / limit), channels, - enabledChannels: enabledChannels.map(ch => ({ channel_id: ch.channel_id, uploader: ch.uploader })) + enabledChannels: enabledChannels.map(ch => ({ channel_id: ch.channel_id, uploader: ch.uploader })), + // Set when rows were not marked missing because the downloads folder looks unmounted. + libraryWarning, + hiddenDeletedCount }; } catch (err) { logger.error({ err }, 'Error in getVideosPaginated'); @@ -498,10 +524,13 @@ class VideosModule { * Deliberately does not check the run's time limit: once started, a slice's * writes complete, so the expensive work already done is never discarded. * @param {Array<{row: Object, fileInfo: Object|undefined, probedResolution: string|null}>} candidates - * @returns {Promise<{updated: number, removed: number, kept: number, skipped: number, failed: number}>} + * @param {{holdBackMissing?: function(Object): boolean}} [options] - holdBackMissing: true for a row + * stored where the downloads folder looks partly unmounted; if it would be marked missing it is + * left alone and counted + * @returns {Promise<{updated: number, removed: number, kept: number, skipped: number, failed: number, notMarkedMissing: number}>} */ - async _applyRescanUpdates(candidates) { - const counts = { updated: 0, removed: 0, kept: 0, skipped: 0, failed: 0 }; + async _applyRescanUpdates(candidates, { holdBackMissing = () => false } = {}) { + const counts = { updated: 0, removed: 0, kept: 0, skipped: 0, failed: 0, notMarkedMissing: 0 }; const limit = createLimiter(RESCAN_WRITE_CONCURRENCY); await Promise.all(candidates.map(({ row, fileInfo, probedResolution }) => limit(async () => { try { @@ -512,6 +541,10 @@ class VideosModule { counts.kept++; return; } + if (changes.removed === true && holdBackMissing(row)) { + counts.notMarkedMissing++; + return; + } const [affected] = await Video.update(changes, { where: unchangedSinceRead(row) }); if (affected === 0) { counts.skipped++; @@ -532,6 +565,8 @@ class VideosModule { const opts = typeof arg === 'number' ? { timeLimit: arg } : arg; const timeLimit = opts.timeLimit ?? 5 * 60 * 1000; const trigger = opts.trigger ?? 'scheduled'; + // Rescan anyway: a manual run over a folder the user emptied on purpose. + const ignoreLibraryCheck = opts.ignoreLibraryCheck === true; if (this._backfillRunning) { logger.info({ trigger }, 'Backfill already running, skipping'); @@ -552,8 +587,12 @@ class VideosModule { let totalKept = 0; let totalSkipped = 0; let totalFailed = 0; + let totalNotMarkedMissing = 0; let fileMapSize = 0; let unreadableOnDisk = 0; + // Set when the downloads folder looks partly unmounted: the scan runs, but + // nothing is marked missing (see libraryRootCheck). + let libraryCheckMessage = null; let result; try { @@ -572,6 +611,44 @@ class VideosModule { return; } + // An unmounted drive or share would make every present video look + // gone. With no present rows under the failing folder there is nothing + // to protect (a fresh install, or an empty subfolder nothing is stored + // in), so the scan simply runs. + let holdBackMissing = () => false; + if (ignoreLibraryCheck) logger.warn({ trigger }, 'Rescan is skipping the downloads folder check by request'); + const libraryCheck = ignoreLibraryCheck ? { ok: true } : await checkLibraryRoot(outputDir); + if (await libraryCheckCoversRows(libraryCheck, Video, { removed: false })) { + const subfolderOnly = libraryCheck.reason === LIBRARY_CHECK_REASONS.SUBFOLDER_EMPTY + || libraryCheck.reason === LIBRARY_CHECK_REASONS.SUBFOLDER_UNREADABLE; + if (subfolderOnly) { + // Only the rows stored under that subfolder are in doubt. + holdBackMissing = (row) => isRowUnderLibraryCheck(libraryCheck, row); + libraryCheckMessage = libraryCheck.message; + logger.warn({ libraryCheck }, 'A subfolder looks unmounted or unreadable; the rescan will not mark videos in it missing'); + } else { + logger.warn({ libraryCheck }, 'The downloads folder looks unmounted; skipping the rescan'); + result = { + trigger, + startedAt: startedAtIso, + completedAt: new Date().toISOString(), + status: 'error', + errorMessage: `${libraryCheck.message} Nothing was marked missing; is the drive mounted?`, + libraryCheckMessage: libraryCheck.message, + processed: 0, + filesOnDisk: 0, + unreadableOnDisk: 0, + updated: 0, + removed: 0, + kept: 0, + skippedChanged: 0, + failed: 0, + notMarkedMissing: 0 + }; + return result; + } + } + // Check time limit before expensive operations const checkTimeLimit = () => { if (Date.now() - startTime > timeLimit) { @@ -663,12 +740,13 @@ class VideosModule { // re-probed next run and never converge. if (candidates.length > 0) { logProgress(`Checking ${candidates.length} records (chunk ${Math.floor(offset / VIDEO_CHUNK_SIZE) + 1})...`); - const counts = await this._applyRescanUpdates(candidates); + const counts = await this._applyRescanUpdates(candidates, { holdBackMissing }); totalUpdated += counts.updated; totalRemoved += counts.removed; totalKept += counts.kept; totalSkipped += counts.skipped; totalFailed += counts.failed; + totalNotMarkedMissing += counts.notMarkedMissing; } } @@ -691,7 +769,8 @@ class VideosModule { removed: totalRemoved, kept: totalKept, skippedChanged: totalSkipped, - failed: totalFailed + failed: totalFailed, + notMarkedMissing: totalNotMarkedMissing }, 'Video metadata backfill completed'); result = { @@ -703,6 +782,9 @@ class VideosModule { kept: totalKept, skippedChanged: totalSkipped, failed: totalFailed, + notMarkedMissing: totalNotMarkedMissing, + libraryCheckMessage, + libraryCheckSkipped: ignoreLibraryCheck, timeElapsed: elapsed, trigger, startedAt: startedAtIso, @@ -728,7 +810,10 @@ class VideosModule { removed: totalRemoved, kept: totalKept, skippedChanged: totalSkipped, - failed: totalFailed + failed: totalFailed, + notMarkedMissing: totalNotMarkedMissing, + libraryCheckMessage, + libraryCheckSkipped: ignoreLibraryCheck }; return result; } @@ -746,7 +831,10 @@ class VideosModule { removed: totalRemoved, kept: totalKept, skippedChanged: totalSkipped, - failed: totalFailed + failed: totalFailed, + notMarkedMissing: totalNotMarkedMissing, + libraryCheckMessage, + libraryCheckSkipped: ignoreLibraryCheck }; // Resolve with the failure and the counters it reached rather than // rethrow, so every caller and the run history keep the partial progress. diff --git a/server/routes/__tests__/maintenance.test.js b/server/routes/__tests__/maintenance.test.js index 70f6bec11..b10a9fd4f 100644 --- a/server/routes/__tests__/maintenance.test.js +++ b/server/routes/__tests__/maintenance.test.js @@ -59,10 +59,29 @@ describe('Maintenance routes', () => { expect(res.status).toBe(202); expect(res.body).toEqual({ status: 'started', trigger: 'manual' }); expect(mockScheduledTaskManager.runNow).toHaveBeenCalledWith( - 'videoRescanFrequency', { trigger: 'manual', enforceCooldown: false } + 'videoRescanFrequency', { trigger: 'manual', enforceCooldown: false, args: { ignoreLibraryCheck: false } } ); }); + test('forwards a request to skip the downloads folder check', async () => { + mockScheduledTaskManager.runNow.mockResolvedValue({ started: true, completion: Promise.resolve({}) }); + + const res = await request(app).post('/api/maintenance/rescan-files').send({ ignoreLibraryCheck: true }); + + expect(res.status).toBe(202); + expect(mockScheduledTaskManager.runNow).toHaveBeenCalledWith( + 'videoRescanFrequency', { trigger: 'manual', enforceCooldown: false, args: { ignoreLibraryCheck: true } } + ); + }); + + test('rejects a non-boolean ignoreLibraryCheck', async () => { + const res = await request(app).post('/api/maintenance/rescan-files').send({ ignoreLibraryCheck: 'yes' }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'ignoreLibraryCheck must be true or false' }); + expect(mockScheduledTaskManager.runNow).not.toHaveBeenCalled(); + }); + test('returns 409 when already running', async () => { mockScheduledTaskManager.runNow.mockResolvedValue({ started: false, reason: 'running', message: 'x', availableAt: null diff --git a/server/routes/__tests__/playlists.test.js b/server/routes/__tests__/playlists.test.js index e45a53095..aa441884b 100644 --- a/server/routes/__tests__/playlists.test.js +++ b/server/routes/__tests__/playlists.test.js @@ -1172,6 +1172,9 @@ describe('GET /api/playlists/:playlistId/videos', () => { youtubeId: 'gone1', youTubeVideoName: 'Deleted local file', removed: true, + removed_reason: 'auto_removal', + removed_at: new Date('2026-10-03T00:00:00Z'), + removed_detail: 'older than 30 days', youtube_removed: false, filePath: '/videos/gone1.mp4', fileSize: 1024, @@ -1189,6 +1192,10 @@ describe('GET /api/playlists/:playlistId/videos', () => { youtube_id: 'gone1', downloaded: false, previously_downloaded: true, + // Why it is gone, so the page can tell Deleted from Missing. + removed_reason: 'auto_removal', + removed_at: new Date('2026-10-03T00:00:00Z'), + removed_detail: 'older than 30 days', }); }); diff --git a/server/routes/__tests__/videoTrash.test.js b/server/routes/__tests__/videoTrash.test.js new file mode 100644 index 000000000..b394920d8 --- /dev/null +++ b/server/routes/__tests__/videoTrash.test.js @@ -0,0 +1,183 @@ +/* eslint-env jest */ +const express = require('express'); +const supertest = require('supertest'); +const createVideoTrashRoutes = require('../videoTrash'); + +class LibraryUnavailableError extends Error { + constructor(message) { + super(message); + this.code = 'LIBRARY_UNAVAILABLE'; + } +} + +class PurgeRunningError extends Error { + constructor() { + super('A purge is already running'); + this.code = 'PURGE_RUNNING'; + } +} + +function makeModule(overrides = {}) { + return { + LibraryUnavailableError, + PurgeRunningError, + getRemovedSummary: jest.fn().mockResolvedValue({ deleted: 2, missing: 1, legacy: 0, libraryCheck: { ok: true, reason: null, path: null, message: null } }), + markMissingAsDeleted: jest.fn().mockResolvedValue({ updated: 2 }), + purgeRemoved: jest.fn().mockResolvedValue({ purged: 0, archiveEntriesRemoved: 0, jobIds: [] }), + ...overrides, + }; +} + +function makeApp(videoTrashModule, jobModule = { reloadJobVideos: jest.fn().mockResolvedValue(undefined) }) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.log = { error: jest.fn(), warn: jest.fn(), info: jest.fn() }; + next(); + }); + app.use(createVideoTrashRoutes({ verifyToken: (req, _res, next) => next(), videoTrashModule, jobModule })); + return app; +} + +describe('GET /api/videos/removed-summary', () => { + test('returns the module summary', async () => { + const module = makeModule(); + const res = await supertest(makeApp(module)).get('/api/videos/removed-summary'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ deleted: 2, missing: 1, legacy: 0, libraryCheck: { ok: true, reason: null, path: null, message: null } }); + }); + + test('returns 500 with the standard error shape when the summary fails', async () => { + const module = makeModule({ getRemovedSummary: jest.fn().mockRejectedValue(new Error('db down')) }); + const res = await supertest(makeApp(module)).get('/api/videos/removed-summary'); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to read the deleted and missing video counts' }); + }); +}); + +describe('POST /api/videos/mark-deleted', () => { + test.each([ + ['nothing', {}], + ['both a selection and all', { videoIds: [1], all: true }], + ['an empty selection', { videoIds: [] }], + ['a non-integer id', { videoIds: ['x'] }], + ['all set to false alone', { all: false }], + ])('rejects %s with 400', async (_label, body) => { + const module = makeModule(); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send(body); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Send either videoIds (1 to 1000 video ids) or all: true' }); + expect(module.markMissingAsDeleted).not.toHaveBeenCalled(); + }); + + test('marks a selection', async () => { + const module = makeModule(); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ videoIds: [1, 2] }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ updated: 2 }); + expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: [1, 2], all: false, ignoreLibraryCheck: false }); + }); + + test('marks all missing videos', async () => { + const module = makeModule({ markMissingAsDeleted: jest.fn().mockResolvedValue({ updated: 40 }) }); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ updated: 40 }); + expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: null, all: true, ignoreLibraryCheck: false }); + }); + + test('marks all past the downloads folder check when asked to', async () => { + const module = makeModule({ markMissingAsDeleted: jest.fn().mockResolvedValue({ updated: 40 }) }); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true, ignoreLibraryCheck: true }); + expect(res.status).toBe(200); + expect(module.markMissingAsDeleted).toHaveBeenCalledWith({ videoIds: null, all: true, ignoreLibraryCheck: true }); + }); + + test('rejects a non-boolean ignoreLibraryCheck with 400', async () => { + const module = makeModule(); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true, ignoreLibraryCheck: 'yes' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'ignoreLibraryCheck must be true or false' }); + expect(module.markMissingAsDeleted).not.toHaveBeenCalled(); + }); + + test('answers 409 with the reason while the downloads folder looks unmounted', async () => { + const module = makeModule({ + markMissingAsDeleted: jest.fn().mockRejectedValue(new LibraryUnavailableError('The downloads folder is empty.')), + }); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'The downloads folder is empty.', reason: 'library-unavailable' }); + }); + + test('answers 500 with the standard error shape on any other failure', async () => { + const module = makeModule({ markMissingAsDeleted: jest.fn().mockRejectedValue(new Error('boom')) }); + const res = await supertest(makeApp(module)).post('/api/videos/mark-deleted').send({ all: true }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to mark videos as deleted' }); + }); +}); + +describe('POST /api/videos/purge-removed', () => { + test.each([ + ['no states', { removeFromArchive: false }], + ['an empty states list', { states: [], removeFromArchive: false }], + ['an unknown state', { states: ['bogus'], removeFromArchive: false }], + ['a missing archive choice', { states: ['deleted'] }], + ['a non-boolean archive choice', { states: ['deleted'], removeFromArchive: 'yes' }], + ])('rejects %s with 400', async (_label, body) => { + const module = makeModule(); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send(body); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'Send states (deleted and/or missing) and removeFromArchive (true or false)' }); + expect(module.purgeRemoved).not.toHaveBeenCalled(); + }); + + test('purges, handing the module the download history reload, and reports what it did', async () => { + const module = makeModule({ + purgeRemoved: jest.fn().mockResolvedValue({ purged: 5, archiveEntriesRemoved: 5, jobIds: ['j1', 'j2'] }), + }); + const jobModule = { reloadJobVideos: jest.fn().mockResolvedValue(undefined) }; + + const res = await supertest(makeApp(module, jobModule)).post('/api/videos/purge-removed').send({ states: ['deleted', 'missing'], removeFromArchive: true }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ purged: 5, archiveEntriesRemoved: 5, jobsAffected: 2 }); + expect(module.purgeRemoved).toHaveBeenCalledWith( + { states: ['deleted', 'missing'], removeFromArchive: true }, + { reloadJobVideos: expect.any(Function) } + ); + // The module calls this before it broadcasts; it must reach jobModule. + await module.purgeRemoved.mock.calls[0][1].reloadJobVideos(['j1', 'j2']); + expect(jobModule.reloadJobVideos).toHaveBeenCalledWith(['j1', 'j2']); + }); + + test('answers 409 while another purge is running', async () => { + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(new PurgeRunningError()) }); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['deleted'], removeFromArchive: false }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'A purge is already running', reason: 'running' }); + }); + + test('answers 409 when a download prevents archive removal', async () => { + const error = Object.assign(new Error('Wait for active downloads to finish.'), { code: 'ARCHIVE_BUSY' }); + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(error) }); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['deleted'], removeFromArchive: true }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: error.message, reason: 'archive-busy' }); + }); + + test('answers 409 with the reason while the downloads folder looks unmounted', async () => { + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(new LibraryUnavailableError('The downloads folder is empty.')) }); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['missing'], removeFromArchive: false }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: 'The downloads folder is empty.', reason: 'library-unavailable' }); + }); + + test('answers 500 with the standard error shape on any other failure', async () => { + const module = makeModule({ purgeRemoved: jest.fn().mockRejectedValue(new Error('boom')) }); + const res = await supertest(makeApp(module)).post('/api/videos/purge-removed').send({ states: ['deleted'], removeFromArchive: false }); + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: 'Failed to empty the trash' }); + }); +}); diff --git a/server/routes/__tests__/videos.routes.test.js b/server/routes/__tests__/videos.routes.test.js index 29e8870cc..8a25728bd 100644 --- a/server/routes/__tests__/videos.routes.test.js +++ b/server/routes/__tests__/videos.routes.test.js @@ -549,3 +549,52 @@ describe('POST /triggerchanneldownloads', () => { expect(res.status).toHaveBeenCalledWith(500); }); }); + +describe('GET /getVideos', () => { + const loggerMock = { info: jest.fn(), error: jest.fn() }; + + const createResponse = () => { + const res = {}; + res.status = jest.fn(() => res); + res.json = jest.fn(() => res); + return res; + }; + + const getHandler = (videosModuleMock) => { + const router = createVideoRoutes({ + verifyToken: (req, res, next) => next(), + videosModule: videosModuleMock, + downloadModule: {}, + ratingMapper: require('../../modules/ratingMapper'), + youtubeUrlParser, + }); + const app = express(); + app.use(router); + return findRouteHandler(app, 'get', '/getVideos'); + }; + + test('passes the missing and deleted tri-state filters to the listing', async () => { + const videosModuleMock = { getVideosPaginated: jest.fn().mockResolvedValue({ videos: [] }) }; + const handler = getHandler(videosModuleMock); + const res = createResponse(); + + await handler({ query: { missingFilter: 'only', deletedFilter: 'exclude' }, log: loggerMock }, res); + + expect(videosModuleMock.getVideosPaginated).toHaveBeenCalledWith(expect.objectContaining({ + missingFilter: 'only', + deletedFilter: 'exclude', + })); + expect(res.json).toHaveBeenCalledWith({ videos: [] }); + }); + + test('treats an unknown deleted filter value as off', async () => { + const videosModuleMock = { getVideosPaginated: jest.fn().mockResolvedValue({ videos: [] }) }; + const handler = getHandler(videosModuleMock); + + await handler({ query: { deletedFilter: 'bogus' }, log: loggerMock }, createResponse()); + + expect(videosModuleMock.getVideosPaginated).toHaveBeenCalledWith(expect.objectContaining({ + deletedFilter: 'off', + })); + }); +}); diff --git a/server/routes/channels.js b/server/routes/channels.js index aaa8460f1..fee80f806 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -893,6 +893,18 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch * default: off * description: Tri-state filter on watched status (per the configured watched rule). `only` keeps watched videos, `exclude` hides them. * - in: query + * name: missingFilter + * schema: + * type: string + * enum: [off, only, exclude] + * description: Tri-state filter on missing videos (previously downloaded; a file was expected on disk and not found) + * - in: query + * name: deletedFilter + * schema: + * type: string + * enum: [off, only, exclude] + * description: Tri-state filter on deleted videos (previously downloaded; deleted in Youtarr or by automatic cleanup, marked as deleted, or removed before Youtarr recorded why) + * - in: query * name: maxRating * schema: * type: string @@ -929,13 +941,14 @@ module.exports = function createChannelRoutes({ verifyToken, channelModule, arch const missingFilter = parseFilterMode(req.query.missingFilter); const ignoredFilter = parseFilterMode(req.query.ignoredFilter); const watchedFilter = parseFilterMode(req.query.watchedFilter); + const deletedFilter = parseFilterMode(req.query.deletedFilter); const maxRating = ratingMapper.parseMaxRatingParam(req.query.maxRating); if (!maxRating.valid) { return res.status(400).json({ error: 'Invalid maxRating' }); } let result; try { - result = await channelModule.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating.value); + result = await channelModule.getChannelVideos(channelId, page, pageSize, downloadedFilter, searchQuery, sortBy, sortOrder, tabType, minDuration, maxDuration, dateFrom, dateTo, protectedFilter, missingFilter, ignoredFilter, watchedFilter, maxRating.value, deletedFilter); } catch (error) { if (error.code === 'CHANNEL_NOT_FOUND') { return res.status(404).json({ error: 'Channel not found' }); diff --git a/server/routes/index.js b/server/routes/index.js index fd821f1e9..b1ae1403f 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -19,7 +19,9 @@ const createMaintenanceRoutes = require('./maintenance'); const createSubfolderRoutes = require('./subfolders'); const createSchedulesRoutes = require('./schedules'); const createLogRoutes = require('./logs'); +const createVideoTrashRoutes = require('./videoTrash'); const videoMetadataModule = require('../modules/videoMetadataModule'); +const videoTrashModule = require('../modules/videoTrashModule'); const videoOembedEnricher = require('../modules/videoOembedEnricher'); const playlistModule = require('../modules/playlistModule'); const m3uGenerator = require('../modules/m3uGenerator'); @@ -103,6 +105,9 @@ function registerRoutes(app, deps) { youtubeUrlParser, })); + // Trash routes: deleted and missing videos (counts, mark as deleted, purge) + app.use(createVideoTrashRoutes({ verifyToken, videoTrashModule, jobModule })); + // Video search routes app.use(createVideoSearchRoutes({ verifyToken, videoSearchModule })); diff --git a/server/routes/maintenance.js b/server/routes/maintenance.js index b431f0c20..4b90597c0 100644 --- a/server/routes/maintenance.js +++ b/server/routes/maintenance.js @@ -21,20 +21,41 @@ function createMaintenanceRoutes({ * /api/maintenance/rescan-files: * post: * summary: Kick off a manual filesystem rescan + * description: > + * With `ignoreLibraryCheck: true` ("Rescan anyway") the scan skips the check that keeps an unmounted + * drive or share from getting every video marked missing. Only for a downloads folder the user emptied + * on purpose; the scheduled rescan never skips it. * tags: [Maintenance] + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * ignoreLibraryCheck: + * type: boolean + * default: false * responses: * 202: * description: Rescan started + * 400: + * description: ignoreLibraryCheck was not a boolean * 409: * description: A rescan is already in progress, or the task cannot start (reason in the body) * 503: * description: The task is not registered yet (server still starting or database unavailable) */ router.post('/api/maintenance/rescan-files', verifyToken, async (req, res) => { + const { ignoreLibraryCheck = false } = req.body || {}; + if (typeof ignoreLibraryCheck !== 'boolean') { + return res.status(400).json({ error: 'ignoreLibraryCheck must be true or false' }); + } try { const outcome = await scheduledTaskManager.runNow('videoRescanFrequency', { trigger: 'manual', enforceCooldown: false, + args: { ignoreLibraryCheck }, }); if (!outcome.started) { return sendRunBlocked(res, outcome, { running: 'Rescan already in progress' }); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index e55d58abc..c3b7222a2 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -544,7 +544,7 @@ function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3u if (youtubeIds.length > 0 && Video) { const downloaded = await Video.findAll({ where: { youtubeId: youtubeIds }, - attributes: ['id', 'youtubeId', 'youTubeVideoName', 'youTubeChannelName', 'duration', 'originalDate', 'removed', 'youtube_removed', 'filePath', 'fileSize', 'audioFilePath', 'audioFileSize', 'video_resolution'], + attributes: ['id', 'youtubeId', 'youTubeVideoName', 'youTubeChannelName', 'duration', 'originalDate', 'removed', 'removed_reason', 'removed_at', 'removed_detail', 'youtube_removed', 'filePath', 'fileSize', 'audioFilePath', 'audioFileSize', 'video_resolution'], }); downloaded.forEach((v) => downloadedById.set(v.youtubeId, v)); } @@ -586,6 +586,10 @@ function createPlaylistRoutes({ verifyToken, playlistModule, downloadModule, m3u thumbnail: youtubeRemoved ? localThumb : flatThumb, downloaded: isDownloaded, previously_downloaded: previouslyDownloaded, + // Why the file is gone (see videoRemovalState): tells Deleted from Missing. + removed_reason: dl?.removed_reason ?? null, + removed_at: dl?.removed_at ?? null, + removed_detail: dl?.removed_detail ?? null, youtube_removed: youtubeRemoved, video_id: dl?.id ?? null, file_path: dl?.filePath ?? null, diff --git a/server/routes/videoSearch.js b/server/routes/videoSearch.js index d7cfaf527..64818e1ef 100644 --- a/server/routes/videoSearch.js +++ b/server/routes/videoSearch.js @@ -39,7 +39,7 @@ function createVideoSearchRoutes({ verifyToken, videoSearchModule }) { * /api/videos/search: * post: * summary: Search YouTube by free text - * description: Search YouTube via yt-dlp and return a list of matching videos, sorted newest-to-oldest by publishedAt (entries without a timestamp sort last). Results are ephemeral; nothing is persisted. Each result includes a `status` field (`downloaded`, `missing`, or `never_downloaded`) describing the video's state in the local Video table. + * description: Search YouTube (YouTube Data API when a key is configured, otherwise yt-dlp) and return matching videos in YouTube's relevance order. Live and upcoming streams are left out. Results are ephemeral; nothing is persisted. Each result includes a `status` field (`downloaded`, `deleted`, `missing`, or `never_downloaded`) describing the video's state in the local Video table, and `channelSubscribed` says whether the video's channel is an enabled subscription. * tags: [Videos] * requestBody: * required: true @@ -84,7 +84,8 @@ function createVideoSearchRoutes({ verifyToken, videoSearchModule }) { * viewCount: { type: integer, nullable: true } * status: * type: string - * enum: [downloaded, missing, never_downloaded] + * enum: [downloaded, deleted, missing, never_downloaded] + * channelSubscribed: { type: boolean, description: "True when the video's channel is an enabled subscription." } * databaseId: { type: integer, nullable: true, description: "Video row id when status is downloaded or missing." } * filePath: { type: string, nullable: true } * fileSize: { type: integer, nullable: true } diff --git a/server/routes/videoTrash.js b/server/routes/videoTrash.js new file mode 100644 index 000000000..c541be449 --- /dev/null +++ b/server/routes/videoTrash.js @@ -0,0 +1,275 @@ +const express = require('express'); + +// A selection can name this many videos at once. +const MAX_SELECTION_SIZE = 1000; +const PURGE_STATES = ['deleted', 'missing']; + +const isPositiveInteger = (value) => Number.isInteger(value) && value > 0; + +/** + * Routes for the trash: videos with no file left (deleted by Youtarr, or + * missing from disk). See modules/videoTrashModule.js. + * @param {Object} deps + * @param {Function} deps.verifyToken + * @param {Object} deps.videoTrashModule + * @param {Object} deps.jobModule - Download History holds jobs in memory; a purge reloads the affected ones + */ +module.exports = function createVideoTrashRoutes({ verifyToken, videoTrashModule, jobModule }) { + const router = express.Router(); + + /** + * @swagger + * /api/videos/removed-summary: + * get: + * summary: Count the videos whose file is gone + * description: > + * Deleted = Youtarr removed the file (a delete in the app, automatic cleanup, or a missing video the user + * marked as deleted), including videos removed before Youtarr recorded why (`legacy`, a subset of `deleted`). + * Missing = a file was expected on disk and not found. `libraryCheck` says whether the downloads folder + * looks mounted; a failing subfolder that no present or missing video is stored in is reported as passing, + * since it puts nothing in doubt. `missingActionsBlocked` says whether the failing check covers missing + * videos, in which case actions over all missing videos are refused. + * tags: [Videos] + * responses: + * 200: + * description: The counts and the library check + * content: + * application/json: + * schema: + * type: object + * properties: + * deleted: + * type: integer + * missing: + * type: integer + * legacy: + * type: integer + * libraryCheck: + * type: object + * properties: + * ok: + * type: boolean + * reason: + * type: string + * nullable: true + * enum: [missing, empty, unreadable, subfolder-empty, subfolder-unreadable] + * path: + * type: string + * nullable: true + * message: + * type: string + * nullable: true + * missingActionsBlocked: + * type: boolean + * description: The failing library check covers missing videos, so marking all of them or emptying them is refused + * downloadsActive: + * type: boolean + * description: A download is running, so a purge that removes archive entries would be refused + * 401: + * description: Authentication required + * 500: + * description: The counts could not be read + */ + router.get('/api/videos/removed-summary', verifyToken, async (req, res) => { + try { + res.json(await videoTrashModule.getRemovedSummary()); + } catch (err) { + req.log.error({ err }, 'Failed to read the removed video summary'); + res.status(500).json({ error: 'Failed to read the deleted and missing video counts' }); + } + }); + + /** + * @swagger + * /api/videos/mark-deleted: + * post: + * summary: Mark missing videos as deleted + * description: > + * Turns missing videos (a file was expected on disk and not found) into deleted ones, which the Videos page + * hides by default. Only missing rows change; present and deleted ids in a selection are left alone. + * The records are kept, so the videos can still be re-downloaded, and a file that turns up again is marked + * downloaded by the rescan. Send either `videoIds` or `all: true`. `all` is refused while the downloads + * folder, or a subfolder missing videos are stored in, looks unmounted; with + * `ignoreLibraryCheck: true` it goes ahead anyway when the check failed only because a folder is empty + * (one the user emptied on purpose), never when a folder is gone or unreadable. + * tags: [Videos] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * videoIds: + * type: array + * minItems: 1 + * maxItems: 1000 + * items: + * type: integer + * description: Database video ids to mark + * all: + * type: boolean + * description: Mark every missing video + * ignoreLibraryCheck: + * type: boolean + * default: false + * description: With all, go ahead while the downloads folder (or a subfolder) looks empty + * responses: + * 200: + * description: How many videos were marked + * content: + * application/json: + * schema: + * type: object + * properties: + * updated: + * type: integer + * 400: + * description: Neither a valid selection nor all, or both, or ignoreLibraryCheck was not a boolean + * 401: + * description: Authentication required + * 409: + * description: all was asked for while the downloads folder, or a subfolder missing videos are stored in, looks unmounted (reason library-unavailable); the videos may be fine + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * reason: + * type: string + * enum: [library-unavailable] + * 500: + * description: The videos could not be marked + */ + router.post('/api/videos/mark-deleted', verifyToken, async (req, res) => { + const { videoIds, all, ignoreLibraryCheck = false } = req.body || {}; + if (typeof ignoreLibraryCheck !== 'boolean') { + return res.status(400).json({ error: 'ignoreLibraryCheck must be true or false' }); + } + const wantsAll = all === true; + const selection = Array.isArray(videoIds) + && videoIds.length >= 1 + && videoIds.length <= MAX_SELECTION_SIZE + && videoIds.every(isPositiveInteger) + ? videoIds + : null; + + if ((wantsAll && videoIds !== undefined) || (!wantsAll && !selection)) { + return res.status(400).json({ error: `Send either videoIds (1 to ${MAX_SELECTION_SIZE} video ids) or all: true` }); + } + + try { + const result = await videoTrashModule.markMissingAsDeleted({ + videoIds: wantsAll ? null : selection, + all: wantsAll, + ignoreLibraryCheck, + }); + res.json(result); + } catch (err) { + if (err.code === 'LIBRARY_UNAVAILABLE') { + return res.status(409).json({ error: err.message, reason: 'library-unavailable' }); + } + req.log.error({ err, all: wantsAll, count: selection ? selection.length : null }, 'Failed to mark videos as deleted'); + res.status(500).json({ error: 'Failed to mark videos as deleted' }); + } + }); + + /** + * @swagger + * /api/videos/purge-removed: + * post: + * summary: Empty the trash + * description: > + * Forgets deleted and/or missing videos entirely. This cannot be undone. For each video it removes the + * database record (so it leaves the Videos page and Download History and its channel and playlist pages + * show it as not downloaded), the watch history Youtarr synced for it from media servers, the playlist + * download date, and the cached metadata and thumbnail. Nothing on disk under the downloads folder is + * touched; a missing video's file that turns up later is not recognised. With `removeFromArchive` the + * videos also leave the download archive (`complete.list`), so automatic channel and playlist downloads + * treat them as new and may download them again; without it they keep being skipped. + * tags: [Videos] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [states, removeFromArchive] + * properties: + * states: + * type: array + * minItems: 1 + * items: + * type: string + * enum: [deleted, missing] + * removeFromArchive: + * type: boolean + * responses: + * 200: + * description: What was removed + * content: + * application/json: + * schema: + * type: object + * properties: + * purged: + * type: integer + * archiveEntriesRemoved: + * type: integer + * jobsAffected: + * type: integer + * description: Download History jobs that lost videos + * 400: + * description: Invalid states or archive choice + * 401: + * description: Authentication required + * 409: + * description: Another purge is running (reason running), downloads are using the archive (reason archive-busy), or missing videos were included while the downloads folder, or a subfolder missing videos are stored in, looks unmounted (reason library-unavailable) + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * reason: + * type: string + * enum: [running, library-unavailable, archive-busy] + * 500: + * description: The purge failed; rows already purged stay purged + */ + router.post('/api/videos/purge-removed', verifyToken, async (req, res) => { + const { states, removeFromArchive } = req.body || {}; + const validStates = Array.isArray(states) + && states.length > 0 + && states.every((state) => PURGE_STATES.includes(state)) + && new Set(states).size === states.length; + if (!validStates || typeof removeFromArchive !== 'boolean') { + return res.status(400).json({ error: 'Send states (deleted and/or missing) and removeFromArchive (true or false)' }); + } + + try { + const result = await videoTrashModule.purgeRemoved( + { states, removeFromArchive }, + { reloadJobVideos: (jobIds) => jobModule.reloadJobVideos(jobIds) } + ); + res.json({ purged: result.purged, archiveEntriesRemoved: result.archiveEntriesRemoved, jobsAffected: result.jobIds.length }); + } catch (err) { + if (err.code === 'PURGE_RUNNING') { + return res.status(409).json({ error: err.message, reason: 'running' }); + } + if (err.code === 'ARCHIVE_BUSY') { + return res.status(409).json({ error: err.message, reason: 'archive-busy' }); + } + if (err.code === 'LIBRARY_UNAVAILABLE') { + return res.status(409).json({ error: err.message, reason: 'library-unavailable' }); + } + req.log.error({ err, states, removeFromArchive }, 'Failed to empty the trash'); + res.status(500).json({ error: 'Failed to empty the trash' }); + } + }); + + return router; +}; diff --git a/server/routes/videos.js b/server/routes/videos.js index 05aa1d0e5..230fe7e57 100644 --- a/server/routes/videos.js +++ b/server/routes/videos.js @@ -101,7 +101,8 @@ module.exports = function createVideoRoutes({ * type: string * status: * type: string - * enum: [never_downloaded, missing, downloaded] + * enum: [never_downloaded, downloaded, deleted, missing] + * description: deleted = Youtarr removed the file (or the video was marked as deleted); missing = a file was expected on disk and not found. `removedReason`, `removedAt` and `removedDetail` say why. * inArchive: * type: boolean * description: Listed in the download archive without a database record (ignored videos excluded); a download skips it unless re-downloading is allowed @@ -196,7 +197,14 @@ module.exports = function createVideoRoutes({ * type: string * enum: [off, only, exclude] * default: off - * description: Tri-state filter on missing (file removed) videos + * description: Tri-state filter on missing videos (a file was expected on disk and not found) + * - in: query + * name: deletedFilter + * schema: + * type: string + * enum: [off, only, exclude] + * default: off + * description: Tri-state filter on deleted videos (deleted in Youtarr or by automatic cleanup, marked as deleted, or removed before Youtarr recorded why). With `exclude`, the response's `hiddenDeletedCount` says how many were hidden. * - in: query * name: watchedFilter * schema: @@ -212,7 +220,7 @@ module.exports = function createVideoRoutes({ * description: Hide videos rated above this rating. Unrated videos are always included. * responses: * 200: - * description: Paginated list of videos + * description: Paginated list of videos. Each row carries `removed`, `removed_reason`, `removed_at` and `removed_detail`. `libraryWarning` is set when videos were not marked missing because the downloads folder looked unmounted. * 400: * description: Invalid maxRating * 500: @@ -222,7 +230,7 @@ module.exports = function createVideoRoutes({ req.log.info('Getting videos'); try { - const { page, limit, search, dateFrom, dateTo, sortBy, sortOrder, channelFilter, protectedFilter, missingFilter, watchedFilter } = req.query; + const { page, limit, search, dateFrom, dateTo, sortBy, sortOrder, channelFilter, protectedFilter, missingFilter, deletedFilter, watchedFilter } = req.query; const maxRating = ratingMapper.parseMaxRatingParam(req.query.maxRating); if (!maxRating.valid) { @@ -242,6 +250,7 @@ module.exports = function createVideoRoutes({ channelFilter: channelFilter || '', protectedFilter: parseFilterMode(protectedFilter), missingFilter: parseFilterMode(missingFilter), + deletedFilter: parseFilterMode(deletedFilter), watchedFilter: parseFilterMode(watchedFilter), maxRating: maxRating.value, };