Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ For multi-part requests (e.g., "review this PR AND explain WebSocket handling"),
- `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.
- `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying videos that failed with a transient mid-stream HTTP 403; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)).
- `modules/download/`: download orchestration (`downloadExecutor`, `ytdlpCommandBuilder`, `DownloadProgressMonitor`, `tempPathManager`, `videoMetadataProcessor`, `customArgsParser`, `cookiePlayerClients` (pure: the yt-dlp `player_client` list (`default,mweb,web_safari`) added to video downloads and single-video metadata fetches whenever a cookies file is in play, because YouTube's SABR-only experiment strips URLs from the logged-in default clients and leaves only 360p itag 18; folded into any user `youtube:` extractor-args token since yt-dlp keeps only the last `--extractor-args` per extractor key, and a user-supplied `player_client` wins), `ytdlpStderrSignals` (pure stderr-line classifiers shared by `YtdlpOutputRouter` and `downloadJobFinalizer`: `containsHttp403` ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, and `isSabrRestriction` matches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder from `dispose()`, after which output is logged but never classified or broadcast; `downloadExecutor` finalizes on the child's `close` event (stdio drained), not `exit`, with a 5 s `STDIO_DRAIN_TIMEOUT_MS` fallback for pipes held open by an orphaned ffmpeg or post-process child), `ytdlpValidator`, `downloadRunTracker` (aggregates the many jobs of one channel+playlist sweep into a single end-of-run summary + notification), `ytdlpEnvBuilder` (yt-dlp process env construction), `DownloadTimeoutController` (per-run activity/absolute timeouts + graceful shutdown), `YtdlpErrorTracker` (per-run ERROR-line classification and failure bookkeeping; a `WARNING: Unable to download video subtitles` line un-fails the current video, because yt-dlp prints the subtitle downloader's ERROR first and then still downloads the video under `--ignore-errors`), `downloadStatusResolver` (pure job-status/final-state decision functions), `downloadResultProcessor` (post-run URL resolution, success/failure partitioning, archive reconciliation), `downloadCleanup` (in-progress video and .part/fragment file cleanup), `downloadCompletionEffects` (post-completion side effects: temp-file/tracking cleanup, poster backfill, playlist hooks, Plex refresh, next-job kickoff), `downloadJobFinalizer` (terminal status persistence, final WebSocket broadcast, run-tracker reporting, notification dispatch), `YtdlpOutputRouter` (per-run stdout/stderr routing + throttled progress emission, plus a 25s heartbeat that rebroadcasts the monitor snapshot while yt-dlp is silent (large merges, audio extraction) so the activity page and its REST seed stay live through quiet post-processing), `transient403RetryPlanner` (pure decision logic for auto-retrying eligible download failures, including transient mid-stream HTTP 403 errors and cookie-specific `Video unavailable` failures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by the `downloadAutoRetryCount` config, and handed-off failures are tagged `autoRetryQueued` so run summaries report the post-retry outcome), `failureAdvisor` (pure registry mapping known persistent-failure signatures (403 with/without cookies, bot check) to plain-language diagnoses; the finalizer stamps a `diagnosisKey` on reportable failed videos and emits a deduped `diagnoses` list that flows into job data, run summaries, the Download History UI, and notification "Likely cause" lines), `failedVideoEnricher` (best-effort DB backfill of title/channel onto failed-video records whose download died before metadata existed, sourced from videos, channelvideos + channels, and playlistvideos; called once by the finalizer right after result partitioning, and the client falls back to a linked YouTube ID when enrichment finds nothing)).
- `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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export const DownloadPerformanceSection: React.FC<DownloadPerformanceSectionProp
<MenuItem value={3}>3 auto-retries</MenuItem>
</Select>
<FormHelperText>
Re-run videos that fail with a transient HTTP 403 in a fresh download job
Re-run videos after retryable download failures in a fresh download job
</FormHelperText>
</FormControl>
</Grid>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ describe('DownloadPerformanceSection Component', () => {

expect(screen.getAllByText('Auto-Retry Failed Videos').length).toBeGreaterThan(0);
expect(
screen.getByText('Re-run videos that fail with a transient HTTP 403 in a fresh download job')
screen.getByText('Re-run videos after retryable download failures in a fresh download job')
).toBeInTheDocument();
});

Expand Down
11 changes: 7 additions & 4 deletions client/src/components/DownloadManager/DownloadProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,10 +497,13 @@ const DownloadProgress: React.FC<DownloadProgressProps> = ({
<Alert
severity="error"
action={
errorDetails.code === 'COOKIES_REQUIRED' ||
errorDetails.code === 'COOKIES_RECOMMENDED' ||
errorDetails.message.includes('Bot detection') ||
errorDetails.message.toLowerCase().includes('cookie') ? (
errorDetails.code !== 'NO_COOKIES_FALLBACK_FAILED' &&
errorDetails.code !== 'NO_COOKIES_FALLBACK_403' && (
errorDetails.code === 'COOKIES_REQUIRED' ||
errorDetails.code === 'COOKIES_RECOMMENDED' ||
errorDetails.message.includes('Bot detection') ||
errorDetails.message.toLowerCase().includes('cookie')
) ? (
<Button
color="inherit"
size="small"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,70 @@ describe('DownloadProgress', () => {
expect(mockNavigate).toHaveBeenCalledWith('/settings/cookies');
});

test('does not offer cookie settings when the no-cookies fallback fails', async () => {
renderWithContext(
<DownloadProgress
downloadProgressRef={mockDownloadProgressRef}
downloadInitiatedRef={mockDownloadInitiatedRef}
jobs={[]}
token="test-token"
/>
);

const processCallback = getProcessCallback();

await act(async () => {
processCallback({
error: true,
text: 'Bot detection encountered during the no-cookies fallback. The fallback also failed, so this video may be genuinely unavailable.',
errorCode: 'NO_COOKIES_FALLBACK_FAILED',
});
});

await waitFor(() => {
expect(screen.getByText('Download Failed')).toBeInTheDocument();
});

expect(
screen.getByText(/Bot detection encountered during the no-cookies fallback/)
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Go to Settings' })
).not.toBeInTheDocument();
});

test('does not offer cookie settings for a recoverable no-cookies fallback 403', async () => {
renderWithContext(
<DownloadProgress
downloadProgressRef={mockDownloadProgressRef}
downloadInitiatedRef={mockDownloadInitiatedRef}
jobs={[]}
token="test-token"
/>
);

const processCallback = getProcessCallback();

await act(async () => {
processCallback({
error: true,
text: 'HTTP 403 detected during the no-cookies fallback. If this retry fails, the video may be genuinely unavailable.',
errorCode: 'NO_COOKIES_FALLBACK_403',
});
});

await waitFor(() => {
expect(screen.getByText('Download Failed')).toBeInTheDocument();
});

expect(
screen.getByText(/HTTP 403 detected during the no-cookies fallback/)
).toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Go to Settings' })
).not.toBeInTheDocument();
});

test('displays generic error without settings button', async () => {
renderWithContext(
<DownloadProgress
Expand Down Expand Up @@ -3158,4 +3222,4 @@ describe('DownloadProgress', () => {
}
});
});
});
});
4 changes: 2 additions & 2 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,9 +611,9 @@ The old `discordWebhookUrl` and `notificationService` fields are automatically r
- **Config Key**: `downloadAutoRetryCount`
- **Type**: `number`
- **Default**: `1`
- **Description**: Number of times a video that fails with a transient HTTP 403 is automatically re-queued in a fresh download job
- **Description**: Number of times a video with a retryable download failure is automatically re-queued in a fresh download job
- **Options**: `0`, `1`, `2`, `3` (the values offered in the UI; `0` disables auto-retry)
- **Note**: YouTube sometimes rejects an already-issued stream URL mid-download with HTTP 403. yt-dlp's own retries (`downloadRetryCount`) re-request the same rejected URL and cannot recover; only a fresh yt-dlp run with a fresh extraction can. When a video fails with the 403 signature, Youtarr queues an "Auto-retry" job for just that video. Permanent failures (members-only, terminated channels, bot detection) are never auto-retried.
- **Note**: Auto-retry currently handles transient HTTP 403 failures by starting a fresh yt-dlp extraction, and cookie-specific `Video unavailable` failures by retrying anonymously without cookies. Permanent failures (members-only, terminated channels, bot detection) are never auto-retried.

### Enable Stall Detection
- **Config Key**: `enableStallDetection`
Expand Down
2 changes: 1 addition & 1 deletion docs/YOUTARR_VS_ALTERNATIVES.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Legend: ✅ supported, ❌ not supported, ⚠️ partial/caveat, "unclear" = not
| REST/GraphQL API | ✅ 40+ endpoints, Swagger UI at `/swagger`, API keys with rate-limiting | ❌ Open feature request | ✅ REST + OpenAPI docs at `/api/docs/` |
| Multi-user / RBAC | ❌ Single admin + API keys | ❌ Single HTTP basic-auth user | ⚠️ Superuser/staff/read-only roles; library is still shared across users |
| Apprise notifications | ✅ | ✅ | ✅ |
| Cookies (private/unlisted/members-only) | ✅ Persistent `cookies.txt` for downloads + separate one-time cookies file for bulk import (auto-deleted after use) [^1]; transient 403 failures retry automatically, persistent ones get a plain-language explanation | ✅ Three modes: Disabled / When Needed / All Ops | ✅ Plus PO-token provider support for bot-detection bypass |
| Cookies (private/unlisted/members-only) | ✅ Persistent `cookies.txt` for downloads + separate one-time cookies file for bulk import (auto-deleted after use) [^1]; retryable download failures retry automatically, persistent ones get a plain-language explanation | ✅ Three modes: Disabled / When Needed / All Ops | ✅ Plus PO-token provider support for bot-detection bypass |
| Backup / restore | ✅ In-app backup & restore scripts | ⚠️ File-level (copy `/config`) | ⚠️ Documented manual multi-step procedure |
| Responsive / mobile-friendly UI | ✅ Responsive; mobile screenshots | ⚠️ Likely responsive (Tailwind); not explicitly claimed | ✅ |
| Live-stream / premiere handling | ⚠️ Streams in per-tab controls; in-progress stream handling not supported | ✅ Per-source shorts/livestream rules | ✅ Streams as a separate per-channel tab; disable via page size 0 |
Expand Down
Loading
Loading