Danflix: Running a 24/7 TV Channel on a Raspberry Pi
TL;DR. Danflix is a 24/7 linear TV channel built from my own media library, about 6,700 files, and served from a Raspberry Pi 4 on my desk. A Python and SQLite scheduler fills two weeks of programming every night. A playout daemon turns the schedule into a 720p H.264 stream on the Pi's hardware encoder, and Owncast serves it as HLS through a Cloudflare Tunnel. A Flask site adds the guide, chat, reminders and an admin control room. It started in December 2024 as a text file and a terminal menu. The goal is a small, self-running network of channels for friends.
In this post
- The stack at a glance: every service and timer on the box
- Hardware and host
- The network edge: tunnel, nginx, shared sign-in
- Danflix architecture: processes, the database, the one rule
- The scheduling engine: blocks, the materializer, gap filler, movies
- The playout and streaming pipeline: FFmpeg, adaptive bitrate, the relay
- The media library and its metadata: caches, health, title clean-up
- Operations: self-healing, stream health, backups, how changes ship
- How Danflix got here: four versions in two years
- The end goal
The stack at a glance
Everything runs on one Raspberry Pi 4 as plain systemd services. There are no containers and no cloud servers; Cloudflare only carries traffic in.
Long-running services
| Service | Built with | Job |
|---|---|---|
danflix |
Python, Flask, SQLite | Website, JSON API, chat, reminders, admin tools |
danflix-playout |
Python, FFmpeg | Plays the schedule: one FFmpeg per item, piped into the relay |
owncast |
Owncast (Go) | Takes the RTMP feed and serves HLS to browsers |
danflix-watchdog |
Python | Restarts Owncast or playout when the broadcast is down but should be live |
danflix-stream-monitor |
Python | Samples HLS segment timing, CPU pressure and temperature every 15 s |
hub |
Flask | dannyalxndr.com, my personal homepage |
accounts |
Flask, SQLite | Shared sign-in for every site on the box |
clicker |
Flask, SQLite | A small browser game on its own subdomain |
nginx |
nginx | Routes each hostname to the right local port |
cloudflared |
Cloudflare Tunnel | Brings public HTTPS traffic in without open router ports |
Scheduled jobs (systemd timers)
| When | Unit | Job |
|---|---|---|
| 02:30 daily | danflix-backup |
SQLite online backup of the databases, mirrored to a second drive |
| 03:30 daily | danflix-media-health |
ffprobe every file; flag corrupt, empty or missing media, including already-scheduled items |
| 04:00 daily | danflix-appointments |
Materializer: turns programming blocks into 14 days of schedule rows |
| 04:15 daily | danflix-housekeeping |
Prunes old chat messages and viewer samples |
| 04:45 daily | danflix-media-ingest |
Finds new files in the library and queues them for review |
| daily | danflix-discord-daily-digest |
Posts the day's lineup to Discord |
| every minute | danflix-discord-announcer |
Optional "now airing" posts to Discord |
| every 5 min | danflix-access-ingest |
Loads nginx access logs into the admin dashboards |
The Danflix codebase is about 20,000 lines of Python and 23,000 lines of templates, JavaScript and CSS, in one git repository. Each long-running process is a separate script with no Flask import, so a crash in the web app can't take the broadcast down.
Hardware and host
| Part | Spec |
|---|---|
| Board | Raspberry Pi 4 Model B, 4 Cortex-A72 cores, 8 GB RAM |
| System disk | microSD (OS, code, databases) |
| Media disk | 4.5 TB USB hard drive (Movies, TV Shows, Anime) |
| OS | Ubuntu for Raspberry Pi |
| Runtime | Python in a virtualenv; a static FFmpeg build |
| Video encode | The Pi 4's hardware H.264 block, through V4L2 (h264_v4l2m2m) |
The library holds about 600 movie folders, 75 TV shows and 16 anime series, roughly 6,700 video files. Formats range from 1080p HEVC to 2000s-era RealMedia, which matters later.
A few host-level choices carry a lot of weight:
- The hardware encoder does the encoding. Software x264 at 720p would use most of the four cores. With V4L2 encoding, the CPU only decodes the source and scales it.
- Each service is its own cgroup. Process niceness doesn't work between systemd services, because each one gets its own cgroup with equal CPU weight. Anything heavy that isn't the broadcast gets a hard CPU cap, so a stuck background job can't starve the live encoder.
- Least privilege. Services run as an ordinary user. The few automated restarts are allowed narrowly and nothing else is. Every other privileged change goes through a reviewed script that I run by hand, with a dry-run preview mode.
The network edge
The Pi sits on a home connection with no open inbound ports. cloudflared keeps an outbound tunnel to Cloudflare, which terminates HTTPS and sends requests down the tunnel to nginx, which routes each request by hostname:
| Hostname | Routed to | Notes |
|---|---|---|
dannyalxndr.com |
Hub | Personal homepage and a small CMS |
danflix.dannyalxndr.com |
Danflix | The channel, guide, chat and admin |
live.dannyalxndr.com |
Owncast | The HLS playlist and video segments |
accounts.dannyalxndr.com |
Accounts | Sign-in, sign-up, sessions |
clicker.dannyalxndr.com |
Clicker | A small game |
Three details make this edge behave:
- Real client IPs. Behind a tunnel every request appears to come from localhost. nginx trusts Cloudflare's published IP ranges and restores the visitor's address from the
CF-Connecting-IPheader. The access logs and the Security dashboard depend on that. - Page and video on separate hostnames. The watch page comes from Danflix, but the player pulls its HLS stream from
live.dannyalxndr.com. Each half can be cached, restarted or debugged on its own. - One identity for every site. A separate accounts service handles sign-in for every subdomain. Danflix doesn't handle passwords itself; it asks the accounts service who the visitor is, and only an admin role unlocks the admin pages.
Danflix architecture
Danflix is a set of small processes around one SQLite database. The web app writes to it, the overnight jobs fill it, and playout reads it. None of them call each other directly.
The top row of the diagram is the video path. The database decides what that path plays, and the web app and the video reach viewers through the same tunnel.
The database
danflix.db is SQLite in WAL mode, so the web app, playout and the timers can read at the same time while one of them writes. Every connection sets a 30-second busy timeout, because a materializer run can hold the write lock longer than SQLite's 5-second default. The tables fall into five groups:
| Group | Main tables | Holds |
|---|---|---|
| Schedule | schedule, schedule_errors, schedule_notes |
What airs when: about 1,500 rows covering the next two weeks and recent history |
| Programming | appointment_blocks, appointment_series, appointment_progress, appointment_occurrences, playlists, gap_filler_pool |
Blocks, the shows assigned to them, per-assignment progress, and every past fill decision |
| Library | media_metadata_cache, pending_media, media_health_issues, title_changes |
ffprobe results and clean titles per file, new files awaiting review, broken files, the title-change log |
| Viewers | chat_messages, chat_emotes, viewer_reminders, viewer_samples, feature_requests |
Chat, reminders, calendar tokens, viewer counts, suggestions |
| Operations | stream_health, audit_log, site_settings |
Stream samples every 15 s, an audit trail of admin actions, settings |
The central rule: playout only reads schedule. It doesn't know that blocks, playlists or the gap filler exist. Each new way of programming the channel is just another way to write rows, so the broadcast path never changes when a feature is added.
Why separate processes
The web app runs as a single process, so live state such as the viewer count can live in memory. Chat and presence use polling rather than WebSockets. Playout, the watchdog, the stream monitor and every timer job are standalone scripts that never import the Flask app. A bad deploy of the website, a slow admin page or an exception in a request handler can't stop the broadcast, and each piece can be restarted on its own.
The scheduling engine
The whole channel reduces to one invariant: at any second, at most one non-placeholder row in schedule covers "now". Every way of programming the channel is a different writer for that table.
CREATE TABLE schedule (
id INTEGER PRIMARY KEY, channel_id INTEGER, title TEXT, file_path TEXT,
start_time TEXT, end_time TEXT, duration_sec INTEGER,
media_type TEXT, series_name TEXT, season INTEGER, episode INTEGER, episode_title TEXT,
appointment_block_id INTEGER, is_placeholder INTEGER DEFAULT 0, locked INTEGER, note TEXT, ...
);
Every insert path checks for an overlap first (find_schedule_conflict()), so the invariant is enforced on write rather than repaired later.
Programming blocks
An appointment_blocks row is a reserved window with no content: days of week, start, end and a start date. For example, "Afternoon Sitcoms, 0–4 (Mon–Fri), 12:00–14:00". The live channel has ten, mostly Monday to Friday, running from Early Repeats at 00:00 to Late Night Shows ending at 00:14 the next day. Shows are attached through appointment_series rows (assignments), and a block fills in one of two modes:
- custom: each assignment owns its own days and sub-window inside the block.
- rotate: assignments take turns, one per active day, in insertion order. The turn for a date is decided once and stored, never recomputed.
Assignments can also be chained (a shared chain_group): the live member plays until its final episode, then hands its slot to the next member in the same transaction. Order can be sequential or shuffle, and a block can cross midnight.
The materializer
appointment_materializer.py runs at 04:00 (or on demand from the admin page) and fills a 14-day window. For each block, date and assignment span:
- Skip the span if it's locked: any
appointment_occurrencesrow already exists for that block, date and assignment. - Load the episode list for the show (from the series index) and its cursor from
appointment_progress, keyed by(assignment_id, series_name, media_type). - Pick the next episode, read its duration from the ffprobe cache, and insert it if it fits in the time left.
- Advance the cursor and record an occurrence, all in the same transaction.
- When the next episode won't fit, cover the remainder with a placeholder row: sentinel
file_path,is_placeholder=1.
Placeholders are what make a block a hard reservation. The overlap check already refuses to book over any row, so nothing else can be scheduled into the block. Playout treats a missing file as "show the slate", so a placeholder needs no special case there either.
The locking rule is what makes reruns safe. Spans without an assignment are always reclaimable, so adding a show to a block claims placeholder time on the next run. Spans that already have content are never touched, so episodes announced in the guide never change underneath viewers. Edits to a block keep the last 5 states (up to 7 days) in appointment_undo.
Gap filler
Episode lengths rarely tile a window exactly. After a block is materialized, fill_leftover() looks at the free intervals. For any interval of 8 minutes or more, it walks the gap-filler pool (10 short shows) ordered by MAX(start_time), least recently aired first, and places the first show whose next episode fits. The pool has its own progress cursor, assignment_id = 0. Intervals that are too short go back to placeholders and air the slate.
Movies and playlists
A movie block draws from every eligible feature in Movies/ with a repeat policy per block:
| Mode | Rule |
|---|---|
all (default) |
No repeats until every movie has aired once, then a new round |
days |
No repeat within N days (default 90) |
off |
Pure random |
In every mode, a film already scheduled within a few days is skipped. Among the films that fit, the pick is random from those that use the window best, and movie_overrun_min (default 30) lets a long film run past the block's end.
Playlists (50 so far, 377 items) are ordered movie lists that assignments treat as a series: position N is episode N of season 1. On air they're stored as plain movies, so viewers see "Iron Man" rather than "S01E01"; the position lives on the occurrence. Reordering a playlist runs remap_progress(), so no block skips or repeats a film because of an edit.
Manual scheduling: insert and ripple
ripple.py holds pure planning functions with no database access. Insert an item, and everything after it slides later until the first existing gap absorbs the shift, so a 20-minute insert on Tuesday evening doesn't push all of next week. Locked items and reserved block time are handled by mode: respect refuses and reports the blocker, around flows content around them, and override moves locked items but still flows around reserved block time and anything already airing.
The playout and streaming pipeline
playout.py is a polling loop. Every 2 seconds it reads the current and next rows from schedule. If the item on air should change, it starts the new item's FFmpeg 1.5 seconds early so the cut lands on time. If the daemon restarts mid-episode, it computes the offset from the row's start_time and seeks there with -ss, so viewers rejoin at the right moment instead of from the top.
Encoding each item
Every source, whether 1080p HEVC or a 2003 RealMedia file, leaves as the same stream: 1280×720 H.264 at the source's frame rate, with stereo 44.1 kHz AAC. This is the per-item command, slightly trimmed:
ffmpeg -ss <offset> -re -i <file> \
-vf "scale=trunc(iw*sar/2)*2:ih,setsar=1,\
scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2,\
pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=black,format=yuv420p" \
-c:v h264_v4l2m2m -b:v <adaptive> -maxrate <adaptive> -bufsize <2x> -g 60 \
-c:a aac -b:a 192k -ar 44100 -ac 2 \
-bsf:v dump_extra=freq=keyframe -muxdelay 0.1 -flush_packets 1 \
-output_ts_offset <relay clock> -f mpegts pipe:1
-rereads at native speed, which is what makes a file into a live broadcast.- The filter chain first squares up non-square pixels from DVD-era sources, then letterboxes or pillarboxes into 16:9. Nothing is stretched.
h264_v4l2m2mhands encoding to the Pi's hardware block.-g 60puts a keyframe every 60 frames (2 seconds at 30 fps), anddump_extrarepeats the codec headers on every keyframe, so the stream can be joined at any cut.
Adaptive bitrate
Resolution is fixed, because changing it mid-stream forces every viewer's decoder to reset. Bitrate is chosen per item in adaptive_encode.py, as the lower of two caps, clamped to 1,200–4,000 kbps:
| 1-min load per core | Load cap |
|---|---|
| ≤ 1.2 | 4,000 kbps |
| ≤ 1.8 | 2,800 kbps |
| > 1.8 | 1,800 kbps |
The second cap is source quality: 1.3× the source file's own bitrate, from a cached ffprobe. Bits beyond that only re-encode noise. A 480p AVI from 1999 doesn't need 4 Mbps.
The relay
In the first design each item's FFmpeg published straight to Owncast over RTMP. Every hand-off meant a disconnect and reconnect, and Owncast started a new HLS session each time: new segment names, playlist numbering back at zero, a few seconds of nothing. One transition was really three (episode, "up next" card, next episode), so viewers' players broke three times and needed a refresh.
stream_relay.py separates producing video from publishing it:
item ffmpeg (MPEG-TS on stdout) ──► relay.write() ──► relay ffmpeg -f mpegts -i pipe:0 -c copy -f flv rtmp://<owncast>/live/<key> ──► Owncast
The relay is one long-lived FFmpeg that stream-copies MPEG-TS from its stdin to RTMP, using almost no CPU. Owncast sees a single publisher that never disconnects. Each new item's timestamps are shifted with -output_ts_offset to continue the relay's clock, so the output timeline never jumps backwards. When an item ends, the relay keeps draining the old process while the new one takes over.
Slates and bumpers
- Bumper. For items over 45 seconds that are followed directly by another item, the last 8 seconds show an "Up next" card.
- Slate. When nothing is scheduled, including placeholders and gaps too short to fill, a slate lists the next few airings with countdowns.
Both are PNGs drawn with Pillow and looped by FFmpeg with a silent audio track, since Owncast drops a stream with no audio. The slate is redrawn by writing a temp file and renaming it over the original. The running FFmpeg re-reads the image in place, so updating the countdown never restarts the stream.
Owncast and the player
Owncast takes the RTMP feed and writes HLS: a rolling playlist of segments about 2.5 seconds long. nginx serves those on live.dannyalxndr.com. The Danflix watch page wraps Owncast's player with the guide, chat and caption controls.
Captions
There's no caption track in the live stream itself. Instead a captions endpoint finds the file on air and extracts its subtitles: the embedded subtitle stream first, then a sibling .srt. It converts them to WebVTT, caches the result, and returns the cues together with elapsed_sec for the current item. The browser offsets every cue by that amount, so captions line up no matter when the viewer joined.
The media library and its metadata
The scheduler needs three facts about every file: what it is (show, season, episode), how long it is, and whether it plays. None of that can be trusted from a file name, and running ffprobe on demand against a USB drive is too slow. So all three are computed ahead of time and cached.
Caches
media_metadata_cachestores one row per file, keyed by path and validated bymtimeand size. It holds the duration, the ffprobe results and the canonical title fields. A file is probed once, and re-probed only when it changes on disk.- The series index is an in-memory map built in one walk of
TV Shows/andAnime/. It maps every name a show might be looked up by (raw folder name, cleaned folder name, name parsed from files, canonical name) to its files, sorted by season and episode. Every lookup shares the one walk. When the index is older than its TTL, the stale copy is served while a background thread rebuilds it, so a page load never waits on the drive.
Nightly checks
- Health (03:30) runs ffprobe on every file and records anything that isn't real video: zero-byte files, text files with a video extension, truncated or corrupt containers. It also re-checks the file behind every current and future schedule row, so a file deleted after it was scheduled is caught before it airs.
- Ingest (04:45) finds files that have never been scheduled or dismissed, canonicalises them, and lists them on the admin "New Media" page with a "check title" badge on low-confidence names.
Title canonicalisation
Every title has one of two shapes, Movie or Show S01E05: Episode Title. title_canon.py is a pure module (no database) that derives them:
- Show name from the folder. The top-level folder is curated by hand; file names inside it vary by release.
title_overrides.jsonfixes folders whose names are wrong. - Strip known junk. Codec, resolution and audio patterns (
x265,1080p,DDP5.1,WEB-DL), bracket groups, and a list of release-group names. - Find the episode number, trying patterns in order:
S01E05,1x05, "Episode/Session/Chapter 5",305when aSeason 3folder agrees, then bare numbers for anime. A leading "Special N" becomes season 0. - Learn per-series junk. Unknown release tags are caught statistically. A trailing word that ends at least 4 files and 8% of a show, or 60% of one release folder, is treated as tagging. A word is never learned when, for at least half of the titles it ends, removing it would leave the title ending on a stop-word, as in Cosmos's "…of the Universe".
- Score confidence:
high,mediumorlow, with flags such asseason_assumedorduplicate_title, for the review list.
| File on disk | Canonical title |
|---|---|
Dragon_Ball_Z_-_S02_E057_-_Gohan,_the_Hunted_(1080p_BluRay_-_QUAD_Audio).mkv |
Dragon Ball Z S02E57: Gohan, the Hunted |
[CBM]_Cowboy_Bebop_-_Session_03_-_Honky_Tonk_Women_[720p]_[ED190C1A].mkv |
Cowboy Bebop S01E03: Honky Tonk Women |
Home Improvement - 210 - Let's Did Lunch.avi (in Season 2/) |
Home Improvement S02E10: Let's Did Lunch |
Special - 004 - Plan To Destroy The Saiya-Jins Part 2.rmvb |
Dragonball Z Kai S00E05: Plan to Destroy the Saiya-Jins Part 2 |
45 unit tests pin these cases to real file names from the library.
Plan, review, apply, undo
Titles are copied into the cache, pending media, the schedule, viewer reminders and programming blocks' show names. So a fix to title_canon.py has to reach existing rows deliberately. title_apply.py does this in four steps:
build_plan()canonicalises the whole library (about 6,700 files in 5 seconds on the Pi) and diffs every table. Nothing is written.- Review. The plan is grouped by show, and anything wrong is left out. Before any change touches programming blocks, the full apply and undo are rehearsed on a copy of the database.
apply_plan()takes a backup, then writes in one transaction. Each update is guarded: it only applies if the row still holds the old value. Old and new values go totitle_changes.undo_last()replays that log backwards.
Renaming a show is the delicate case, because programming blocks find episodes and progress by name. A rename moves the assignment, its per-assignment progress rows and the gap-filler entry together. Missing the gap-filler table would silently send four shows back to episode 1.
Operations
A channel that's meant to run for weeks without me has to notice its own failures, explain them, and recover from the common ones.
Self-healing
| Layer | Check | Action |
|---|---|---|
| systemd | Process exited | Restart=always on every long-running service |
| Watchdog | Something should be airing, but Owncast isn't receiving a stream | Restart Owncast or playout; clear systemd's failed state if playout hit its start limit |
| Boot check | danflix.service isn't active after a reboot |
Restart it |
| Stream monitor | HLS segments arrive late or bunched | Record why (below); a person decides |
The watchdog checks the outcome, not process status. Both failures it handles have really happened: Owncast crashing while systemd still briefly showed it active, and playout staying "active" while holding a stale reference to a deleted schedule row. It deliberately doesn't try to detect a black or frozen picture, which is too easy to confuse with a dark film.
Stream health
The watchdog answers "is it up?"; the stream monitor answers "is it smooth?". Every 15 seconds it stores a row in stream_health (about 14,800 so far):
- Delivery: share of late HLS segments and the longest gap between segments. Owncast writes one about every 2.5 s, and late, bunched segments are exactly what a viewer sees as stutter.
- Cause: CPU pressure from Linux PSI, CPU per process over the window, temperature, throttling flags, free memory, and any service stuck in a restart loop.
This turned a vague "it stutters sometimes" into a fixable list. On 2026-09-20 CPU pressure was about 70%, and the culprits were a Discord bot crash-looping every 3.7 seconds (over 8,000 restarts), two more broken services, and a media-server job stuck decoding a damaged RealMedia file. One reviewed script stopped the crash loops and capped that job's CPU, and the monitor shows whether the fix held.
Backups
- Nightly at 02:30, plus automatically before any bulk title change.
- SQLite's online backup API (
Connection.backup()), safe while the app holds open connections in WAL mode, producing one self-contained file per database. - A
manifest.jsoninside each backup folder, so each backup describes itself. - Mirrored to a second physical drive. The mirror refuses to run unless the target really is a different device, because one failed SD card shouldn't take the data and every backup with it.
Logs, audit and security
- Logs: playout, the watchdog and the media-health check each keep their own log. Scheduling problems also go into
schedule_errors, shown on the dashboard. - Audit: admin actions are written to
audit_log. - Access: nginx writes a custom log format with the real client IP. A timer loads it into a separate database every 5 minutes for the Security and Access Logs pages.
- Discord: a daily lineup digest, plus optional "now airing" posts.
How changes are made
- Git. The repo has been under git since 2026-09-19. Databases, backups and generated images are git-ignored.
- Tests. 16
unittestfiles, 252 tests, all runnable on the Pi in seconds. They cover the canonicaliser, the series index, appointments and chains, gap filler and undo, playlists, movie blocks, the relay, the letterbox filter, adaptive bitrate and stream health. Anything that touches data is first dry-run against a copy of the database by pointingdatabase.DB_PATHat it. - Privileged changes. These go through reviewed scripts that explain what's wrong, what they'll change and how to undo it, with a dry-run preview.
- Claude Code. Much of the recent work was done with Claude Code, working in isolated git worktrees. The app restart after a server-side change stays a manual step I do myself.
How Danflix got here
Danflix has been rebuilt roughly four times in under two years. Each version kept the same idea, a schedule driving FFmpeg into a live stream, and replaced whatever had become the bottleneck.
v0: a text file and a terminal (December 2024)
The Pi was already my home server. The first channel was a weekend of scripts:
- Schedule:
schedule.txt, one line per airing, such asMonday (2024-12-23), 05:30 PM - Attack on Titan S01E01 - "<path to file>". - Scheduler:
scheduler.py, an interactive menu for browsing folders and adding episodes. The first version wrote cron jobs throughpython-crontab. The next one was a loop that checked the file every second and shelled out to FFmpeg when an entry's minute arrived. - Encode: software x264 at 854×480, 300 kbps video and 48 kbps audio, pushed over RTMP to a Node-Media-Server container. I tested viewing with a VLC script and an
x11grabscreen capture. - Extras: a Discord bot that posted the day's lineup, and a
rename_files_sxxeyy.pyscript to force file names into shape.
It worked, but each item was a separate RTMP session, there was no web page, and titles were whatever the files said. Owncast was downloaded the same month and became the streaming server.
v1: Owncast in public (September 2025)
live.dannyalxndr.com went up as a bare page around Owncast's player. The channel finally had a URL to share, but the schedule still lived in scripts.
v2: the Danflix web app (November 2025 to March 2026)
- November 2025: the
danflix.dannyalxndr.comsite was created in nginx. - 28 February 2026:
danflix.dband the Flask app were born, with achannelstable and a first channel called DANFLIX. - 4 March 2026: the first rows in the new
scheduletable, plus a first title-fixing script. - March 2026: an attempt at multi-channel streaming with SRS was set up, then disabled.
playout.pyreplaced the old scheduler. It moved to 720p on the Pi's hardware encoder.
v3: hardening and automation (September 2026 to now)
This is where most of the code in this post comes from. In roughly four weeks:
| Date | Change |
|---|---|
| 2026-09-06 | Cloudflare Tunnel; the Hub site goes up |
| 2026-09-07 | Live-feed watchdog; deploy/ scripts with dry-run previews |
| 2026-09-08 | Access logs; the shared accounts service |
| 2026-09-11 | Danflix moves to the shared sign-in (single sign-on) |
| 2026-09-12 to 13 | Nightly backups, ingest scan, housekeeping, Discord digest, captions |
| 2026-09-18 | Insert and ripple scheduling |
| 2026-09-19 | Git repo; title canonicalisation applied to 4,800 rows (undoable); week-grid guide |
| 2026-09-20 | Seamless relay; stream-health monitor and the stutter fix; chained series; movie blocks; letterboxing |
| 2026-09-21 | Programming blocks v2 with reserved placeholders; gap filler; block undo; RealMedia support |
| 2026-09-28 | Per-assignment progress; blocks that cross midnight; the site's Brand Kit |
| 2026-09-29 | Adaptive bitrate by system load and source quality |
| 2026-10-04 | Movie playlists; title fixes for about 100 films; season-0 specials; series index skips a show's own film folder |
The database went from 15 tables on 11 September to 36 today. The pattern of this phase: measure first (stream health, a library-wide title diff), then fix. Every fix comes with a test and an undo path.
The end goal
The goal is a small network, not a single channel: several themed channels running at once, needing no day-to-day admin, with a friendly crowd watching together. Each part has a concrete engineering gap.
Multiple channels
Much of the groundwork is in place. channels is a real table, and schedule, programming blocks and chat are all keyed by channel_id. The constraint is encoding. One live 720p encode already uses about 1.2 cores of decode, scale and muxing on top of the hardware encoder, and the Pi has four. Two directions are open:
- Encode ahead of time. Transcode the library overnight into a uniform 720p H.264 format. Playout could then stream-copy files instead of encoding live, and each extra channel would cost about as little as the relay does today.
- More hardware. Keep live encoding, and add a second box per channel or two.
The March 2026 SRS experiment was the first try at the publishing side. Owncast serves one stream per instance, so multiple channels means either multiple Owncast instances or a different HLS server.
Fully hands-off
Today the overnight jobs keep two weeks programmed, and the watchdog and stream monitor catch the known failures. What still needs me:
- Reviewing low-confidence titles from newly added files.
- Restarting services after a code change, which is deliberately not automated.
- Spotting a show whose files can't be found, such as an assignment that resolves no episodes.
- Detecting a frozen or black picture. This is deliberately not done yet, and would need cheap frame inspection that can tell a broken stream from a dark scene.
A place for friends
The social layer exists in a first form: watch-party chat with emotes, polls and moderation, reminders with calendar sync, title suggestions with upvotes, and watching an external Twitch, YouTube or Kick stream alongside Danflix chat. The next step is making the guide feel shared. Requests should feed straight into programming, and the most-requested titles should land in a block automatically.
The rule from the last two years still holds: the broadcast path stays as simple as possible, a polling loop that plays whatever schedule says. Everything clever happens before a row is written.
Once More Unto The Breach..
In spite of how worn down my hands have become through many battles lost, this dream of mine has been well worth the cost of getting here. I look at Danflix now and see something that I have created with great satisfaction.
I am powerful.