TunarrTube is a local-first companion application for Tunarr (a self-hosted "live TV from your media library" server). It turns public YouTube playlists and channels into a synchronized local media library, then can publish that library as a live-TV-style channel inside Tunarr.
Concretely, TunarrTube:
- Analyzes a YouTube playlist or channel with
yt-dlpand stores the resulting metadata in a local SQLite database. - Detects new/removed items on manual or scheduled sync, without discarding history.
- Downloads explicitly selected videos as stable, re-encoded MP4 files (or caches/streams them, depending on the source's playback mode).
- Registers the source's media directory with Tunarr as a Local Media source and creates/updates a Tunarr channel whose programming is built from the downloaded videos.
Source: README.md, corroborated end-to-end by lib/sources/service.ts, lib/downloads/service.ts, and lib/tunarr/service.ts.
A single local operator who self-hosts Tunarr and wants specific YouTube playlists/channels to behave like TV channels, without manually downloading and organizing video files. There is no multi-user, multi-tenant, or authentication concept anywhere in the schema or API (see docs/ARCHITECTURE.md and docs/DECISIONS.md) — the product is built for one trusted operator on a loopback-only host or behind an operator-managed authenticated boundary, consistent with the README's "local-first companion" framing.
These are traced directly from the route handlers, service functions, and page/component code (not assumed):
Sources → Add Source (app/sources/new/page.tsx, components/add-source-form.tsx):
- Paste an HTTPS YouTube playlist or channel URL.
- Analyze (
POST /api/sources/analyze) runsyt-dlp --dump-single-json --flat-playlistand stores the result as a time-limitedImportDraft(1 hour TTL,lib/sources/service.ts:analyzeAndStoreDraft). The UI shows the detected name, uploader, thumbnail, and video count. - For channel URLs, the user also picks a feed type (Videos / Shorts / archived Live / All) and a history limit (how many recent items to consider).
- Choose a playback mode (Permanent download / Cache on first play / Stream on demand) and optionally enable automatic sync with an interval.
- Create (
POST /api/sources) consumes the draft, creates theSourceand itsVideo/SourceVideorows, and enqueues backgroundmetadata,thumbnail, and (if download mode)downloadjobs for every video — see "Choose a retention strategy per source" below for what each playback mode auto-queues.
On a source's detail page (app/sources/[id]/page.tsx, components/video-selection-table.tsx):
- Videos appear immediately after creation; per-video metadata (description, duration, upload date) fills in as background
metadatajobs complete. - The user selects videos and clicks Download selected, which posts to
POST /api/downloadsand polls job status until each finishes. For a download-mode source this is largely redundant with step 1's auto-queue (see below) and mainly useful for a video whose download failed or was skipped; for cache/stream-mode sources it is the only thing that triggers a permanent download ahead of playback. - Completed downloads are written as
<mediaDirectory>/<youtubeId>.mp4plus a<youtubeId>.jsonsidecar with title/description/duration/source metadata. - A Play button on each row prepares playback (
POST /api/playback/prepare) and opens an inline<video>player streamed fromGET /api/playback/[sourceId]/[videoId].
- Sync Now (components/source-actions.tsx) calls
POST /api/sources/[id]/sync, which re-analyzes the source URL, adds newly found videos, and marks memberships no longer present asmissing— it does not delete video metadata or already-downloaded media. - Automatic sync runs the same logic on a per-source interval, driven by
lib/jobs/scheduler.tsinside the single TunarrTube process. - Bounded channel syncs (a channel source with a
historyLimit) skip the "mark missing" sweep so older, previously-seen entries that fall outside the current inspection window are preserved rather than flagged missing (lib/sources/service.ts:syncSource).
From a source's Tunarr integration panel (components/tunarr-channel-form.tsx):
- Choose a channel name, optional channel number (defaults to the next available Tunarr number), and a programming order (playlist order / oldest first / newest first / random).
- Create/Update Tunarr Channel (
POST /api/sources/[id]/tunarr) enqueues atunarr_publishjob. That job:- Ensures a Tunarr "Local Media" source exists pointing at this source's media directory (translated through configured path mappings if TunarrTube and Tunarr see different absolute paths).
- Triggers and waits for a Tunarr library scan.
- Matches scanned Tunarr programs to downloaded videos by filename (the YouTube ID), in the requested programming order.
- Creates or updates the Tunarr channel and replaces its entire programming lineup.
- At least one video in the source must be fully downloaded before a channel can be created.
- Reconcile re-links a source to its Tunarr channel/media source if IDs drift (e.g. the channel was recreated on the Tunarr side). Unlink forgets the local link without touching the remote Tunarr objects.
- Publishing again later (e.g. after a sync or new downloads) updates the same channel in place and replaces its programming — it does not create a duplicate channel.
Each source has a playbackMode:
- download — every video is permanently downloaded into the source's media directory (the original MVP behavior).
- cache — nothing downloads up front; a video is fetched into a shared cache directory the first time it's played, subject to a size/age eviction policy (default 20 GB / 30 idle days, configurable in Settings). Pinned, actively-playing, or Tunarr-linked assets are protected from eviction.
- stream — nothing is ever stored; playback proxies a signed YouTube URL resolved on demand, with no retention at all.
Publishing a cache or stream source to Tunarr materializes (downloads) every video into the source's media directory first, since Tunarr needs local files to scan — see lib/tunarr/service.ts:publishSourceToTunarr and lib/downloads/service.ts:materializeForTunarr.
- Dashboard (
/) — source count, unique video count, downloaded asset count, and the most recently updated sources. - Videos (
/videos) — the canonical, deduplicated video library across all sources, with availability and duration. - Queue (
/jobs) — every running, queued, and recently finished background job (download, cache, metadata, thumbnail, sync, retag, Tunarr publish/refresh) with its target and status, self-polling every few seconds. - Cache (
/cache) — usage dashboard (used/pinned/protected/evictable bytes), per-asset pin/unpin/evict actions, and manual "enforce limits" / "clear evictable" actions. - Logs (
/logs) — sanitized operational history (source, sync, metadata, download, video categories), filterable by category. Signed YouTube URLs and cookie flags are redacted before any log line is persisted. - Settings (
/settings) — base media directory,yt-dlp/FFmpeg detection ("Test" buttons), Tunarr base URL and connectivity test, cache size/age limits, and ordered Tunarr path mappings with a live preview.
| Term | Meaning |
|---|---|
| Source | A YouTube playlist or channel subscription the user configured. Owns a media directory, a playback mode, sync settings, and (optionally) a linked Tunarr channel. |
| Video | A canonical YouTube video record, deduplicated by YouTube ID across all sources that reference it. |
| SourceVideo | The join between a Source and a Video: playlist position, membership status (present/missing), and this source's download status/local path/retention origin for that video. |
| ImportDraft | An ephemeral (1-hour TTL) record of an yt-dlp analysis, created by "Analyze" and consumed by "Create Source". Prevents re-running yt-dlp between analyze and create. |
| Job | A queued unit of background work: metadata, thumbnail, sync, download, cache, tunarr_publish, tunarr_refresh. Processed one at a time by the single in-process worker. |
| Playback mode | Per-source retention strategy: download (permanent), cache (download on first play, evictable), stream (no retention, live proxy). |
| Retention origin | Why a downloaded file exists on disk for a given SourceVideo: permanent (user-requested download), tunarr (materialized only to satisfy a Tunarr publish), or none. |
| Feed type | For channel sources, which YouTube feed to read: videos, shorts, live (archived live streams), or all (merged, deduplicated, newest-first). |
| History limit | For channel sources, how many recent items yt-dlp should inspect per sync. null means unbounded. |
| Programming order | How a Tunarr channel's videos are ordered when a lineup is published: playlist, oldest, newest, or random. |
| CacheAsset | The cache-mode counterpart to a downloaded file: a single shared cached copy of a Video, independent of any one source, with pin/eviction state. |
| Path mapping | An ordered, longest-prefix translation from a TunarrTube filesystem path to the path Tunarr sees for the same directory (needed when the two run in different containers/mounts). |
These are explicit, stated constraints — either in the README or directly enforced in code — not assumptions:
- Public HTTPS YouTube URLs only, on
youtube.com/www.youtube.com/m.youtube.com/music.youtube.com(lib/youtube/url.ts). Private/unlisted-via-cookie, age-restricted, or authenticated extraction is explicitly out of scope for this MVP (README, "Troubleshooting"). - Single TunarrTube process. The README explicitly warns not to run multiple replicas against the same SQLite database; the job worker and scheduler rely on in-process state with no distributed locking.
- Never destroys a completed download because the source disappeared online. A sync marks a
SourceVideomissing; it does not delete the file or its metadata (README, and enforced inlib/sources/service.ts:syncSource). - Deleting a source never deletes its media directory or a linked Tunarr channel. Only the source's own catalog rows (and orphaned videos' thumbnails/cache files) are removed; downloaded MP4s stay on disk and any Tunarr channel/Local Media source built from them is left untouched in Tunarr, now orphaned from TunarrTube (README, "Troubleshooting";
lib/sources/service.ts:deleteSource). - At least one fully downloaded video is required before a Tunarr channel can be created (
lib/tunarr/service.ts:publishSourceToTunarr). - TunarrTube and Tunarr must agree on the same absolute media path. Docker path translation is never inferred automatically — the operator must configure an ordered path mapping in Settings when the two see different mount points.
- Tunarr integration is capability-gated. Before any mutation, TunarrTube reads the configured Tunarr server's
/openapi.jsonand refuses to proceed if a required endpoint is missing, rather than guessing at compatibility (README;lib/tunarr/client.ts:discover). - Downloads are atomic from the caller's perspective. A video is only recorded as downloaded after
yt-dlpand FFmpeg both finish successfully into a temporary location that is then renamed into place; interrupted jobs are recovered (requeued) on the next application start and retried up to three times.