This document records architectural and product decisions that can be reliably traced to this repository — either stated explicitly in documentation/comments, or strongly evidenced by the implementation. It does not speculate about historical reasoning that isn't recoverable from the checkout.
Each entry is labeled:
- Explicit — stated directly in the README or a code comment.
- Evidenced — not stated in prose, but unambiguous from how the code is written and organized.
- Unclear — a real design choice exists, but its motivation cannot be determined from what's in the repo.
Explicit. The README states: "Automatic synchronization is configured per source and runs inside the single TunarrTube process. Do not run multiple replicas against the same SQLite database." This matches the implementation directly: lib/jobs/runner.ts and lib/jobs/scheduler.ts hold worker/scheduler state on globalThis with no lock table, advisory lock, or leader-election mechanism — only safe under a single running instance.
Explicit. prisma/schema.prisma's datasource db { provider = "sqlite" }, and the README: "stores metadata in SQLite." The deployment model reinforces this as deliberate rather than incidental: the Dockerfile ships a single-file DB under /config, and compose.yaml defines no separate database service.
Explicit (behavior) / Evidenced (mechanism). README: "Downloads use temporary directories and are published only after yt-dlp and FFmpeg finish successfully." The mechanism — a per-attempt temp subdirectory under ._ytarr-tmp/, rename() into the final path, rm -rf of the temp directory in a finally — is in lib/downloads/service.ts:downloadMp4. The same temp-then-rename pattern is reused for JSON sidecars (writeSidecar) and mirrored thumbnails (lib/thumbnails/service.ts:persist), which is evidenced-only (not called out in prose) but is clearly the same deliberate pattern applied consistently.
Explicit. README: "TunarrTube never deletes a prior completed file because an online video disappeared." Directly evidenced in lib/sources/service.ts:syncSource, which sets membershipStatus: "missing" on a SourceVideo rather than deleting it or its localPath.
Evidenced. lib/sources/service.ts:deleteSource removes the Source row (cascading its SourceVideo/queued Job rows), deletes only the source's own thumbnail plus orphaned videos' thumbnail/cache-asset files, and explicitly returns preservedMediaDirectory: source.mediaDirectory — the directory holding permanently-downloaded MP4s is never touched. It also never calls TunarrApiClient or clears any tunarr* field before the row is dropped, so a source's linked Tunarr channel and Local Media source are left exactly as they were, now orphaned rather than deleted. Consistent with the "prior completed download is never deleted" decision above and with lib/tunarr/service.ts:unlinkTunarr's own log message, "Unlinked Tunarr without deleting remote objects" — nothing in this codebase ever deletes a remote Tunarr object.
Evidenced. lib/downloads/service.ts:reuseExistingAsset and materializeForTunarr both attempt link() first and fall back to copyFile on failure (e.g. across filesystems/volumes). Not documented in prose, but the intent is unambiguous: avoid storing duplicate copies of the same video when it's referenced by more than one source, or when a cached copy is promoted to a permanent one, while still working when a hardlink isn't possible.
Explicit. README: "TunarrTube reads the configured server's /openapi.json and refuses mutations when required API capabilities are absent." Implemented in lib/tunarr/client.ts:discover, which checks a fixed map of required (path, method) pairs before any create/update call is permitted, and is invoked from publishSourceToTunarr before any mutation.
Videos are matched to scanned Tunarr programs by filename (YouTube ID), not a stored external mapping
Evidenced. lib/tunarr/service.ts:mapPrograms extracts path.basename(externalId, ext) from each scanned Tunarr program and matches it against video.youtubeId. No comment states why, but it directly depends on the download naming convention (<youtubeId>.mp4) being stable, and avoids needing to persist or synchronize a separate Tunarr-program-to-video mapping table.
Explicit. The README's "Phase 2 playback and synchronization" section documents all three modes and their tradeoffs directly ("Sources can permanently download media, cache it on first play, or stream it without retention"). The playbackMode field, lib/downloads/service.ts:cacheVideo, and lib/playback/service.ts:preparePlayback/playbackResponse implement exactly this.
Explicit (guarantee) / Evidenced (mechanism). README: "Pinned, active, and Tunarr-linked assets are protected from eviction." lib/cache/service.ts:enforceCachePolicy skips any asset that is pinned, has activeReaders > 0, or whose video ID is in protectedVideoIds() (videos required by a source with a linked Tunarr channel).
Publishing to Tunarr with cache/stream playback modes materializes (fully downloads) every video first
Evidenced. publishSourceToTunarr calls materializeForTunarr for every membership when source.playbackMode !== "download" and input.prefetch !== false. Not called out in the README in these terms, but it follows necessarily from Tunarr needing real local files to scan, and the UI states it plainly: "Cache and Stream sources are fully prefetched on their first publication" (components/tunarr-channel-form.tsx).
Explicit (architectural constraint, not a configuration gap). This isn't something a future flag or setting could unlock without a change on Tunarr's side. Two independent facts force it: (1) Tunarr's other_videos local-media source is a filesystem scanner — mapPrograms (lib/tunarr/service.ts) matches scanned programs back to Video rows by the YouTube-ID filename Tunarr found on disk, and Tunarr has no remote/URL-backed media source type this integration (or, as far as this codebase's usage shows, Tunarr itself) can target instead; (2) even if it did, the URL resolveStreamUrl (lib/youtube/ytdlp.ts) returns is a short-lived signed googlevideo.com/youtube.com link scoped to the resolving request, unsuitable for a channel that may replay the same program hours or days later. So stream/cache playback modes only change when/how long a local copy is retained outside of Tunarr publishing (see the decision above) — they never make a published Tunarr channel skip downloading. The one place true live-without-download proxying exists is the single-video in-app preview (playbackResponse in lib/playback/service.ts), which never feeds a Tunarr channel. Documented in README.md's "Playback and retention" section and FAQ.
Explicit. There is no user/session/credential model in the Prisma schema and no auth check in any API route or the Tunarr client. README.md and SECURITY.md define this as a local, single-operator boundary: default production bindings are loopback-only, and any broader exposure requires an operator-managed authenticating proxy or VPN. proxy.ts blocks browser requests identified as cross-site, but is defense in depth rather than authorization.
Evidenced. lib/logging/service.ts:sanitizeLogValue specifically targets googlevideo/youtube URLs and --cookies[-from-browser] argument values with dedicated regexes, and is applied both to every persisted LogEntry and to runProcess's failure messages (lib/system/process.ts). No comment explains the motivation, but the specificity of the redaction targets (signed CDN URLs, credential-bearing CLI flags) makes the intent — never let short-lived authenticated URLs or credentials land in persisted logs or bubble into a client-visible error message — unambiguous.
Explicit. README: "The media root can be changed in Settings. Existing source destinations move to the corresponding directory under the new root for future downloads; completed files retain their recorded paths." Matches lib/settings/service.ts:updateSettings, which updates each Source.mediaDirectory but never touches existing SourceVideo.localPath values or moves files on disk.
Explicit. README: "Docker path mapping is not inferred; configure a path visible to Tunarr" and "Longest-prefix matching is used." Implemented in lib/settings/service.ts:translatePathWithMappings, sorting candidate mappings by resolved-prefix length before selecting a match.
Evidenced. The committed migrations progress from the initial schema through Tunarr integration, Phase 2 playback/cache support, video availability reasons, and per-source quality settings.
Unclear why the original author chose this, but a concrete failure mode for the unflagged (Turbopack) build was observed and is worth recording. package.json's build script explicitly passes --webpack; Next.js 16.3.4 defaults to Turbopack, so this is a deliberate opt-out. Nothing in the repository states the original reason. Separately, in this session's sandboxed execution environment, running next build without --webpack failed because Turbopack's PostCSS worker could not bind an internal port — a restricted-environment issue, not a demonstrated incompatibility between TunarrTube and Turbopack in general. The /* turbopackIgnore: true */ annotations already present in lib/sources/service.ts, lib/settings/service.ts, and lib/downloads/service.ts (each on a dynamic path.join() call using a runtime-resolved directory) show the codebase is written to be Turbopack-aware, which cuts against assuming an unresolved incompatibility. Do not treat the sandbox failure as proof the flag is required in the app's intended host or Docker environment — confirm with an unflagged build there before removing --webpack, and consult node_modules/next/dist/docs/ for this Next.js version's current guidance either way.
Evidenced. Every client component in components/ uses local useState plus direct fetch calls and router.refresh(); no Redux/Zustand/React Query/SWR appears in package.json or anywhere in the codebase. Not stated as a decision, but consistently applied with no exception — including components/theme-toggle.tsx, added later for light/dark mode, which reaches for local useState/useEffect rather than any state or theming library — which rules out mere oversight.
Evidenced. Light/dark mode is implemented entirely with CSS custom properties in app/globals.css (:root for light, @media (prefers-color-scheme: dark) for the system default, :root[data-theme] for an explicit override) plus a beforeInteractive inline script in app/layout.tsx and a small components/theme-toggle.tsx client component. next-themes is not in package.json. Nothing in the repo states this choice in prose, but it's consistent with the "No client-side state management library" decision above: the app has a standing preference for zero-dependency, hand-written solutions over pulling in a library for something a ~30-line component and a few CSS rules can do directly.
Explicit. .github/workflows/ci.yml runs the test suite, TypeScript type checking, and the production build for pull requests and default-branch pushes. Dependabot checks npm and GitHub Actions dependencies weekly.