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
- Distinctive: line 19
Refactors: only when the user explicitly asks for a refactor, and only as wide as the user scopes it.
- Distinctive: line 21
When in doubt: ask. A small clarifying question is cheaper than an unwanted refactor.
- Distinctive: line 106
DRY when writing the third copy: if you find yourself writing the same logic a third time in your own new code, extract it.
- Very specific: line 111
Before creating a new component/module file, answer: (1) is this substantial enough to extract, or should it stay inline?
- Distinctive: line 115
Comments: only where the logic is not self-evident.
- Distinctive: line 140
Never import modules directly inside route files.
- Very specific: line 156
No
anyin new code: Use proper types everywhere: - Very specific: line 161
CONFIG_FIELDS registry: use client/src/config/configSchema.ts when adding new configuration fields.
- Very specific: line 193
Cleanup effects: always return cleanup from useEffect for subscriptions, timers, and event listeners.
- Very specific: line 200
Responsive breakpoints: use the custom useMediaQuery from hooks/useMediaQuery with a raw media query (useMediaQuery('(max-width: 767px)')) …
- Very specific: line 218
Validate untrusted input at route boundaries: Check types, lengths, and shape before passing anything to modules.
- Distinctive: line 248
Assert behavior, not implementation: Test what the code does, not how.
- Very specific: line 253
Mocking React components: do NOT use JSX inside jest.mock().
- Very specific: line 254
Mocking axios: use a factory (jest.mock('axios', () => ({ get: jest.fn(), post: jest.fn(), ...
- Very specific: line 259
Place jest.mock() calls before require() imports.
- Very specific: line 260
Use jest.resetModules() and jest.clearAllMocks() in beforeEach, then re-require the module under test.
- Very specific: line 264
Use renderHook with explicit type annotations for parameterized hooks (e.g., renderHook(({ token }: { token: string | null }) => …
- Very specific: line 265
Use waitFor for async state changes, not arbitrary timeouts.
- Distinctive: line 267
Test cleanup on unmount.
- Distinctive: line 273
Do not test private implementation details.
- Very specific: line 277
When a code change creates or invalidates information in this file or in docs/, update the docs in the same PR.
- Distinctive: line 279
New top-level route file or module: add a one-liner to the Architecture section above.
- Very specific: line 280
New feature directory under
client/src/components/: add a one-liner to the Frontend section. - Very specific: line 281
New or changed env var: update docs/ENVIRONMENT_VARIABLES.md.
- Very specific: line 282
New or changed config field: update docs/CONFIG.md and register the field in client/src/config/configSchema.ts.
- Very specific: line 283
New dev command or flag: update docs/DEVELOPMENT.md.
- Very specific: line 284
Schema changes: update docs/DATABASE.md.
- Very specific: line 291
dev: default branch for active development. - Very specific: line 293
Feature branches: create from dev, merge back to dev via PR.
- Very specific: line 298
Commit prefixes: feat: (minor), fix: (patch), BREAKING CHANGE: (major).
- Distinctive: line 309
When debugging CI/CD or GitHub Actions issues, check both the permissions AND the actual command execution logs.
≈ “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.
Files
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.logcalls,anytypes, 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 inutils/validate-cookies.py, memoizes unchanged results, and reports unusable files for omission;modules/uploadedCookies.jsgives the uploadedconfig/cookies.user.txta 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.jsowns 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);resolveRescanUpdaterecomputes each nominated row's write from a fresh per-format stat (present only for a regular file / missing onENOENTorENOTDIRor 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 meansremoved: falseeven 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 askept(videosKeptin the run details, not the message). The walk's count of folders and files it could not read isunreadableOnDiskin the run details and log (not the message: those rows are re-checked by path). Rows another writer changed are counted asskippedChanged(videosSkippedin the run record) and failed writes asfailed(videosFailed); both appear in the run message, and a finished scan with failed writes records aserror/partial(a timed-out scan staystimed-outso Maintenance can continue it); the completionrescanStatusbroadcast is built through the samerescanRunSummaryrecord 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 andremoved: falseonly for a file it actually found, and looks up a lost row's files by YouTube id with onevideosModule.scanForVideoFileswalk 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 aserror/partial, naming the count in its message (recreatedUnverifiedandunreadableOnDiskin the details); a partial walk that affected no row stayscompleted.modules/cookieDetails.js: summarizes the YouTube login cookies (names, counts, earliest expiry; never values) in the active cookie file asdetailsonGET /api/cookies/statusand the upload/delete responses, reading external files throughexternalCookies.readExternalCookies.modules/cookieTest.js:POST /api/cookies/test, one yt-dlp request to the subscriptions feed withbuildCommonArgs(same proxy/IP family/cookies/cache as downloads) throughytDlpRunner, 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 bycookieTestRateLimiter.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.lookupruns 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.databaseSocketis mysql2'sstreamfactory (db.jsdialectOptions.stream): it reuses the last address a connection reached and looks the host up again only after a connection fails before connecting.youtubeExistenceCheckis 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 answersexists/removed/unknown(callers leaveyoutube_removed_checked_atalone onunknown, 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 totrace, so the root level is the only filter andlogger.applyLevelSettingcan change it live. Only the server process (process.argv[1]isserver.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, toconfig/logs/youtarr.<n>.logthroughlogging/logFileTransport.js(pino-roll withremoveOtherLogFiles, so files from earlier runs are pruned too;LOG_FILE_MAX_SIZEdefault 10MB,LOG_FILE_MAX_COUNTolder 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 throughlogger.getLoggingStatus()(served asloggingon/getconfig). Never log a field namedlevel: it overwrites pino's level and the record is dropped.modules/logLevelSync.jsapplies thelogLevelsetting ('' =LOG_LEVEL) at startup, on config change, and in the post-processor.modules/logFilesModule.js+routes/logs.jsserveGET /api/logs/download(all files oldest first, numeric order), read line by line throughlogging/logScrubber.js, which replaces the configured keys/tokens and Apprise notification URLs by value plusX-Plex-Token/X-Emby-Token/api_keyvalues 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 inserver/routes/index.js.modules/: class-based singletons holding business logic. Top-level modules includechannelModule,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_removeOldestUntilFreedloop, 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, whenautoRemovalKeepRecentCountis 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 honorswatchStatusWatchedRule, 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 fromwatchStatusQueries.buildWatchedEligibilitySql, where a qualifying played row with a NULLlast_watched_atblocks days-since-watched removal),nfoGenerator,cronJobs,apiKeyModule,ytdlpModule(yt-dlp version management:ytdlpUpdateChannelconfig selects the stable or nightly release channel, every update runs--update-to <channel>@latestso 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 viashouldReapplyChannelwhen 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 preservedadded_at.refreshForFollowingowns 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_atrecords immutable discovery;downloaded_atrecords 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__/*.m3ufallback 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 savedsort_order('default' = YouTube playlist order, 'reversed' = flipped); also writes optional per-channel<channel folder>.m3ufiles at the top of each channel directory (per-channelm3u_enabled/m3u_sort_ordersettings, entries filtered to files that exist on disk, no.ignoremarker 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:getAllreturns the union of thesubfolderstable + config default + Plex-mapping subfolders;registeridempotently upserts a name;deleteguards 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 onedoSpecificDownloadsjob labelledChannel 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 onVideos.video_resolutionat 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, viaselectionTierForHeight) 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, inclient/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'sre(viautils/title-filter-regex.py, one process per batch, JSON on stdin) so the channel filter preview and validation match yt-dlp's--match-filterexactly. Channel downloads apply the pattern throughytdlpCommandBuilder.buildMatchFilters, which escapes only'and&because yt-dlp'smatch_strunescapes nothing else. Playlist title filters are separate:playlistModule.buildTitleFilterRegExpevaluates 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 sharedSTORED_BYTES_SQLfragment 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). OptionaldownloadPauseUsageLimit(storageUsage total) anddownloadPauseMinFreeSpace(df) triggers, both off by default; a failed measurement never pauses (fail open).assertDownloadsAllowed()throwsDownloadsPausedError(codeDOWNLOADS_PAUSED) fromdownloadModule's fresh-request entry points (doChannelAndPlaylistDownloads,doChannelDownloads,doSpecificDownloads,doPlaylistDownloads; queued jobs started with isNextJob skip it), which routes map to 409 via the injectedstorageGuard.isPausedError(Download New reachesdoChannelAndPlaylistDownloadsthrough the scheduler'schannelDownloadFrequencytask rather than calling it directly, so its own pause check runs first, insideautoDownloadScheduler.getRunBlocker, and a paused Download New request gets thedownloads-pausedreason 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 aspausedReason(not a failure);jobModule.startNextJobrefreshes the guard (so every finished job re-checks) and holds Pending jobs while paused, restarting the queue on the guard'sresumedevent.startNextJobcoalesces overlapping calls into one in-flight scan (theresumedevent 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 markedErrorbyfailUnstartedJob, 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 ajobIssueto its run and callsstartNextJob).subscriptionImportcallsstartNextJobwhen 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-pauseopts in withrefresh({ includeUsage: true })for the settings page./updateconfigrejects size values other than blank or<positive int>MB|GB|TB;configModule.normalizeStorageSizeFieldscorrects 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 broadcastsdownloadPauseChanged, sendsnotificationModule.sendDownloadPauseNotification(paused and resumed), and re-checks every 5 min while paused; deletions re-check when paused.GET /api/jobs/download-pauseserves the status; the client shows it throughhooks/useDownloadPauseStatus,layout/DownloadPauseBanner(every page, dismissible per pause except on/downloads), andshared/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 byplaylistvideos.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 usePlaylist Retry: <title>(displayed as Saved playlist retries), retaining URL-list processing, notifications, and run aggregation. Already-active entries are excluded before selection.following_requested_countreports 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 withdownloadExecutor.doDownload, including preflight and finalization. Such purges are refused with 409archive-busywhile 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.removedstays the single "no file" flag every query reads;removed_reason(user_deleted,auto_removalwith the rule inremoved_detail, e.g. "older than 30 days",not_found,marked_deleted) andremoved_atsay why, andvideoRemovalStatederivespresent | deleted | missing(missing = removed withnot_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/notStateWherethe 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 everyremoved: truewrite against an unmounted drive or share: the downloads root must exist and hold something, and so must each__subfolderin it (missing | empty | unreadable | subfolder-empty | subfolder-unreadable; a root failure is reported alone, while every failing subfolder is reported, inpaths, with the strictest reason (unreadable beats empty, so an unreadable share is never looked past as emptied on purpose) andpathits first subfolder;messagenames 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 infilesystem/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, andlibraryCheckCoversRows(check, Video, where)whether it covers any row the filter matches (aLIKEon both path columns, wildcards escaped), so an empty__subfolderno 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 aslibraryWarning({ reason, path, message, heldBack }), which/getVideospasses 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 aremovedpredicate, 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 aserror("Nothing was marked missing; is the drive mounted?"), an empty or unreadable__subfolderlets it finish but hold back missing marks for the rows stored in it (notMarkedMissing, outcomepartial, 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-fileswithignoreLibraryCheck: true("Rescan anyway" in the Maintenance card, offered only for theemptyreasons, 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 askippedrun 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__subfoldernothing 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./getVideosfilters:missingFilteris strictly missing,deletedFilter(defaultoff; the Videos page sendsexcludeand persists the user's choice in localStorage) hides or isolates deleted rows and returnshiddenDeletedCountwhile excluding; the channel listing takes the samedeletedFilter, appended as the last positional argument throughchannelModule->channelVideosService->channelVideoQuery. The client mirrors the states inutils/videoRemoval.ts(getRemovalState,describeRemoval, the plain-language sentence shown as the modal's File Details Status row; chip tooltips render it throughshared/RemovalDescription, which sets a missing file's path apart in a monospace pill, usingdescribeMissingso both share the wording) andutils/videoStatus.tsx(deletedstatus, neutral style,isRemovedStatus).videoTrashModuleservesGET /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), anddownloadsActivefromarchiveAccess.isDownloading(), which the Empty trash dialog uses to disable archive removal before the user confirms) andPOST /api/videos/mark-deleted({ videoIds }or{ all: true }: only missing rows change, tomarked_deleted;allis refused with 409library-unavailablewhile the check covers missing rows, unless it also sendsignoreLibraryCheck: trueand the reason isemptyorsubfolder-empty(a folder the user emptied on purpose; nevermissingor unreadable), and a selection is never refused; the video modal marks one missing video through it), andPOST /api/videos/purge-removed({ states: ['deleted'|'missing'], removeFromArchive }, "Empty trash" in Settings > Maintenance): one purge at a time (409running), refused formissingwhile the check covers missing rows; in chunks of 500, each in a transaction, it deletesvideo_watch_statusandjobvideosrows (the latter has a restricting FK) before thevideosrow and nullsplaylistvideos.downloaded_at, then unlinksjobs/info/<id>.info.jsonand the cachedvideothumb-<id>*.jpg(library repair would otherwise recreate the row as missing from the archive), removes thecomplete.listlines 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 injectedjobModule.reloadJobVideos(the route hands it in) and only then broadcastsvideosUpdated+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.m3ufile 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.videoDeletionModulecalls 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 usedownloadModule.afterDownloadHookinstead.modules/playlistConstants.js: backend playlist fetch/batch limits, preview default, and shared refresh-conflict text. Client feature defaults live incomponents/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-previewpreviews publication/position choices;POST /api/playlists/:playlistId/download-batchqueues explicit ids;POST /api/playlists/:playlistId/followingsets up/resumes following or resets the starting point while preserving pause state. All require auth and an enabled playlist. Swagger annotations inroutes/playlists.jsdescribe validation and errors. modules/download/: download orchestration (downloadExecutor,ytdlpCommandBuilder,DownloadProgressMonitor,tempPathManager,videoMetadataProcessor,customArgsParser(allowlist gate for the Settings "Custom yt-dlp options" field:checkCustomArgsresolves 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 againstytdlpOptionTable; returns{ok, args, blocked, warnings, errors}whereargsare 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 overytdlpOptionSpecs, the full yt-dlp option list generated byscripts/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;/updateconfigrejects only a changed value and always returnscustomArgsCheckfor the value it saved (the client applies it after Save),/getconfigcarries it for the saved value,buildCustomArgsre-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-separatedsubtitleLanguagesetting and says which entries are bare codes;ytdlpCommandBuilder.buildSubtitleLanguageSelectorexpands each bare code into a--sub-langsregex 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, whileen-orig, hand-edited codes, theallalias, exclusions, and regexes pass through verbatim; the subtag groups switch case-sensitivity back on so-origand 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 uploadeden-GBand the autoenboth download and YouTube's machine translations includept-PTnext topt; from the info.jsonsubtitleskeys 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-subsor any prefix of them yt-dlp accepts, such as--sub-lang(a repeated--sub-langsis appended, not replaced), when the uploaded codes are unknown, or for other videos' files in a flat folder),cookiePlayerClients(pure: the yt-dlpplayer_clientlist (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 useryoutube:extractor-args token since yt-dlp keeps only the last--extractor-argsper extractor key, and a user-suppliedplayer_clientwins),ytdlpStderrSignals(pure stderr-line classifiers shared byYtdlpOutputRouteranddownloadJobFinalizer:containsHttp403ignores yt-dlp's "require a GVS PO Token ... may yield HTTP Error 403" advisory, which prints on every free-account cookie run, andisSabrRestrictionmatches the account-level SABR experiment warning; the router classifies complete stderr lines only, holding a split line until its newline and flushing the remainder fromdispose(), after which output is logged but never classified or broadcast;downloadExecutorfinalizes on the child'scloseevent (stdio drained), notexit, with a 5 sSTDIO_DRAIN_TIMEOUT_MSfallback 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, whichautoDownloadScheduler-- given the tracker byserver.jsviasetRunTracker-- 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:channelAutoDownloadreturns once the sweep is queued (releasing the scheduler's lock exactly as before) with afinalRecordfromchannel/sweepCompletion.waitForSweepEnd(ends on the tracker'sonRunFinished/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 bychannel/sweepRunSummary.toRunRecord(counts; failed videos, job issues, stopped groups, or playlist check failures make iterror/partial; a pause ends it assuccess/paused; it carries the sweep's realfinishedAt, whichscheduledTaskManager.describeResultpasses toscheduledTaskRuns.finishin 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.maybeFinalizefinishes a sealed run only once every job has reported:updateJobsets 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 forREPORT_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 totracker.handleJobEndedin 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 asjobIssue({ 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, andjobModule.failUnstartedJobthroughjobModule.onJobAbandoned(wired totracker.handleAbandonedJobin server.js, so jobModule never requires the tracker).jobIssuesmake 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; aWARNING: Unable to download video subtitlesline 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, anddownloadResultProcessor.partitionDownloadResultsturns it into a subtitle failure throughdropSubtitleOnlyFailureonly 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 thetemp-out-of-spacediagnosis: explicit "No space left on device" text, or aConversion 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-specificVideo unavailablefailures; the finalizer enqueues an "Auto-retry" URL-list job via a callback injected from downloadModule, capped by thedownloadAutoRetryCountconfig, and handed-off failures are taggedautoRetryQueuedso 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 adiagnosisKeyon reportable failed videos and emits a dedupeddiagnoseslist 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 minusUC; members-only videos live in separateUUM*playlists and are not counted), via YouTube APIplaylists.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 inchannels.tab_video_counts(JSON keyed by media type); a failed lookup never overwrites a count. Bulk runs (channelVideoCountsFrequencydaily, andrefreshAtStartup2 minutes after boot, recorded as astartuprun only when it counted something; server.js wires in the run history viasetRunHistoryand the download check viasetDownloadActivityCheck) 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,lookupChannelsPacedcounts one tab at a time with a 3 s gap (yt-dlp's--sleep-requestsonly 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 (outcomethrottled) and starts a pause (tabCountThrottle: 6 h doubling to 24 h on consecutive throttles, forgotten 3 days after it ends); scope isbulkfor a bot check on a lookup without cookies while cookies are configured (on-demand lookups with cookies stay available) andallotherwise, 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) writescurrentDetails()into its run details, and the latest executed run is read back whatever its outcome. The pause doubling ends only throughclearIfUnchanged, 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 anallpause is active. Only one bulk refresh runs at a time (an overlapping one records as skipped). Downloaded = videos rows (locally deleted included,youtube_removedand 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 astab_download_statson/getchannelsand byGET /api/channels/:channelId/tab-stats(addsloaded).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 (fetchRegistryshared in-flight-fetch guard,channelYtdlpExecutoryt-dlp listing runner,channelIdentity,tabState,channelMappers,videoEntryParser,channelVideoWriter/channelVideoQuerychannel-video persistence and querying,channelThumbnails(avatar + banner caching, poster/backdrop backfill viabackfillChannelImages),channelBackdropBackfill(onwriteBackdropImagesenable: 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.jsremains 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,mediaServerSyncorchestrator,adapters/{plexAdapter,jellyfinAdapter,embyAdapter,baseAdapter}). Adapters wrap server-specific REST APIs;mediaServerSyncdecides which adapters apply for a given playlist and updatesplaylist_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'sVideo.audioFilePath(Plex resolves tracks by listing artist sections with type=10), every other playlist syncsVideo.filePath, in the playlist's savedsort_order('reversed' flips YouTube playlist order); items lacking a file of the playlist's type are left out (count surfaced asunsyncable_counton 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.replacePlaylistItemsnever creates an empty playlist: Plex empties in place, Jellyfin/Emby (replace = delete + recreate) delete it and return{ id: null }, which clearsplaylist_sync_state.server_playlist_idso 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 exactfilePathbasename 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 upsertsvideo_watch_statusrows keyed (video, server, user) plus amedia_server_usersname 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 ascompleteUserIds(not a user whose paging repeated a page). For those listings a present video (notremoved) 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 togglesplexWatchStatusAllUsers/jellyfinWatchStatusAllUsers/embyWatchStatusAllUsersdefault on; video files only),watchStatusQueries(stateless read side of the watch-status rows:getWatchedByMap()aggregates listing-pagewatchedByhonoringwatchStatusWatchedRule('any' default / 'primary' = owner + configured user only),buildWatchedExistsSql()expresses the same watched rule as a raw-SQL EXISTS fragment for videosModule's paginated listing, andgetStatusesForVideo()shapes the per-user modal API rows with names frommedia_server_users; the three listing pages filter on watched state through this rule - VideosPage/ChannelPage via a tri-statewatchedFilterquery param (off/only/exclude), the playlist videos route viawatchedState(all/watched/not_watched), where unwatched always includes unknown/never-synced videos) andwatchStatusScheduler(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 globalfetch()override that detects 503requiresDbFixresponses and surfaces the database error overlay. You can use normalfetch()anywhere; database errors are handled automatically.components/: feature directories and pages. Complex features pair a top-levelFeatureName.tsxwith a same-namedFeatureName/directory holdingcomponents/,hooks/, and__tests__/. Examples of this sibling-file layout:Subscriptions.tsx+Subscriptions/(renamed fromChannelManager),Configuration.tsx+Configuration/,ChannelPage.tsx+ChannelPage/,PlaylistPage.tsx+PlaylistPage/. Newer features (e.g.SubscriptionImport/) put the main component atFeatureName/index.tsxinstead; either layout is acceptable for new features.components/shared/: reusable components used across multiple features (e.g.VideoModal/for the video detail modal,ThumbnailClickOverlayfor clickable thumbnail hotspots,DeleteVideosDialog,AddChannelDialogfor confirming a channel add from search results or an unsubscribed channel name click,OpenInYouTubeLinkfor 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.AccordionContentstays mounted when collapsed (tests can query it) but isinvisible, so its controls leave the tab order and accessibility tree.components/layout/: app shell and navigation chrome.AppShell.tsxis the outer frame;NavSidebar.tsx/NavHeader.tsxown desktop and mobile nav;navLayoutConstants.tsholds shared sidebar/header sizing constants;layoutFallback.cssprovides 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.tsxhosts the manual filesystem rescan trigger and the "Deleted and missing videos" card (Settings/components/RemovedVideosCard.tsx+EmptyTrashDialog.tsx, data throughSettings/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 bymissingActionsBlocked, except that for an empty folder Mark all stays available once the user confirms they emptied it, sendingignoreLibraryCheck, 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), aDeletedfilter chip resting atexcludeand remembered in localStorage (shared/VideoList/usePersistedChipFilter.ts; status chips with adefaultValueother than off count as active, and show aShow: ...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, whichMaintenanceSectionscrolls to), and the unmounted-folder warning banner.shared/AvailabilityChiprenders 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-statusroutes toConfiguration/sections/WatchStatusSection.tsx, which surfaces the sync toggle and a manual "Sync Now" trigger throughConfiguration/hooks/useWatchStatusSync.ts(polls/api/mediaservers/watch-statuswhile a sync is running, whether started here or by the scheduler; a sync-in-progress state is shown only for arunning409 refusal from Sync Now, not other refusals such as no media server configured)./settings/schedulingroutes toConfiguration/sections/SchedulingSection.tsx: every cron schedule from theConfiguration/schedules.tsregistry (grouped, withScheduleEditordaily/preset/custom modes, and a per-taskfrequentRunWarningfromschedules.tsshown when any task other than automatic downloads is set to run more than once an hour;describeSchedulerenders custom expressions in words through cronstrue, but only whenisSupportedCronSyntaxpasses, a client mirror of the server's field rules and node-cron's parser, so10/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 first7in the weekday field rewritten to0before 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), so0/15reads as hourly,1-31/10as days 10, 20, 30, and5-7as Sunday through Friday; anything cronstrue still rejects falls back to the echo instead of throwing during render) plus live scheduler state fromGET /api/schedulesthroughConfiguration/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, arunNowavailability, 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'savailableAtpasses so Run now re-enables on schedule; every timestamp is rendered in the server's zone viautils/formatters.formatDateTimeInZone). Tasks are collapsed accordion rows (sections/components/ScheduleTaskRow.tsx): the header reads at a glance (status dot and label fromscheduleDisplay.ts, the schedule in words including unsaved edits, relative next/last run ticked byhooks/useNowand corrected by the hook'sclockOffsetMs, how long the last finished run took, and a short reason when Run now is blocked; phones get two lines,lglines the times up in columns), andScheduleTaskDetailsholds the description, settings link,ScheduleRunStatus, the fullrunNowHint.tshint, 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'slastFinishedRun(GET /api/schedules, viascheduledTaskRuns.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'sRunNowControl(icon-only on phones, and a sibling of the Radix trigger, never inside it) starts a run throughConfiguration/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 throughScheduleSummary.layout/ScheduledTaskIndicator.tsx(header, beside the download indicator) shows while any scheduled task except automatic downloads runs, viahooks/useRunningScheduledTasks.ts(GET /api/schedulesonce, then onscheduledTaskStatus/connectionRestoredbroadcasts 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'slogging.envLevel(viauseConfig().loggingStatus), log file limits or an unwritable-folder warning, and Download logs throughConfiguration/hooks/useLogDownload.ts(axios blob, since a link cannot sendx-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 sharedAddChannelDialog, whose confirm hands off toSubscriptionsvialocation.state.addChannelUrland auto-runs the add flow.components/FindVideos/: video search page at/videos/find. The server returns YouTube's relevance order;sortResults.tssorts 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 sharedVideoList/VideoListSortButtonon phones and in grid view (itsSortOption.undirectedmarks relevance, which has no arrow).hooks/useFindVideosPreferences.tsremembers view (table by default), sort, and minimum duration in localStorage;hooks/useVideoSearch.tskeeps 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.tsxholds 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.tsowns the dialog for either one row or the selection, never touching the selection for a single download), and the channel name through the sharedChannelNameDisplay(link whenchannelSubscribed, plus a Subscribed chip; otherwise the add-channel affordance openingAddChannelDialog).themes/: theme definitions (playful,linear,flat), shared layout policy (layoutPolicy.ts), and theALL_THEMESregistry. New themes add an entry here and implement the required token surface.hooks/: app-wide custom hooks for data fetching and state.contexts/andproviders/: React Context for cross-cutting concerns (auth token, WebSocket, theme).contexts/ThemeEngineContext.tsxowns the active theme mode, resolves the layout policy for the current viewport, and injects theme CSS variables onto the document root.config/configSchema.ts: theCONFIG_FIELDSregistry. 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_cion every table from migration20260907000000-normalize-utf8mb4-unicode-collationonward (older installs hadutf8mb4_general_cilegacy tables beside explicitlyunicode_cinewer ones, which breaks any SQL comparing string columns across them). Migrations sorted before it must not compare string columns across tables;migrations/lib/unicodeCollation.jsis the lowercase-table-name collation helper,jobsUuidCollation.jsis 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 restoringutf8mb4_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 withFOREIGN_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_Nnames across renames and auto-names new constraints1,2, so Elfhosted-style databases carry mixed-case constraint names on lowercase tables. Test migrations that alter foreign-key columns againstmariadb:12.3as 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-only5,55 0,23schedule is not rejected),violatesMinimumIntervalwithnormalizeToMinimumInterval(whatconfigModuleapplies at startup: runs closer than 15 minutes apart are dropped, never added, and the hour and calendar fields are kept, so0,5 2 * * 0becomes0 2 * * 0and pre-floor hand edits keep running instead of blocking every Settings save), andgetNextRun(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, becausesetHoursonly yields an ambiguous time's first occurrence).scheduledTaskManager.jsowns 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 bysetRunRecorder(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 withfinalRecord(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 stuckfinalRecordcan never hold a task running.runis always called asrun({ trigger, ...args }).runNow(id, { trigger, args, enforceEnabled, enforceCooldown })is the single entry point for manual runs -- Scheduling's Run now viaroutes/runNowResponse.jsandPOST /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'sisRunningprobe) -> the task'sgetRunBlocker-> 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, sorunningandrunNowonGET /api/schedulesnever disagree;scheduledTaskStatusbroadcasts ({ key }) tell open Scheduling pages to refetch, andannounceRun(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 aserrorwith outcomepartialand counts in its message.scheduledTaskRuns.jsis 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) inscheduled_task_runs, best-effort so a DB problem never reaches the task, withmarkInterruptedRuns()at startup flagging rows a crashed process left running.rescanRunSummary.jsandytdlpUpdateRunSummary.jsmap 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 byscheduledTaskManagerthroughrunNow.jobModule.runStartupBackfillrecords the archive repair's startup pass the same way (one finished row, because it starts beforemarkInterruptedRuns()), and their last-run displays (Maintenance page, YT-DLP page via/api/ytdlp/latest-version) read the history, falling back to the legacyrescanLastRun/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 setsinArchiveon each result: the ID is inconfig/complete.list(one read per call througharchiveModule.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 ismodules/archiveOnlyIds.js, shared with channel Download all and playlist download selection; it requires only the ChannelVideo model, not the models index, becausedownloadModule.test.jsreplaces model files with plain objects and the index's association setup throws on them whenplaylistDownloadModuleloads it. The channel listing sets the same flag on the page it returns (channelVideosService.applyArchiveFlag, called frombuildChannelVideosResponseso 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 setschannelSubscribed(an enabled Channel row for the result'schannelId) 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-dlpytsearch). 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 dropslive_statusis_live/is_upcomingentries, 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;useLocalVideoStatusrefreshes 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 devon port 3000), running tests, Swagger/OpenAPI docs at/swaggerand/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.jsonschema. Real field names includeplexApiKey,plexYoutubeLibraryId,plexSubfolderLibraryMappings,plexIP/plexPort/plexViaHttps/plexUrl,channelAutoDownload,channelDownloadFrequency(cron),channelFilesToDownload,preferredResolution,videoCodec,defaultSubfolder, plus groups for SponsorBlock, notifications (appriseUrls), download performance, and auto-removal. LegacycronScheduleis auto-migrated tochannelDownloadFrequencyon startup.docs/AUTHENTICATION.md: Plex OAuth, local auth (bcrypt,username/passwordHash), API keys (x-api-keyheader, rate-limited, download-only scope), session management (x-access-token, 7-day expiry), headless bootstrap viaAUTH_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/downloadendpoint, 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
wslibrary; there is no separate WS port. The upgrade bypasses Express (noverifyToken), sowebSocketServerauthorizes it itself throughwebSocketAuthbefore the handshake: only/ws, and only with an active session (sessionAuth.findActiveSession, the same lookupverifyTokenuses; API keys are refused; anything goes withAUTH_ENABLED=false). Browsers cannot set handshake headers, soWebSocketProvider(rendered insideAppContent, given thetoken, no connection while it is null) offersyoutarrplusyoutarr.auth.<token>as subprotocols and the server echoesyoutarr, 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 callsonSessionCheckNeededon 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);AppContentthen re-runs/auth/validateand signs out only on 401/403, so an outage never logs anyone out. Clients never send data (maxPayload1 KB), and every socket has anerrorlistener, because ws rethrows an invalid frame'serrorwithout one and that crashed the process. Broadcast message types includevideoActivityUpdated(invalidatesGET /api/jobs/video-activitysnapshots),downloadProgress,downloadComplete,jobsUpdated(emitted when a download job is enqueued or flips to In Progress), andvideosUpdated(emitted per video mid-batch after its DB rows are persisted); listing pages and the Download History page subscribe viaclient/src/hooks/useDownloadListingsRefresh.tsto 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-activityreturnsdownloadModule.getCurrentActivitySnapshot()(the executor's retained progress monitor read via the side-effect-freetoPayload(), plus the stored last-final message), consumed byuseCurrentActivitySeedinclient/src/components/DownloadManager/hooks/, which seeds only until the first live broadcast arrives and re-probes on tab-visibility and on the provider's localconnectionRestoredmessage (emitted after every reconnect).client/src/utils/jobTypeLabel.tsis the client-side mirror ofserver/modules/download/jobTypes.jslabels 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/, orui/-- 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')), neverconsole.logorconsole.error. - Structured logging with context objects:
logger.error({ err: error, videoId }, 'Download failed'), not string interpolation. - Levels:
errorfor failures needing attention,warnfor recoverable issues,infofor significant operations,debugfor 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:
Never import modules directly inside route files; receive them as factory arguments.function createMyRoutes({ verifyToken, myModule }) { const router = express.Router(); // ... return router; } module.exports = createMyRoutes; - 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/andserver/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
anyin 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.
- Error handlers:
- 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.tswhen adding new configuration fields. - The codebase has ~300 existing
anyoccurrences; do not add more.
Component Structure
- When authoring or rewriting a component: aim for fewer than 10
useStatecalls 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 inshared/"in case" it gets reused.
- Feature-local (used by a single feature/page): place in the feature directory, e.g.
- Feature directory layout for new complex features, either of these is acceptable:
Sibling-file examples:# 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.tsxChannelManager.tsx,Configuration.tsx,ChannelPage.tsx.index.tsxexample: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
useEffectfor subscriptions, timers, and event listeners. - Memoize deliberately:
useMemofor genuinely expensive derivations,useCallbackfor callbacks passed to memoized children. Do not over-memoize trivial values.
Styling
- Tailwind utility classes via
classNameare 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 byThemeEngineContext. - Inline
styleis acceptable only for computed/dynamic values (e.g., a padding driven byisMobile, a length-dependentmaxHeight). For static styling, prefer Tailwind classes. - Responsive breakpoints: use the custom
useMediaQueryfromhooks/useMediaQuerywith a raw media query (useMediaQuery('(max-width: 767px)')) or the exportedbreakpoints.down('sm')helper. Tailwind'smd:/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.tsandclient/src/components/Configuration/hooks/usePlexConnection.ts); do not copy them. - Auth headers:
headers: { 'x-access-token': token }. Hooks receivetokenfrom 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 inApp.tsxis the one placefetchis intentional, for database error detection.
State Management
- React Context for cross-cutting concerns (auth, WebSocket, theme).
- Local
useState/useReducerfor 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
verifyTokenunless 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.jsandserver/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 typecheckTests 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. UsegetByRole,getByLabelText,getByText,getByTestId, andwithin()for scoped queries. ESLint enforces this viatesting-library/no-node-access. - No conditional
expectcalls. - 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.
- Mocking React components: do NOT use JSX inside
jest.mock(). Jest hoists the mock and JSX fails with "Invalid variable access." UseReact.createElementviarequire('react')inside the factory. - Mocking axios: use a factory (
jest.mock('axios', () => ({ get: jest.fn(), post: jest.fn(), ... }))) andrequire('axios')after the mock. Direct ES6import axios from 'axios'fails with ESM errors. - Mocking
fetchJSON: mockjsonasjest.fn().mockResolvedValueOnce(...), not an async arrow function; the latter returns the wrong shape. - 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 beforerequire()imports. - Use
jest.resetModules()andjest.clearAllMocks()inbeforeEach, 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
renderHookwith explicit type annotations for parameterized hooks (e.g.,renderHook(({ token }: { token: string | null }) => useMyHook(token), { initialProps: { token: null } })). - Use
waitForfor async state changes, not arbitrary timeouts. - For polling/interval tests, use fake timers (
jest.useFakeTimers()) and clean up withjest.runOnlyPendingTimers()+jest.useRealTimers()inafterEach. - 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
beforeEachfor 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.mdand register the field inclient/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
/swaggerstays accurate. - Changed services, environment, or ports in
docker-compose.yml: mirror the change in the stack indocs/platforms/portainer.md, which keeps its own copy with absolute paths.
Git Workflow
Branches
dev: default branch for active development; all contributor PRs targetdev.main: stable release branch; mergingdev->maintriggers a production release.- Feature branches: create from
dev, merge back todevvia PR.
Release
- Merging to
devbuilds an RC image (dev-latest,dev-rc.<sha>). - Merging
dev->maintriggers a production release with semantic versioning. - Commit prefixes:
feat:(minor),fix:(patch),BREAKING CHANGE:(major). - Docker images auto-publish to
dialmaster/youtarron release.
PRs
- Contributor PRs target
dev, notmain. - PRs to
devget automatic Claude Code reviews. - CI runs on PRs to both
devandmain.
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.
Trending
More talk lately.about the whole repository
Who's talking about DialmasterOrg/Youtarr
- A Hacker News story“Automate YouTube channel downloads with NFO/cover art for media servers”Hacker News · Oct 11, 2025 · 4 points
- YunoHost-Apps/youtarr_ynh“Upgrade to v1.90.2”GitHub · Oct 27, 2026
- st0o0/homelab-infra“chore(deps): update dialmaster/youtarr Docker tag to v1.90.1”GitHub · Oct 26, 2026
- trash-panda-v91-beta/nebular-grid“fix(container): update docker.io/dialmaster/youtarr ( v1.90.0 ➔ v1.90.1 )”GitHub · Oct 25, 2026
- d4rkeagle65/k8s-homelab“Update dialmaster/youtarr Docker tag to v1.90.1”GitHub · Oct 24, 2026
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
- Oct 27, 2026Upgrade to v1.90.2 — YunoHost-Apps/youtarr_ynh0 reactions · 1 comment
- Oct 26, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.90.1 — st0o0/homelab-infra0 reactions
- Oct 25, 2026fix(container): update docker.io/dialmaster/youtarr ( v1.90.0 ➔ v1.90.1 ) — trash-panda-v91-beta/nebular-grid0 reactions · 1 comment
- Oct 24, 2026Update dialmaster/youtarr Docker tag to v1.90.1 — d4rkeagle65/k8s-homelab0 reactions
- Oct 24, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.90.0 — st0o0/homelab-infra0 reactions
39 more on GitHub
- Oct 24, 2026feat(container): update docker.io/dialmaster/youtarr ( v1.89.0 ➔ v1.90.0 ) — trash-panda-v91-beta/nebular-grid0 reactions · 1 comment
- Oct 22, 2026Upgrade to v1.90.1 — YunoHost-Apps/youtarr_ynh0 reactions · 1 comment
- Oct 21, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.89.0 — st0o0/homelab-infra0 reactions
- Oct 21, 2026Update dialmaster/youtarr Docker tag to v1.90.1 — pjan/atlas0 reactions
- Oct 20, 2026feat(container): update docker.io/dialmaster/youtarr ( v1.88.0 ➔ v1.89.0 ) — trash-panda-v91-beta/nebular-grid0 reactions · 1 comment
- Oct 20, 2026Upgrade to v1.90.0 — YunoHost-Apps/youtarr_ynh0 reactions · 2 comments
- Oct 18, 2026Update dialmaster/youtarr Docker tag to v1.89.0 — pjan/atlas0 reactions
- Oct 17, 2026Upgrade to v1.89.0 — YunoHost-Apps/youtarr_ynh0 reactions · 1 comment
- Oct 17, 2026feat(container): update docker.io/dialmaster/youtarr ( v1.87.1 ➔ v1.88.0 ) — trash-panda-v91-beta/nebular-grid0 reactions · 1 comment
- Oct 17, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.88.0 — st0o0/homelab-infra0 reactions
- Oct 14, 2026Upgrade to v1.88.0 — YunoHost-Apps/youtarr_ynh0 reactions · 1 comment
- Oct 14, 2026chore(deps): update docker.io/dialmaster/youtarr docker tag to v1.90.2 - autoclosed — jlejeune/k3s-homelab0 reactions
- Oct 14, 2026Update dialmaster/youtarr Docker tag to v1.88.0 — pjan/atlas0 reactions
- Oct 7, 2026chore(deps): update dialmaster/youtarr docker tag to v1.87.1 — patrickjmcd/k8s-argo0 reactions · 1 comment
- Oct 7, 2026fix(container): update docker.io/dialmaster/youtarr ( v1.87.0 ➔ v1.87.1 ) — trash-panda-v91-beta/nebular-grid0 reactions · 1 comment
- Oct 5, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.87.1 — st0o0/homelab-infra0 reactions · 1 comment
- Oct 3, 2026Upgrade to v1.87.1 — YunoHost-Apps/youtarr_ynh0 reactions · 2 comments
- Oct 3, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.87.0 — st0o0/homelab-infra0 reactions
- Oct 1, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.86.1 — st0o0/homelab-infra0 reactions
- Oct 1, 2026feat(container): update docker.io/dialmaster/youtarr ( v1.85.0 ➔ v1.87.0 ) — trash-panda-v91-beta/nebular-grid0 reactions · 1 comment
- Sep 29, 2026Upgrade to v1.87.0 — YunoHost-Apps/youtarr_ynh0 reactions
- Sep 28, 2026feat(container): update docker.io/dialmaster/youtarr ( v1.84.1 ➔ v1.85.0 ) — trash-panda-v91-beta/nebular-grid0 reactions · 1 comment
- Sep 27, 2026Upgrade to v1.86.1 — YunoHost-Apps/youtarr_ynh0 reactions · 1 comment
- Sep 27, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.85.0 — st0o0/homelab-infra0 reactions
- Sep 24, 2026Upgrade to v1.85.0 — YunoHost-Apps/youtarr_ynh0 reactions · 1 comment
- Sep 23, 2026feat(container): update docker.io/dialmaster/youtarr ( v1.83.1 ➔ v1.84.1 ) — trash-panda-v91-beta/nebular-grid0 reactions · 1 comment
- Sep 22, 2026chore(deps): update dialmaster/youtarr Docker tag to v1.84.1 — st0o0/homelab-infra0 reactions
- Sep 20, 2026Upgrade to v1.84.1 — YunoHost-Apps/youtarr_ynh0 reactions · 2 comments
- Sep 19, 2026Upgrade to v1.84.0 — YunoHost-Apps/youtarr_ynh0 reactions · 2 comments
- Sep 16, 2026Upgrade to v1.83.1 — YunoHost-Apps/youtarr_ynh0 reactions · 2 comments
- Sep 15, 2026Upgrade to v1.83.0 — YunoHost-Apps/youtarr_ynh0 reactions
- Sep 14, 2026Upgrade to v1.82.1 — YunoHost-Apps/youtarr_ynh0 reactions · 3 comments
- Sep 8, 2026Upgrade to v1.82.0 — YunoHost-Apps/youtarr_ynh0 reactions · 4 comments
- Sep 1, 2026Upgrade to v1.81.1 — YunoHost-Apps/youtarr_ynh0 reactions
- Aug 28, 2026Upgrade to v1.81.0 — YunoHost-Apps/youtarr_ynh0 reactions · 2 comments
- Aug 22, 2026Upgrade to v1.80.0 — YunoHost-Apps/youtarr_ynh0 reactions
- Aug 19, 2026Upgrade to v1.79.0 — YunoHost-Apps/youtarr_ynh0 reactions
- Aug 13, 2026v1.78.0 — YunoHost-Apps/youtarr_ynh0 reactions · 2 comments
- Jan 29, 2026Add YouTube playlist support — karam-ajaj/Youtarr0 reactions
On Hacker News · 1
- Oct 11, 2025Automate YouTube channel downloads with NFO/cover art for media servers4 points · 2 comments
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
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.

