agentleFS
DialmasterOrgDialmasterOrg/Youtarr/CLAUDE.md

Youtarr

CLAUDE.md1.8k starsChanged 12 days agoISCView on GitHub

Youtarr is a Dockerized application that automatically downloads videos from YouTube channels and integrates them with Plex Media Server. It consists of: IMPORTANT: Change only what the task requires. This rule overrides every other rule in this file when they conflict. The size and quality rules in this file apply to code you are creating or substantially rewriting. They are not a mandate to refactor existing code you pass through. For multi-part requests (e.g., "review this PR AND explain WebSocket…

We split this CLAUDE.md into the rules it gives an agent

31 rules. About 0 are common, 10 are distinctive, and 21 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈10

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 21

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Refactors: only when the user explicitly asks for a refactor, and only as wide as the user scopes it.

    line 19
  2. Distinctive:

    When in doubt: ask. A small clarifying question is cheaper than an unwanted refactor.

    line 21
  3. Distinctive:

    DRY when writing the third copy: if you find yourself writing the same logic a third time in your own new code, extract it.

    line 106
  4. Very specific:

    Before creating a new component/module file, answer: (1) is this substantial enough to extract, or should it stay inline?

    line 111
  5. Distinctive:

    Comments: only where the logic is not self-evident.

    line 115
  6. Distinctive:

    Never import modules directly inside route files.

    line 140
  7. Very specific:

    No any in new code: Use proper types everywhere:

    line 156
  8. Very specific:

    CONFIG_FIELDS registry: use client/src/config/configSchema.ts when adding new configuration fields.

    line 161
  9. Very specific:

    Cleanup effects: always return cleanup from useEffect for subscriptions, timers, and event listeners.

    line 193
  10. Very specific:

    Responsive breakpoints: use the custom useMediaQuery from hooks/useMediaQuery with a raw media query (useMediaQuery('(max-width: 767px)')) …

    line 200
  11. Very specific:

    Validate untrusted input at route boundaries: Check types, lengths, and shape before passing anything to modules.

    line 218
  12. Distinctive:

    Assert behavior, not implementation: Test what the code does, not how.

    line 248
  13. Very specific:

    Mocking React components: do NOT use JSX inside jest.mock().

    line 253
  14. Very specific:

    Mocking axios: use a factory (jest.mock('axios', () => ({ get: jest.fn(), post: jest.fn(), ...

    line 254
  15. Very specific:

    Place jest.mock() calls before require() imports.

    line 259
  16. Very specific:

    Use jest.resetModules() and jest.clearAllMocks() in beforeEach, then re-require the module under test.

    line 260
  17. Very specific:

    Use renderHook with explicit type annotations for parameterized hooks (e.g., renderHook(({ token }: { token: string | null }) => …

    line 264
  18. Very specific:

    Use waitFor for async state changes, not arbitrary timeouts.

    line 265
  19. Distinctive:

    Test cleanup on unmount.

    line 267
  20. Distinctive:

    Do not test private implementation details.

    line 273
  21. Very specific:

    When a code change creates or invalidates information in this file or in docs/, update the docs in the same PR.

    line 277
  22. Distinctive:

    New top-level route file or module: add a one-liner to the Architecture section above.

    line 279
  23. Very specific:

    New feature directory under client/src/components/: add a one-liner to the Frontend section.

    line 280
  24. Very specific:

    New or changed env var: update docs/ENVIRONMENT_VARIABLES.md.

    line 281
  25. Very specific:

    New or changed config field: update docs/CONFIG.md and register the field in client/src/config/configSchema.ts.

    line 282
  26. Very specific:

    New dev command or flag: update docs/DEVELOPMENT.md.

    line 283
  27. Very specific:

    Schema changes: update docs/DATABASE.md.

    line 284
  28. Very specific:

    dev: default branch for active development.

    line 291
  29. Very specific:

    Feature branches: create from dev, merge back to dev via PR.

    line 293
  30. Very specific:

    Commit prefixes: feat: (minor), fix: (patch), BREAKING CHANGE: (major).

    line 298
  31. Distinctive:

    When debugging CI/CD or GitHub Actions issues, check both the permissions AND the actual command execution logs.

    line 309

≈ “Common” is an estimate: at least 1 in 5 of the 25,680 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
CLAUDE.md

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

Youtarr is a Dockerized application that automatically downloads videos from YouTube channels and integrates them with Plex Media Server. It consists of:

  • Backend: Node.js/Express server with MariaDB database using Sequelize ORM
  • Frontend: React/TypeScript application with a custom UI layer (Radix primitives + Tailwind CSS, themed via CSS variables)
  • Infrastructure: Docker Compose setup with separate containers for app and database

Scope Discipline

IMPORTANT: Change only what the task requires. This rule overrides every other rule in this file when they conflict.

  • Bug fixes: make the minimum change needed to fix the bug. Do not rewrite surrounding code for style, extract helpers you don't need for the fix, or clean up while you're there.
  • Features: touch only files the feature requires. Write new code to the standards below. Leave pre-existing code alone unless the feature forces you to change it.
  • Refactors: only when the user explicitly asks for a refactor, and only as wide as the user scopes it.
  • Unrelated issues you notice (oversized files, duplication, legacy patterns, missing tests, console.log calls, any types, drifted error-response shapes): list them in your final report so the user can decide whether to address them. Do not fix them.
  • When in doubt: ask. A small clarifying question is cheaper than an unwanted refactor.

The size and quality rules in this file apply to code you are creating or substantially rewriting. They are not a mandate to refactor existing code you pass through.

Task Handling

For multi-part requests (e.g., "review this PR AND explain WebSocket handling"), explicitly acknowledge all parts and complete each one before considering the task done.

Architecture

Backend (server/)

  • modules/externalCookies.js: validates external cookie snapshots through the installed yt-dlp parser in utils/validate-cookies.py, memoizes unchanged results, and reports unusable files for omission; modules/uploadedCookies.js gives the uploaded config/cookies.user.txt a disposable private copy per run (yt-dlp rewrites its cookie file in place on exit, so concurrent runs sharing it could read a half-written jar or undo a Settings upload; nothing is written back, and Settings uploads are written to a temp file and renamed into place; if the copy fails, the run goes without cookies (error-level log; info when the upload was just deleted) and yt-dlp is never handed the uploaded file itself, whose exit-time jar save could overwrite a newer upload or recreate deleted cookies); modules/ytdlpProcess.js owns both kinds of copy's lifetime around yt-dlp processes. No persistent cookie fallback.
  • modules/rescanRowUpdate.js + modules/videoRowGuard.js: the filesystem rescan (videosModule.backfillVideoMetadata) walks the disk once and reads rows in chunks, so downloads and deletions in between make the walk stale. The walk only nominates rows (isRescanCandidate); resolveRescanUpdate recomputes each nominated row's write from a fresh per-format stat (present only for a regular file / missing on ENOENT or ENOTDIR or a non-file / unknown on any other error; both formats missing marks the row missing and keeps its stored paths; a missing format is cleared only when the other is present; any present format means removed: false even if the other is unknown; an unknown format is never cleared, and with nothing present an unknown writes nothing), and the write only lands if the row is unchanged since read (videoRowGuard.unchangedSinceRead: removed, both paths and sizes, video_resolution, last_downloaded_at; zero affected rows means a conflict only when the write includes a column that really differs, because Sequelize's MySQL dialect connects with -FOUND_ROWS and mysql2 then counts changed rows, not matched rows). Nominated rows left as recorded (files still at a stored path, e.g. outside the downloads folder, or files that could not be checked; re-checked every run) are counted as kept (videosKept in the run details, not the message). The walk's count of folders and files it could not read is unreadableOnDisk in the run details and log (not the message: those rows are re-checked by path). Rows another writer changed are counted as skippedChanged (videosSkipped in the run record) and failed writes as failed (videosFailed); both appear in the run message, and a finished scan with failed writes records as error/partial (a timed-out scan stays timed-out so Maintenance can continue it); the completion rescanStatus broadcast is built through the same rescanRunSummary record so the live Maintenance view matches the run history. Library repair (jobModule.backfillFromCompleteList) uses the same guard: it reads the row before searching the disk, writes file details and removed: false only for a file it actually found, and looks up a lost row's files by YouTube id with one videosModule.scanForVideoFiles walk per run (only when a lost row exists; video and audio, any folder layout), recreating it as missing when the walk finds nothing. The walk skips and counts (unreadable) folders and files it cannot read rather than stopping, logging unreadable files once per folder; repair still recreates unmatched rows as missing after a partial walk, because deferring would block recovery wherever a folder is never readable (lost+found, NAS recycle bins), and the next rescan restores a row whose file it finds. Such a run records as error/partial, naming the count in its message (recreatedUnverified and unreadableOnDisk in the details); a partial walk that affected no row stays completed.
  • modules/cookieDetails.js: summarizes the YouTube login cookies (names, counts, earliest expiry; never values) in the active cookie file as details on GET /api/cookies/status and the upload/delete responses, reading external files through externalCookies.readExternalCookies. modules/cookieTest.js: POST /api/cookies/test, one yt-dlp request to the subscriptions feed with buildCommonArgs (same proxy/IP family/cookies/cache as downloads) through ytDlpRunner, against a private temp copy of uploaded cookies (yt-dlp writes its jar back on exit, which would otherwise undo a replace/delete made mid-test; external files get spawnYtDlp's snapshot) and never logs stderr (yt-dlp echoes malformed cookie lines with their values); any exit 0 with JSON is signed in (even zero subscriptions), and the feed's "Incomplete yt initial data" warnings mean signed out (EXPIRED_COOKIES), a meaning specific to this feed, so it is not in the shared subscription-import classifier. One test at a time (409), rate-limited by cookieTestRateLimiter.
  • server.js: Express entry point; logs unhandled promise rejections instead of exiting (issue #921). db.js: Sequelize setup. logger.js: Pino logger with request correlation.
  • modules/databaseSocket.js + modules/youtubeExistenceCheck.js: keep an internet outage from becoming a database outage (issue #921). dns.lookup runs on libuv's threadpool, which runs only 2 lookups at a time by default; offline, each internet lookup there hangs ~16 s, and the database host's lookup queued behind them outlasted mysql2's 10 s connect timeout although the database was fine. databaseSocket is mysql2's stream factory (db.js dialectOptions.stream): it reuses the last address a connection reached and looks the host up again only after a connection fails before connecting. youtubeExistenceCheck is the "does this video still exist" oEmbed HEAD check behind the channel page and Videos page listings: its lookups go through c-ares (dns.Resolver, off the threadpool, ~4-6 s to fail offline; A and AAAA in parallel, IPv4 listed first, so a host that reaches YouTube only over IPv6 still connects), it answers exists/removed/unknown (callers leave youtube_removed_checked_at alone on unknown, so the check runs again later), and after a request gets no answer it pauses checks for 5 minutes, then lets one request at a time test whether YouTube answers again.
  • 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.<n>.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 <channel>@latest so the channel survives container recreation, latest-version checks hit the matching GitHub repo per channel, downloads no longer block updates, and startup re-applies the configured channel via shouldReapplyChannel when the installed binary's version shape drifts), messageEmitter, jobAuxData (serialize/parse for Jobs.aux_data, the JSON snapshot of job.data minus videos - failed downloads, diagnoses, skip counts, terminated channels - written at job save and merged back on startup load so Download History detail survives restarts; persisted failedVideos capped at 1000), playlistModule (YouTube playlist lifecycle + auto-create source channels; every fetch uses yt-dlp's default webpage path, capped at 5000 entries, with an InnerTube fallback on failure or a short result. A separate metadata-only request verifies the total; missing/conflicting totals and partial snapshots cannot initialize/reset following or prune tracked rows. Following setup above 5000 entries returns 422, unverifiable snapshots return 503, and concurrent refreshes return 409. First enable follows future discoveries only; an older auto-enabled playlist without a cutoff initializes the same way on its first successful sweep, without seeding a batch. Existing cutoffs survive upgrades. New baselines capture the highest tracked row id after refresh; legacy timestamp cutoffs still use preserved added_at. refreshForFollowing owns refresh/reload for subscribe, first enable, explicit reset, and scheduled downloads. Only an explicit reset clears saved batch requests, and it preserves the paused/running state. first_seen_at records immutable discovery; downloaded_at records actual download time during reconciliation. Removal remains a soft delete (enabled=false); re-subscribe restores saved settings. An idle successful auto-download sweep records one empty Complete 'Playlist Downloads' job, visible only with "Show jobs with no videos" enabled), m3uGenerator (writes __playlists__/*.m3u fallback files; entries prefer the file matching the playlist's Download Type - mp3 for MP3 Only playlists, video otherwise - and fall back to the other so no downloaded item is dropped, and leave out deleted rows (videoRemovalState.removalState), which keep their stored paths; missing rows stay listed, since their file may come back; entries follow the playlist's saved sort_order ('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel <channel folder>.m3u files at the top of each channel directory (per-channel m3u_enabled/m3u_sort_order settings, entries filtered to files that exist on disk, no .ignore marker so Jellyfin/Emby auto-import them; regenerated on download completion, in-app deletion, settings changes, channel enable/disable, and rescan completion (the scheduled rescan, nightly by default, doubles as the periodic refresh); zero-entry channels get the file deleted instead of an empty playlist)), playlistDownloadGrouper (resolves per-video download settings for playlist downloads (override -> channel -> playlist -> global) and groups videos by resolved settings), subfolderModule (subfolder registry: getAll returns the union of the subfolders table + config default + Plex-mapping subfolders; register idempotently upserts a name; delete guards removal against non-empty disk paths and active references), channelDownloadAllModule ("download all videos for a channel tab": selects every known never-downloaded channel video (excluding ignored/YouTube-removed/members-only/live, and anything ever downloaded even if since deleted, or listed in the download archive without a row (archiveOnlyIds, e.g. emptied from the trash with the archive kept) - download-all is not a re-download mechanism), previews count + total duration for the confirmation dialog, and queues one doSpecificDownloads job labelled Channel Download All: <title>; these jobs are exempt from the 6-hour absolute runtime cap since large channels legitimately take days, with the 30-minute activity timeout remaining as the hang guard), manualDownloadGrouper (buckets manually pasted URLs by resolved per-channel resolution/audio format before job creation; per-URL channel attribution prefers the channelId captured during URL validation (server cache merged over the client echo), then videos.channel_id, then channelvideos listers, gated to tracked+enabled channels; multi-bucket pastes share one downloadRunTracker run labeled 'Manual downloads'; file structure is NOT grouped - manual jobs always download nested to temp and the post-processor resolves flat-vs-subfolder per video from its real channel at finalize, so bulk imports are structurally correct regardless of pre-download attribution), youtubeUrlParser (pure YouTube URL -> video-id parsing shared by videoValidationModule and manualDownloadGrouper), playlistVideoFilters (builds publication/discovery/download ordering before pagination and resolves the playlist videos listing's downloadState/watchedState filters into a youtube_id constraint for the paginated page query; models and watchStatusQueries are passed at call time so the playlists route keeps its dependency injection), channelSearchModule (YouTube channel search: YouTube Data API search.list type=channel + channels.list enrichment when a key is configured, yt-dlp channel-filtered results-page fallback otherwise; stamps subscribed from enabled Channel rows; videoCount is API-path only), resolutionTier (measures a video file's actual pixel dimensions via ffprobe - ground truth over info.json, which can describe a different format than what's on disk - storing the raw "WIDTHxHEIGHT" string on Videos.video_resolution at download time and via the rescan backfill ("0x0" = probe failed, stamped once so failed rows aren't re-probed nightly); also feeds videoMetadataModule's available-resolutions list: format_note for landscape formats, but the selection class (smallest ladder rung >= pixel height, via selectionTierForHeight) for vertical ones, because YouTube's format_note labels verticals by short edge (1080x1920 = "1080p") while the app labels them by selection class - without the conversion a top-of-ladder vertical download's tier would be absent from the modal's list. Interpreting dimensions into the displayed tier is deliberately display logic, in client/src/utils/videoResolution.ts: landscape maps to the YouTube ladder (wider-than-16:9 by long edge * 9/16, else height, floor-snapped), vertical maps to its SELECTION class (smallest rung >= pixel height, matching the height-capped yt-dlp selector, so 608x1080 reads 1080p and 1080x1920 reads 2160p); the listing chips (DownloadFormatIndicator) and the video modal (608x1080 (1080p)) share that util, so they always agree, and label-rule changes need no migration or re-probe).
  • modules/titleFilterRegex.js: evaluates channel title filter regexes with Python's re (via utils/title-filter-regex.py, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's --match-filter exactly. Channel downloads apply the pattern through ytdlpCommandBuilder.buildMatchFilters, which escapes only ' and & because yt-dlp's match_str unescapes nothing else. Playlist title filters are separate: playlistModule.buildTitleFilterRegExp evaluates them as case-insensitive JavaScript regexes, and the playlist settings route rejects patterns it cannot compile.
  • modules/storageUsage.js: total bytes of downloaded videos (SUM(file_size + audio_file_size) over non-removed rows, never a disk walk) and the shared STORED_BYTES_SQL fragment the oldest-first auto-removal candidate queries use, so audio-only (MP3) videos count their real size.
  • modules/storageGuard.js: the storage download pause (issues #196/#217). Optional downloadPauseUsageLimit (storageUsage total) and downloadPauseMinFreeSpace (df) triggers, both off by default; a failed measurement never pauses (fail open). assertDownloadsAllowed() throws DownloadsPausedError (code DOWNLOADS_PAUSED) from downloadModule's fresh-request entry points (doChannelAndPlaylistDownloads, doChannelDownloads, doSpecificDownloads, doPlaylistDownloads; queued jobs started with isNextJob skip it), which routes map to 409 via the injected storageGuard.isPausedError (Download New reaches doChannelAndPlaylistDownloads through the scheduler's channelDownloadFrequency task rather than calling it directly, so its own pause check runs first, inside autoDownloadScheduler.getRunBlocker, and a paused Download New request gets the downloads-paused reason instead of a bare 409); the scheduled sweep records a skipped run first, and a pause hit mid-sweep stops the playlist loop and is reported as pausedReason (not a failure); jobModule.startNextJob refreshes the guard (so every finished job re-checks) and holds Pending jobs while paused, restarting the queue on the guard's resumed event. startNextJob coalesces overlapping calls into one in-flight scan (the resumed event fires inside the refresh a scan awaits, and a channel job stays Pending while it builds groups, so two scans would start it twice) and catches the unawaited job action's rejection; a job whose action rejects while it is still Pending is marked Error by failUnstartedJob, which waits for the launching scan to settle and then scans again, so the queue never stalls behind it (the single-group channel job's pre-spawn failure likewise reports a jobIssue to its run and calls startNextJob). subscriptionImport calls startNextJob when an import finishes (the import occupies the queue but completes outside the download path, so jobs queued or held meanwhile would otherwise stay Pending). The downloaded-size SUM runs only when a usage limit is set; GET /api/jobs/download-pause opts in with refresh({ includeUsage: true }) for the settings page. /updateconfig rejects size values other than blank or <positive int>MB|GB|TB; configModule.normalizeStorageSizeFields corrects or clears hand-edited ones on load (startup and file-watch reload), because the client saves the full config and one bad value would otherwise block every Settings save. On a paused-state flip it broadcasts downloadPauseChanged, sends notificationModule.sendDownloadPauseNotification (paused and resumed), and re-checks every 5 min while paused; deletions re-check when paused. GET /api/jobs/download-pause serves the status; the client shows it through hooks/useDownloadPauseStatus, layout/DownloadPauseBanner (every page, dismissible per pause except on /downloads), and shared/DownloadPauseAlert, with settings at /settings/storage-limits (Configuration/sections/StorageLimitsSection.tsx).
  • modules/playlistDownloadModule.js: shared eligibility for scheduled, download-all, explicit-id, preview, batch queueing, and download/following count paths, with dependencies passed at call time. Requests persist for scheduled retry only when auto-download is enabled and a baseline exists; queue failures are logged with playlist id and selected count. Successful downloads clear their request flags. Scheduled selection returns disjoint discovery and older saved-retry pools, each capped at the configured limit. Requested discoveries belong only to the discovery pool. Older requests rotate by playlistvideos.auto_download_last_attempt_at (DATETIME(3), nullable), recorded before queue submission on explicit saved batches and scheduled retry selection, outside the pure selector. Null attempts remain in the bounded pool. The original batch queues in full. Discovery jobs are admitted before any retry settings group; retry jobs use Playlist Retry: <title> (displayed as Saved playlist retries), retaining URL-list processing, notifications, and run aggregation. Already-active entries are excluded before selection. following_requested_count reports eligible outstanding selections in the header without claiming they failed. Explicit-id downloads can override ignored and allowRedownload retains its existing meaning. Without allowRedownload, entries the download archive lists without a Videos row (archiveOnlyIds.findArchiveOnlyIds; channel-ignored videos excepted, since a download removes their entry) are not eligible on any path: yt-dlp would skip them, so a sweep would queue them again every run.
  • modules/download/archiveAccess.js: coordinates archive-removing purges with downloadExecutor.doDownload, including preflight and finalization. Such purges are refused with 409 archive-busy while any download holds access; new downloads wait for an already-running purge to release it. Purges that keep archive entries can run during downloads.
  • modules/videoRemovalState.js + modules/libraryRootCheck.js + modules/videoTrashModule.js (+ routes/videoTrash.js): deleted vs. missing videos (issue #670). videos.removed stays the single "no file" flag every query reads; removed_reason (user_deleted, auto_removal with the rule in removed_detail, e.g. "older than 30 days", not_found, marked_deleted) and removed_at say why, and videoRemovalState derives present | deleted | missing (missing = removed with not_found; every other removed row, including the NULL reason of rows removed before the columns existed, is deleted; removedColumns(reason) / presentColumns() are the only writers, stateWhere / notStateWhere the only filters). Deleted stays deleted: the page-load file check (fileCheckModule) and the rescan (rescanRowUpdate) only mark present rows missing, and a file that reappears clears all three columns in every writer (download persistence, job recovery, library repair, file check, rescan). libraryRootCheck.checkLibraryRoot(baseDir) guards every removed: true write against an unmounted drive or share: the downloads root must exist and hold something, and so must each __subfolder in it (missing | empty | unreadable | subfolder-empty | subfolder-unreadable; a root failure is reported alone, while every failing subfolder is reported, in paths, with the strictest reason (unreadable beats empty, so an unreadable share is never looked past as emptied on purpose) and path its first subfolder; message names no path: the root is "the downloads folder" and a subfolder goes by its own name, because the container path means nothing on the host). Entries Youtarr creates itself never count (.youtarr_tmp, __playlists__, leftover .youtarr_healthcheck_* files, named in filesystem/constants.js): a sweep or a restart creates them on the bare mount point while a share is down, and they would otherwise make it look mounted for good. isUnderLibraryCheckPath(check, filePath) says whether a failing check covers a file (root reasons cover every file, subfolder reasons only the files under the failing subfolders), isRowUnderLibraryCheck(check, row) whether it covers either of a row's files, and libraryCheckCoversRows(check, Video, where) whether it covers any row the filter matches (a LIKE on both path columns, wildcards escaped), so an empty __subfolder no matching row is stored in (for example one a channel was moved out of) blocks nothing. The file check runs the check lazily, once per pass and only when a row would be marked missing, holds back the rows it covers, and returns them as libraryWarning ({ reason, path, message, heldBack }), which /getVideos passes through for the Videos page banner (the channel page gets the suppression only; its listing query reads plain rows, raw: true, because the check spreads them and a Sequelize instance loses its getters when spread); its writes carry a removed predicate, so only a present row becomes missing and only a removed row is restored even if a delete lands inside the request. The rescan checks first, and only counts a failure that covers present rows: a failing root with present rows ends the run as error ("Nothing was marked missing; is the drive mounted?"), an empty or unreadable __subfolder lets it finish but hold back missing marks for the rows stored in it (notMarkedMissing, outcome partial, named in the message), and with no present rows (a fresh install) it runs normally. A manual rescan may skip the check: POST /api/maintenance/rescan-files with ignoreLibraryCheck: true ("Rescan anyway" in the Maintenance card, offered only for the empty reasons, for a folder the user emptied on purpose); the scheduled task never passes it, and the run record says the check was skipped. Automatic cleanup (videoDeletionModule.performAutomaticCleanup) and the orphan folder sweep record a skipped run with the check's message while the check covers present rows (_libraryBlock; a dry run never checks; once the sweep runs, it removes an empty __subfolder nothing is stored in, which clears the check), and manual deletes (deleteVideos, deleteVideosByYoutubeIds: one check per batch, fail-open if the check itself throws) refuse each covered video with the message rather than stamping a row whose file may sit on an unmounted share; job recovery likewise leaves the removal columns alone when it cannot find the file. /getVideos filters: missingFilter is strictly missing, deletedFilter (default off; the Videos page sends exclude and persists the user's choice in localStorage) hides or isolates deleted rows and returns hiddenDeletedCount while excluding; the channel listing takes the same deletedFilter, appended as the last positional argument through channelModule -> channelVideosService -> channelVideoQuery. The client mirrors the states in utils/videoRemoval.ts (getRemovalState, describeRemoval, the plain-language sentence shown as the 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.
  • modules/download/: download orchestration (downloadExecutor, ytdlpCommandBuilder, DownloadProgressMonitor, tempPathManager, videoMetadataProcessor, customArgsParser (allowlist gate for the Settings "Custom yt-dlp options" field: checkCustomArgs resolves every token to its canonical yt-dlp option the way optparse does -- exact name, unique long-option prefix, short alias, grouped short options -- then checks it against ytdlpOptionTable; returns {ok, args, blocked, warnings, errors} where args are canonical names + values, so yt-dlp reads them the same way the check did, even after a runtime yt-dlp update; blocked options and malformed tokens are skipped individually and the rest still apply; replaces the old bypassable denylist, advisories GHSA-xj5v-qwfw-gp7j / GHSA-jprg-87hp-2phh), ytdlpOptionTable (the hand-maintained ALLOW/WARN/BLOCK policy over ytdlpOptionSpecs, the full yt-dlp option list generated by scripts/generate-ytdlp-option-specs.py (see docs/DEVELOPMENT.md); ALLOW and WARN are explicit allowlists and every other known option and unknown token blocks, so a dangerous future flag is denied until classified; /updateconfig rejects only a changed value and always returns customArgsCheck for the value it saved (the client applies it after Save), /getconfig carries it for the saved value, buildCustomArgs re-checks at download time, and the client renders the blocked/unreadable/warning notices from the server (Configuration/sections/components/CustomArgsCheckAlerts) -- it never carries the option list), subtitleLanguages (pure: parses the comma-separated subtitleLanguage setting and says which entries are bare codes; ytdlpCommandBuilder.buildSubtitleLanguageSelector expands each bare code into a --sub-langs regex that also matches YouTube's regional and script tracks (en-GB, zh-Hans), since yt-dlp matches every entry as a case-insensitive full regex, while en-orig, hand-edited codes, the all alias, exclusions, and regexes pass through verbatim; the subtag groups switch case-sensitivity back on so -orig and the <target>-<source> machine translations of uploaded tracks stay out; the pattern is verified against yt-dlp's own matcher because Node 20 cannot evaluate (?-i:...)), subtitleSidecarPruner (pure: yt-dlp picks uploaded and auto-generated tracks from one list keyed by exact code, so an uploaded en-GB and the auto en both download and YouTube's machine translations include pt-PT next to pt; from the info.json subtitles keys the per-video post-processor keeps, per configured bare language, the uploaded track(s), else the bare auto track, else everything; a code listed verbatim is never deleted but still counts as uploaded when it is, and nothing is pruned when any entry is an alias, exclusion or regex, when the custom yt-dlp args carry --sub-langs/--srt-langs/--all-subs or any prefix of them yt-dlp accepts, such as --sub-lang (a repeated --sub-langs is appended, not replaced), when the uploaded codes are unknown, or for other videos' files in a flat folder), 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; getUnfinishedJobs(runId) lists that run's still-Pending/In-Progress jobs, which autoDownloadScheduler -- given the tracker by server.js via setRunTracker -- polls so the Automatic downloads scheduled task reports as running until every channel, playlist, and automatic-retry job of the sweep finishes, while the task's history row stays open until the sweep ends: channelAutoDownload returns once the sweep is queued (releasing the scheduler's lock exactly as before) with a finalRecord from channel/sweepCompletion.waitForSweepEnd (ends on the tracker's onRunFinished/getFinishedRun, at a storage pause with none of the run's jobs in progress or still reporting, when the tracker knows neither the run nor its end, or after 48 h; never rejects) mapped by channel/sweepRunSummary.toRunRecord (counts; failed videos, job issues, stopped groups, or playlist check failures make it error/partial; a pause ends it as success/paused; it carries the sweep's real finishedAt, which scheduledTaskManager.describeResult passes to scheduledTaskRuns.finish in place of the current time). The tracker remembers the last 20 finished runs, so a sweep that finished before anyone listened still reports its end. maybeFinalize finishes a sealed run only once every job has reported: updateJob sets a final status before the finalizer reports the job's totals (its videos are reloaded from the database first), so a job with a final status that has not reported holds the run for REPORT_GRACE_MS (5 min, one timer per run); after that it is counted as an issue (reason: the job's output) and the run is dated to when that job ended. jobModule.onJobEnded (every final status, wired to tracker.handleJobEnded in server.js) makes sure a job that ends without reporting still starts that clock, for manual download runs too. Job-level failures reach the run as jobIssue ({ status, reason, byUser }): the ungrouped finalizer (read back from the job's persisted terminal status; an Error whose failed videos were all handed to an automatic retry, without a bot check, is left to the retry job's own result), the executor's output-directory and spawn failures, the single-group channel job's pre-spawn failure, and jobModule.failUnstartedJob through jobModule.onJobAbandoned (wired to tracker.handleAbandonedJob in server.js, so jobModule never requires the tracker). jobIssues make the run's summary a warning, appear in its text, the activity page card, and every notification formatter (notifications/utils.getStoppedLines), and notify unless the user terminated the job), 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; an empty subtitle response prints the ERROR with no WARNING, so an ERROR between [info] Writing video subtitles to: and the media download's first [download] Destination: is held as subtitle-only, and downloadResultProcessor.partitionDownloadResults turns it into a subtitle failure through dropSubtitleOnlyFailure only when the video's file exists), 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; the finalizer runs the in-progress cleanup after a terminated run, a bot-detected run, and any other non-zero yt-dlp exit, because a video whose merge failed leaves its fully downloaded streams in temp where they can fill the disk before the next job's temp clean; skipped when the run finalized through the stdio drain fallback, since a post-processor child may still be moving a finished video), tempSpaceProbe (decides which failed videos ran out of temp space, for the temp-out-of-space diagnosis: explicit "No space left on device" text, or a Conversion failed! whose leftover temp files are larger than the temp folder's free space (fs.statfs); measured by the finalizer before any cleanup, never throws, and an unmeasurable filesystem flags nothing), 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 (out of temp space, 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/channel/tabCountSources.js + tabCountThrottle.js + tabVideoCounts.js + tabDownloadStats.js: per-tab download percentages (issue #865). Totals come from each tab's auto-generated playlist (UULF/UUSH/UULV + channel id minus UC; members-only videos live in separate UUM* playlists and are not counted), via YouTube API playlists.list (50 ids per unit) when a key is set, else yt-dlp --flat-playlist --playlist-items 0 ("The playlist does not exist" = empty tab = 0, but every tab empty = failure). Stored in channels.tab_video_counts (JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (channelVideoCountsFrequency daily, and refreshAtStartup 2 minutes after boot, recorded as a startup run only when it counted something; server.js wires in the run history via setRunHistory and the download check via setDownloadActivityCheck) pick the source at the start: with a key, every enabled channel (startup: counts missing or 24h old) in 50-id batches; without one, or when the API call fails, lookupChannelsPaced counts one tab at a time with a 3 s gap (yt-dlp's --sleep-requests only spaces requests inside one process) over channels whose counts and last attempt (channels.tab_counts_attempted_at, stamped when a lookup starts) are 3+ days old, oldest attempt first, capped at 200 lookups per run at a whole-channel boundary, and saves each channel as it finishes. Bulk yt-dlp runs go WITHOUT cookies (a bot check lands on the IP, not the account) until two runs in a row are bot-checked without counting anything while cookies are configured (an IP YouTube always bot-checks without cookies, like a VPS); then they send cookies, still paced, for 30 days from the switch before trying without again (on such an IP each retry costs two throttled runs, about a day of missed counts a month). A bulk run stops at the next channel boundary while a download is running (stopBeforeChannel) and before any lookup while bulk refreshes are paused (stopBeforeLookup, so an overlapping on-demand throttle ends it); neither stop starts or clears a pause. A bot check, HTTP 429, or "try again later" stops the run (outcome throttled) and starts a pause (tabCountThrottle: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope is bulk for a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) and all otherwise, including a bulk run that sent cookies. The run history is the persistence layer for the pause, the cookie mode (bulkCookiesSince) and the consecutive bot-check count (cookielessBotChecks), kept as independent parts: every executed bulk run (empty, API, failed, or stopped) writes currentDetails() into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only through clearIfUnchanged, so a run never clears a pause that started while it ran, and clearing or decaying the pause leaves the cookie mode alone. Three unexplained failures in a row end the run without a pause (channel-unavailable errors don't count). On-demand refreshes (after subscribe; channel page open when older than 24h, 1h after the last attempt) use the API, else yt-dlp with cookies two at a time, and skip yt-dlp while an all pause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included, youtube_removed and members-only excluded) through the channel's listing row or, without one, videos.channel_id + media_type; ignored counted separately; percent floors and caps at 100. Served as tab_download_stats on /getchannels and by GET /api/channels/:channelId/tab-stats (adds loaded).
  • 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), 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/)

  • App.tsx: app routing plus a global fetch() override that detects 503 requiresDbFix responses and surfaces the database error overlay. You can use normal fetch() anywhere; database errors are handled automatically.
  • components/: feature directories and pages. Complex features pair a top-level FeatureName.tsx with a same-named FeatureName/ directory holding components/, hooks/, and __tests__/. Examples of this sibling-file layout: Subscriptions.tsx + Subscriptions/ (renamed from ChannelManager), Configuration.tsx + Configuration/, ChannelPage.tsx + ChannelPage/, PlaylistPage.tsx + PlaylistPage/. Newer features (e.g. SubscriptionImport/) put the main component at FeatureName/index.tsx instead; either layout is acceptable for new features.
  • components/shared/: reusable components used across multiple features (e.g. VideoModal/ for the video detail modal, ThumbnailClickOverlay for clickable thumbnail hotspots, DeleteVideosDialog, AddChannelDialog for confirming a channel add from search results or an unsubscribed channel name click, OpenInYouTubeLink for the link out to YouTube on the channel and playlist page headers and Find Channels result cards).
  • components/ui/: theme-neutral UI primitives (Button, Card, Dialog, Select, etc.) built on Radix and styled via CSS variables + Tailwind. Use these instead of Material-UI imports in new code. AccordionContent stays mounted when collapsed (tests can query it) but is invisible, so its controls leave the tab order and accessibility tree.
  • components/layout/: app shell and navigation chrome. AppShell.tsx is the outer frame; NavSidebar.tsx / NavHeader.tsx own desktop and mobile nav; navLayoutConstants.ts holds shared sidebar/header sizing constants; layoutFallback.css provides fallback CSS variables for themes that skip layout overrides.
  • components/Settings/: Settings page wrapper and splash index (SettingsIndex.tsx) listing the per-section routes under /settings/<key>. MaintenanceSection.tsx hosts the manual filesystem rescan trigger and the "Deleted and missing videos" card (Settings/components/RemovedVideosCard.tsx + EmptyTrashDialog.tsx, data through Settings/hooks/useRemovedVideos.ts: the counts, "Mark all missing as deleted", and "Empty trash..." with its which-videos / download-archive choices and an explicit cannot-be-undone acknowledgement; the missing choices are disabled with the reason while the downloads folder looks unmounted, driven by missingActionsBlocked, except that for an empty folder Mark all stays available once the user confirms they emptied it, sending ignoreLibraryCheck, so "Rescan anyway" is never a dead end; Mark all is disabled while a rescan runs). The Videos page has the matching per-selection "Mark as deleted" action (VideosPage/components/MarkDeletedDialog.tsx, VideosPage/hooks/useMarkDeleted.ts; the shared video modal offers it for one missing video, from any page), a Deleted filter chip resting at exclude and remembered in localStorage (shared/VideoList/usePersistedChipFilter.ts; status chips with a defaultValue other than off count as active, and show a Show: ... chip, only when moved off it), 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.
  • config/configSchema.ts: the CONFIG_FIELDS registry. Use this pattern when adding new configuration fields; it auto-derives types, defaults, and change tracking.
  • types/, utils/: shared types and helpers.

Database

  • MariaDB 10.3 with utf8mb4. Migrations in migrations/ run automatically on container startup. Create new migrations with ./scripts/db-create-migration.sh migration-name.
  • 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. 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.

Reference docs (consult these instead of re-deriving from code)

  • docs/DEVELOPMENT.md: dev environment setup, ./scripts/build-dev.sh (flags: --install-deps, --no-cache) and ./scripts/start-dev.sh (flags: --no-auth, --debug, --headless-auth, --pull-latest, --dev, --arm, --external-db), Vite HMR dev server option (cd client && npm run dev on port 3000), running tests, Swagger/OpenAPI docs at /swagger and /swagger.json.
  • docs/DOCKER.md: Docker architecture (two containers), Compose file variants (main, ARM override, external-db), volume layout, NAS configuration, backup/restore, platform deployment (DATA_PATH, AUTH_ENABLED, PLEX_URL).
  • docs/DATABASE.md: schema tables, internal vs external database, migration workflow, idempotent migration helpers, troubleshooting.
  • docs/ENVIRONMENT_VARIABLES.md: all env vars (DB_*, YOUTUBE_OUTPUT_DIR, DATA_PATH, YOUTARR_UID/GID, AUTH_ENABLED, AUTH_PRESET_*, PLEX_URL, LOG_LEVEL, LOG_FILE_MAX_SIZE, LOG_FILE_MAX_COUNT, TZ, YOUTARR_IMAGE).
  • docs/CONFIG.md: config/config.json schema. Real field names include plexApiKey, plexYoutubeLibraryId, plexSubfolderLibraryMappings, plexIP/plexPort/plexViaHttps/plexUrl, channelAutoDownload, channelDownloadFrequency (cron), channelFilesToDownload, preferredResolution, videoCodec, defaultSubfolder, plus groups for SponsorBlock, notifications (appriseUrls), download performance, and auto-removal. Legacy cronSchedule is auto-migrated to channelDownloadFrequency on startup.
  • docs/AUTHENTICATION.md: Plex OAuth, local auth (bcrypt, username/passwordHash), API keys (x-api-key header, rate-limited, download-only scope), session management (x-access-token, 7-day expiry), headless bootstrap via AUTH_PRESET_*.
  • docs/API_INTEGRATION.md: API key integration for external tools (bookmarklets, iOS Shortcuts, Android Tasker, Home Assistant, cURL/Python/JS). Documents the /api/videos/download endpoint, rate limits, and CORS/auth-proxy bypass for Cloudflare Zero Trust and Authelia.
  • docs/TROUBLESHOOTING.md: common runtime issues.

Implementation notes (non-obvious behaviors)

  • Plex integration uses OAuth; the user must be the Plex server admin. The target Plex library must be "Other Videos" with the "Personal Media" agent.
  • Video downloads: yt-dlp writes to a temp location, then post-processing finalizes files into the channel's target directory. Resume-on-partial-download is supported.
  • WebSocket server shares the HTTP server port (3011 in container, 3087 on host) via the ws library; there is no separate WS port. The upgrade bypasses Express (no verifyToken), so webSocketServer authorizes it itself through webSocketAuth before the handshake: only /ws, and only with an active session (sessionAuth.findActiveSession, the same lookup verifyToken uses; API keys are refused; anything goes with AUTH_ENABLED=false). Browsers cannot set handshake headers, so WebSocketProvider (rendered inside AppContent, given the token, no connection while it is null) offers youtarr plus youtarr.auth.<token> as subprotocols and the server echoes youtarr, which keeps the token out of URLs and proxy logs. Open sockets are re-checked every 5 minutes (one lookup per session, however many tabs share it) and closed (1008) once their session expired or was revoked; a lookup error keeps them. A refused handshake reaches the browser only as 1006, so the provider calls onSessionCheckNeeded on a 1008 close and on every 3rd attempt in a row that never opens (so a check that could not reach the server during an outage is retried, about every 90 s at the 30 s backoff cap); AppContent then re-runs /auth/validate and signs out only on 401/403, so an outage never logs anyone out. Clients never send data (maxPayload 1 KB), and every socket has an error listener, because ws rethrows an invalid frame's error without one and that crashed the process. Broadcast message types include videoActivityUpdated (invalidates GET /api/jobs/video-activity snapshots), downloadProgress, downloadComplete, jobsUpdated (emitted when a download job is enqueued or flips to In Progress), and videosUpdated (emitted per video mid-batch after its DB rows are persisted); listing pages and the Download History page subscribe via client/src/hooks/useDownloadListingsRefresh.ts to refetch in near real time. The server pings clients every 30s and terminates unresponsive ones (proxy idle-timeout guard); on connect it replays only the last stored final-state message, never in-flight progress. The activity page therefore seeds its initial state over REST: GET /api/jobs/current-activity returns downloadModule.getCurrentActivitySnapshot() (the executor's retained progress monitor read via the side-effect-free toPayload(), plus the stored last-final message), consumed by useCurrentActivitySeed in client/src/components/DownloadManager/hooks/, which seeds only until the first live broadcast arrives and re-probes on tab-visibility and on the provider's local connectionRestored message (emitted after every reconnect). client/src/utils/jobTypeLabel.ts is the client-side mirror of server/modules/download/jobTypes.js labels for the activity header, queued chips, and job summaries.
  • Deno is installed in the container; yt-dlp uses it automatically for JS-heavy extractors.

Code Quality Standards (for new and rewritten code)

These standards apply when you are authoring new code or doing an explicit rewrite. See Scope Discipline above.

General Principles

  • DRY when writing the third copy: if you find yourself writing the same logic a third time in your own new code, extract it.
  • Single responsibility: each module, class, or function should have one clear purpose. If a function name requires "and" to describe it, split it.
  • Extract deliberately, inline small snippets:
    • Extract when a block is substantial and has a clear external interface. Rough threshold: ~30+ lines of JSX/logic with its own props or state, or two usages in structurally different parents (e.g. mobile card vs desktop table row). Size and interface clarity matter more than copy count.
    • Inline one-off snippets used in a single place. Three similar lines of code is better than a premature abstraction. The "three copies" rule applies to small repeated patterns, not to substantial blocks.
    • Before creating a new component/module file, answer: (1) is this substantial enough to extract, or should it stay inline? (2) which directory -- feature-local, shared/, or ui/ -- and why? (3) what test file am I adding alongside it?
  • File size targets for new files: backend modules under ~500 lines, frontend components under ~300 lines. Several legacy files exceed these limits (notably jobModule.js, channelSettingsModule.js, videoDeletionModule.js, VideosPage.tsx, ChannelVideos.tsx, DownloadProgress.tsx) - do not use them as examples. The codebase is migrating toward these targets over time.
  • Named constants for magic values: in new code, timeouts, retry counts, limits, and defaults should be named constants at the top of the file or in a shared constants module.
  • Early returns for error and edge cases, to keep happy-path nesting shallow.
  • Comments: only where the logic is not self-evident. Do not add docstrings, type comments, or explanatory prose to code you did not change.

Backend Standards (Node.js/Express)

Logging

  • Always use the Pino logger (require('../logger')), never console.log or console.error.
  • Structured logging with context objects: logger.error({ err: error, videoId }, 'Download failed'), not string interpolation.
  • Levels: error for failures needing attention, warn for recoverable issues, info for significant operations, debug for diagnostic detail.

Error Handling

  • Standard error response shape: res.status(statusCode).json({ error: 'Human-readable message' }). The codebase has drift ({ success: false, error }, { status, message }); new code must use { error }.
  • Always catch async errors in route handlers with try-catch. Never let unhandled promise rejections escape.
  • Specific status codes: 400 for bad input, 401 for unauthenticated, 403 for forbidden, 404 for not found, 409 for conflicts, 503 for service unavailable.
  • Log errors with context: include relevant IDs, operation names, and the error object.

Route Patterns

  • Dependency injection factory pattern for all new route files:
    function createMyRoutes({ verifyToken, myModule }) {
      const router = express.Router();
      // ...
      return router;
    }
    module.exports = createMyRoutes;
    Never import modules directly inside route files; receive them as factory arguments.
  • Keep routes thin: validate input, call module methods, format responses. Business logic belongs in modules.

Module Patterns

  • Class-based singletons: new modules export a single instance (module.exports = new MyModule()).
  • Avoid circular dependencies: restructure the dependency graph, extract shared logic, use events, or pass dependencies at call time. Do not use late-binding require() inside methods as a workaround. Some existing modules (e.g., cronJobs.js) use late-binding; do not copy the pattern.
  • Sub-module aggregator pattern for complex features: see server/modules/filesystem/ and server/modules/notifications/ as good examples.

Database

  • Sequelize model methods for queries, not raw SQL, unless there is a specific performance reason. Raw queries must use explicit parameter binding.
  • Create migrations using the script: ./scripts/db-create-migration.sh migration-name. Never create migration files manually.
  • Transactions for multi-step operations that must be atomic.

Frontend Standards (React/TypeScript)

TypeScript

  • No any in new code. Use proper types everywhere:
    • Error handlers: catch (err: unknown) with type narrowing.
    • API responses, WebSocket payloads: define interfaces.
    • Event handlers: use React's built-in event types.
  • Define interfaces for props, API payloads, and shared data. Co-locate feature-specific types with the feature; put shared types in client/src/types/.
  • CONFIG_FIELDS registry: use client/src/config/configSchema.ts when adding new configuration fields.
  • The codebase has ~300 existing any occurrences; do not add more.

Component Structure

  • When authoring or rewriting a component: aim for fewer than 10 useState calls and under 300 lines. If you cross that threshold while writing new code, extract logic into custom hooks and UI into child components.
  • Placement of new components and modules (prescriptive, not just descriptive -- shared/ is not the default):
    • Feature-local (used by a single feature/page): place in the feature directory, e.g. components/DownloadManager/VideoThumbnail.tsx. This is the default.
    • Cross-feature (imported by 2+ feature directories today, not hypothetically): place in components/shared/.
    • Theme-neutral UI primitive (Button-like, Card-like, no business logic): place in components/ui/.
    • Promote to shared/ later when a second feature actually imports it -- do not speculatively place in shared/ "in case" it gets reused.
  • Feature directory layout for new complex features, either of these is acceptable:
    # Sibling-file layout (most common in this repo)
    FeatureName.tsx              # the main component
    FeatureName/
    ├── components/
    ├── hooks/
    └── __tests__/
    
    # index.tsx layout
    FeatureName/
    ├── components/
    ├── hooks/
    ├── __tests__/
    └── index.tsx
    Sibling-file examples: ChannelManager.tsx, Configuration.tsx, ChannelPage.tsx. index.tsx example: SubscriptionImport/.

Custom Hooks

  • Extract data fetching and multi-step state logic into hooks. Each hook owns one concern.
  • Return shape: { data, loading, error, ...actions } for consistency.
  • Do not extract one-off state: local component state read in exactly one place stays inline.
  • Cleanup effects: always return cleanup from useEffect for subscriptions, timers, and event listeners.
  • Memoize deliberately: useMemo for genuinely expensive derivations, useCallback for callbacks passed to memoized children. Do not over-memoize trivial values.

Styling

  • Tailwind utility classes via className are the default for layout, spacing, and typography. Import UI primitives from ./ui (Box, Card, Typography, Button, Chip, Dialog, etc.); do not reach for MUI, it is not installed.
  • Theme tokens via CSS variables, not hardcoded colors: var(--destructive), text-foreground, text-muted-foreground, bg-card. Theme values are set on the document root by ThemeEngineContext.
  • Inline style is acceptable only for computed/dynamic values (e.g., a padding driven by isMobile, a length-dependent maxHeight). For static styling, prefer Tailwind classes.
  • Responsive breakpoints: use the custom useMediaQuery from hooks/useMediaQuery with a raw media query (useMediaQuery('(max-width: 767px)')) or the exported breakpoints.down('sm') helper. Tailwind's md:/sm: class prefixes are also fine for pure-CSS responsive behavior.

API Calls

  • Use Axios in new hooks and components. A few legacy hooks use raw fetch() (client/src/hooks/useConfig.ts and client/src/components/Configuration/hooks/usePlexConnection.ts); do not copy them.
  • Auth headers: headers: { 'x-access-token': token }. Hooks receive token from the calling component.
  • Type requests and responses with interfaces.
  • Handle specific status codes (403, 404, 503) rather than generic catch-alls.
  • Exception: the global fetch() override in App.tsx is the one place fetch is intentional, for database error detection.

State Management

  • React Context for cross-cutting concerns (auth, WebSocket, theme).
  • Local useState / useReducer for component-specific state.
  • Custom events sparingly, only for decoupled communication (e.g., CONFIG_UPDATED_EVENT).

Security

Youtarr shells out to yt-dlp and writes files using user-influenced paths, so this section is not optional.

  • Validate untrusted input at route boundaries. Check types, lengths, and shape before passing anything to modules. Return 400 with { error } for invalid input.
  • Never interpolate user data into shell commands or filesystem paths directly. Use the existing helpers: modules/download/ytdlpCommandBuilder.js, modules/filesystem/pathBuilder.js, modules/filesystem/sanitizer.js. If a new helper is needed, add it to those modules, not inline in a route.
  • Auth by default: new routes must wire verifyToken unless the user explicitly says the route is public. If you add a public route, call it out in your final report so the user can confirm.
  • Never log secrets. Pino redacts known keys, but do not bypass redaction by interpolating tokens, passwords, cookies, or API keys into log messages.
  • Treat uploaded files as untrusted: Takeout CSV and cookies uploads in the subscription import flow must be validated, size-limited, and parsed defensively.
  • Sequelize protects against SQL injection only when using model methods. Raw queries require explicit parameterization.
  • Rate-limit endpoints that accept external identifiers or credentials (follow the existing pattern in server/routes/videos.js and server/routes/auth.js).

Testing

Run tests from the project root. See docs/DEVELOPMENT.md for the full workflow.

npm run test:frontend                                        # all frontend tests
npm run test:frontend -- client/src/hooks/__tests__/foo.ts   # one frontend test
npm run test:backend                                         # all backend tests
npm run test:backend -- server/modules/__tests__/foo.test.js # one backend test
npm run lint                                                  # lint front + back
npm run lint:ts                                               # TypeScript typecheck

Tests Accompany New Files

New component/module files ship with tests in the same change. When you create a new .tsx/.ts component, hook, or backend module, add its test file in the same commit. Co-locate in the nearest __tests__/ directory. Cover externally-observable behavior: renders with required props, responds to interactions, handles the missing/error/loading states the component declares. The write-tests and write-tests-react slash commands scaffold these. Trivial pure-display components (no props, no logic) are the only exception.

Test Execution Policy

NEVER skip tests. No test.skip(), no describe.skip(), no commented-out failing tests. Fix failing tests or delete them if obsolete. If a test is written, it must run and pass in CI.

React Testing Library Rules

  • No direct DOM queries: no querySelector, parentElement, or other native DOM access. Use getByRole, getByLabelText, getByText, getByTestId, and within() for scoped queries. ESLint enforces this via testing-library/no-node-access.
  • No conditional expect calls.
  • Assert behavior, not implementation. Test what the code does, not how.

Jest Mocking Gotchas (project-specific)

These are real landmines that cost time, not general Jest advice. Full examples live in existing tests under server/modules/__tests__/ and client/src/**/__tests__/; consult them before mocking a new dependency.

  1. Mocking React components: do NOT use JSX inside jest.mock(). Jest hoists the mock and JSX fails with "Invalid variable access." Use React.createElement via require('react') inside the factory.
  2. Mocking axios: use a factory (jest.mock('axios', () => ({ get: jest.fn(), post: jest.fn(), ... }))) and require('axios') after the mock. Direct ES6 import axios from 'axios' fails with ESM errors.
  3. Mocking fetch JSON: mock json as jest.fn().mockResolvedValueOnce(...), not an async arrow function; the latter returns the wrong shape.
  4. Mock path resolution: mock paths are relative to the test file, not the component under test. Double-check ../ vs ../../.

Backend Test Setup

  • Place jest.mock() calls before require() imports.
  • Use jest.resetModules() and jest.clearAllMocks() in beforeEach, then re-require the module under test.
  • Mock only external dependencies (db, fs, HTTP, logger). Over-mocking internal module methods usually indicates a design issue.

Hook Test Setup

  • Use renderHook with explicit type annotations for parameterized hooks (e.g., renderHook(({ token }: { token: string | null }) => useMyHook(token), { initialProps: { token: null } })).
  • Use waitFor for async state changes, not arbitrary timeouts.
  • For polling/interval tests, use fake timers (jest.useFakeTimers()) and clean up with jest.runOnlyPendingTimers() + jest.useRealTimers() in afterEach.
  • Test cleanup on unmount.

Test Quality

  • Each test asserts one thing. A test with 15 assertions is testing too much.
  • Meaningful names: 'returns empty array when no channels exist', not 'test case 1'.
  • Minimal setup: only what the specific test needs; use beforeEach for genuinely shared setup.
  • Do not test private implementation details. If you need to expose internals to test them, the API design needs work.

Documentation Sync

When a code change creates or invalidates information in this file or in docs/, update the docs in the same PR. This is not optional; stale docs are how we got here.

  • New top-level route file or module: add a one-liner to the Architecture section above.
  • New feature directory under client/src/components/: add a one-liner to the Frontend section.
  • New or changed env var: update docs/ENVIRONMENT_VARIABLES.md.
  • New or changed config field: update docs/CONFIG.md and register the field in client/src/config/configSchema.ts.
  • New dev command or flag: update docs/DEVELOPMENT.md.
  • Schema changes: update docs/DATABASE.md.
  • API changes: Swagger annotations live with the routes; update them so /swagger stays accurate.
  • Changed services, environment, or ports in docker-compose.yml: mirror the change in the stack in docs/platforms/portainer.md, which keeps its own copy with absolute paths.

Git Workflow

Branches

  • dev: default branch for active development; all contributor PRs target dev.
  • main: stable release branch; merging dev -> main triggers a production release.
  • Feature branches: create from dev, merge back to dev via PR.

Release

  • Merging to dev builds an RC image (dev-latest, dev-rc.<sha>).
  • Merging dev -> main triggers a production release with semantic versioning.
  • Commit prefixes: feat: (minor), fix: (patch), BREAKING CHANGE: (major).
  • Docker images auto-publish to dialmaster/youtarr on release.

PRs

  • Contributor PRs target dev, not main.
  • PRs to dev get automatic Claude Code reviews.
  • CI runs on PRs to both dev and main.

Debugging

CI/CD

When debugging CI/CD or GitHub Actions issues, check both the permissions AND the actual command execution logs. A fix is not complete until the underlying behavior is verified working.

Download now (ZIP)

Trending

More talk lately.about the whole repository

Dec 2025: 0 mentionsJan 2026: 1 mentionFeb 2026: 0 mentionsMar 2026: 0 mentionsApr 2026: 0 mentionsMay 2026: 0 mentionsJun 2026: 0 mentionsJul 2026: 0 mentionsAug 2026: 4 mentionsSep 2026: 14 mentionsOct 2026: 25 mentionsNov 2026: 0 mentions
Dec 2025Jun 2026Nov 2026
Mentions a month of the DialmasterOrg/Youtarr repository that our weekly crawl found. There is no adoption data for this file yet.

Who's talking about DialmasterOrg/Youtarr

The biggest 5 of 45 mentions linking to the repository that our crawl found. Each line is their own headline.

See all 45

Talked about on GitHub and Hacker News (45 times).

On GitHub · 44

39 more on GitHub

On Hacker News · 1

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.