diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md new file mode 100644 index 0000000..809fff4 --- /dev/null +++ b/CONTEXT-MAP.md @@ -0,0 +1,6 @@ +# K-TV Context Map + +Two bounded contexts: + +- **Backend** — `crates/CONTEXT.md` — domain model, scheduling engine, library, playout, streaming +- **Frontend** — `k-tv-frontend/CONTEXT.md` — EPG UI, channel viewer, dashboard diff --git a/crates/CONTEXT.md b/crates/CONTEXT.md new file mode 100644 index 0000000..8e1ed73 --- /dev/null +++ b/crates/CONTEXT.md @@ -0,0 +1,133 @@ +# K-TV Backend — Domain Glossary + +## MCP (Model Context Protocol) + +Exposes the system to AI agents as a creative partner, not a CRUD proxy. An agent acts as the TV network's programming director — it analyzes the library, designs thematic channels, builds schedules, adjusts rotation policies, reviews what's been airing, and diversifies programming. MCP tools must support library exploration, schedule analysis, and EPG review alongside channel/schedule management. + +## Industry Formats + +No invented standards — use what exists: +- **M3U** — channel discovery for IPTV clients (playlist of available channels) +- **XMLTV** — concrete EPG export (what's playing when) +- **HLS (RFC 8216)** — video streaming from the Playout Service +- **SCTE-35** — mid-roll break signaling in HLS streams +- **iCalendar (RFC 5545)** — schedule template import/export. A ProgrammingBlock maps to a VEVENT with RRULE for recurrence. K-tv-specific properties (filter, strategy, interstitial rules) use X-KTV-* custom properties. Shareable, viewable in any calendar app. + +## Deployment + +Three binaries: presentation (HTTP API), worker (background jobs), playout (streaming). Default deployment is single-machine Docker Compose with SQLite (WAL mode). All three share the same SQLite file. Can be distributed across machines by swapping in Postgres via adapter. Images pushed to a private Docker registry, deployed via Ansible. + +## General Principles + +All runtime configuration is via environment variables — no recompilation to change behavior. Ports abstract infrastructure choices so implementations can be swapped in wiring without touching domain or application code. + +## Operator + +The system administrator who designs channels, manages the library, configures providers, and controls the system. The first registered user is automatically promoted to Operator. Maps to `is_admin` in the codebase. + +## Viewer + +A user who watches channels but cannot manage anything. May self-register if the Operator enables open registration. Needed for accessing Private channels — Public channels require no account. + +## Channel + +A virtual TV station that a viewer tunes to. Owned by a single User. A Channel owns its ScheduleConfig directly (1:1) — schedule templates are not shared across channels. + +## Gap / No-Signal + +Time between ProgrammingBlocks with no scheduled content. Behavior is configurable per Channel via a gap filler setting: off (no signal — the Playout Service serves nothing), or a MediaFilter that selects interstitial content to play during unscheduled time (e.g., a test-pattern clip on loop, random filler from an interstitials pool). Off by default. + +## GeneratedSchedule + +A concrete, time-bound schedule produced from a ScheduleConfig. Contains ScheduledSlots with specific start/end times. Uses a rolling window (default 7 days) that self-heals — the auto-scheduler ensures at least N days of schedule always exist ahead of the current time, regenerating before expiry. The generation counter (monotonically increasing per Channel) drives the RotationPolicy. + +## ScheduleConfig + +The programming template for a Channel. Maps each Weekday to a list of ProgrammingBlocks. This is the "what should play when" design — it is not the concrete schedule itself. + +## MediaItem + +A playable piece of media (movie, episode, short) in the system's library. Provider-agnostic — the library is the single source of truth. Providers sync items into the library; the schedule engine queries the library, never a provider directly. Stream URLs are the one exception — resolved from the originating provider at playback time. + +Not to be confused with a "file" or "video" — a MediaItem is metadata about something playable, not the content itself. + +## Library + +The canonical inventory of all media available to the system. Populated by syncing from one or more Providers. All scheduling, browsing, and filtering operates against the library. + +## Provider + +An external media source (Jellyfin, Plex, Emby, local files, YouTube, etc.). A Provider has two jobs: sync items into the Library, and expose a source URI that the Playout Service can read from. The domain is completely blind to which provider an item came from — providers are an infrastructure concern. + +## Shared Broadcast + +All viewers tuning to the same Channel at the same moment see the same content at the same offset. There is no pause, rewind, or fast-forward — like old-school cable TV. A viewer joining mid-movie starts at whatever point the broadcast has reached. This means the Playout Service produces one HLS stream per Channel, shared by all viewers. + +## Event Queue + +The inter-process communication mechanism. Events published by any binary are persisted to a database-backed queue and consumed by other binaries via polling. Failed events are moved to a Dead-Letter Queue (DLQ) after exhausting retries. Abstracted behind EventPublisher/EventConsumer ports — the implementation can be swapped from database-backed to NATS JetStream without changing domain or application code. + +## Dead-Letter Queue (DLQ) + +Where events go after failing to process beyond the retry limit. Prevents poison messages from blocking the main queue. Must be inspectable for debugging. + +## Worker + +A separate binary responsible for all background processing: library sync from providers, auto-schedule regeneration, and event-driven jobs (webhooks, etc.). Communicates with the rest of the system via the event bus and shared database. Runs independently from the presentation and playout binaries so CPU-heavy work doesn't compete with request handling or stream serving. + +## Playout Service + +A separate binary that owns the video stream. Takes a source URI from a Provider, reads it via FFmpeg (network URL or local path — no full download), and produces an HLS stream with proper segmentation, all audio/subtitle tracks, SCTE-35 markers for mid-roll breaks, and timed metadata for overlays. Every viewer gets a stream from the Playout Service, never directly from a Provider. Uses a sliding window for segment retention — only segments near the current playback position are kept, older segments are deleted. Window size is configurable via environment variables. + +## Segment Store + +A port abstracting where HLS segments are persisted. Implementations may target local filesystem, a NAS mount, tmpfs/ramdisk, or any other storage backend. The Playout Service writes and cleans up segments through this port, never directly to a path. All storage configuration is via environment variables — no recompilation needed to change storage strategy or tune parameters like max disk usage. + +## ContentType + +What a MediaItem is: Movie, Episode, or Short. Describes the media itself, not how it's used. + +## MediaRole + +How the scheduling engine uses a MediaItem: Program (default — fills ProgrammingBlocks) or Interstitial (inserted between programs as bumpers, ads, station IDs). A 15-second station ID and a 15-minute short film are both ContentType::Short, but one is MediaRole::Interstitial and the other is MediaRole::Program. + +## Interstitial + +A short-form MediaItem (bumper, ad, station ID, promo) inserted between regular program items in the schedule timeline. Not a separate asset type — it's a regular MediaItem classified by role. Sourced from the same Library as programs. + +## Mid-Roll Break + +A point where a long program (e.g., a movie) is split and interstitial content is inserted in the middle, mimicking commercial breaks. Configured per ProgrammingBlock. Break points prefer chapter markers from the media file's metadata when available, with a fixed-interval fallback (every N minutes). Signaled via SCTE-35 markers in the HLS stream for IPTV client compatibility. + +## Chapter + +A named marker within a media file indicating a content boundary (e.g., scene breaks in a movie). Extracted during library sync and stored on the MediaItem. Used by mid-roll break logic to find natural break points. Not all media files have chapters — the system falls back to fixed-interval breaks when they're absent. + +## Overlay + +Metadata-driven visual content rendered on top of the current video stream (e.g., "Coming up next" banners). Not composited server-side — delivered as timed metadata that capable clients render. IPTV clients that don't support it simply ignore the metadata. Not a ScheduledSlot — a presentation-layer concern. + +## AccessMode + +Controls Channel visibility: Public (visible to anyone, no auth) or Private (requires authenticated user). Channel-level only — no block-level access control. For content separation (e.g., adult content), use user-level permissions, not channel passwords. + +## ProgrammingBlock + +A named time window within a single day of a ScheduleConfig (e.g., "Morning Cartoons 06:00–08:00"). Defines when content plays and how it is sourced (Manual or Algorithmic). + +## FillStrategy + +The algorithm used to select and order MediaItems when filling a ProgrammingBlock. Six strategies: + +- **Sequential** — one series in episode order, resumes across schedule generations (daily strip) +- **Random** — shuffle from filtered pool (variety block) +- **BestFit** — greedy bin-packing, picks longest item that fits remaining time (precise time-slot filling) +- **Alternating** — cycles through N series or pools (Mon=Show A, Tue=Show B, repeat) +- **Weighted** — random selection biased by recency or play count (fresh content surfaces more) +- **Marathon** — one series, sequential, fills the entire block from a starting point (weekend binge) + +Marathon differs from Sequential: Sequential resumes where it left off across generations, Marathon intentionally burns through as many episodes as possible in a single block. + +## RotationPolicy + +Controls how frequently items repeat in a Channel's schedule. Prevents the same movie from airing twice in a week on a small library. Configured per Channel. Fields: cooldown_days (don't replay within N days), cooldown_generations (don't replay within N schedule generations), min_available_ratio (safety valve — if filtering would leave fewer than this fraction of items, ignore cooldown to prevent dead air). Formerly called RotationPolicy in the codebase — "rotation" matches broadcast TV terminology. diff --git a/crates/docs/adr/0001-library-as-single-source-of-truth.md b/crates/docs/adr/0001-library-as-single-source-of-truth.md new file mode 100644 index 0000000..5685b58 --- /dev/null +++ b/crates/docs/adr/0001-library-as-single-source-of-truth.md @@ -0,0 +1,23 @@ +# ADR-0001: Library as single source of truth for media + +## Status + +Accepted + +## Context + +The system originally had two representations of media: MediaItem (fetched live from providers at schedule-generation time) and LibraryItem (cached in the local database for browsing). The schedule engine bypassed the library entirely and queried providers directly, creating a tight coupling to provider availability and duplicating the concept of "a piece of media." + +## Decision + +The local library is the single source of truth for all media in the system. Providers (Jellyfin, Plex, local files, YouTube, etc.) are sync sources only — they feed items into the library, but are never queried at runtime by the schedule engine. + +One unified type — MediaItem — lives in the library. The schedule engine queries the library, not providers. + +## Consequences + +- Schedule generation works even when a provider is offline. +- No more two-type split (MediaItem vs LibraryItem) — one concept, one type. +- Provider adapters become sync-only: their job is to discover items and upsert them into the library. +- Stream URL resolution still needs the provider at playback time (the library stores metadata, not video files). This is the one runtime provider dependency. +- Filters (genres, decade, content type, etc.) operate on library data, not provider APIs. diff --git a/crates/docs/adr/0002-ktv-owns-the-stream.md b/crates/docs/adr/0002-ktv-owns-the-stream.md new file mode 100644 index 0000000..5552324 --- /dev/null +++ b/crates/docs/adr/0002-ktv-owns-the-stream.md @@ -0,0 +1,31 @@ +# ADR-0002: K-TV owns the stream via a Playout Service + +## Status + +Accepted + +## Context + +The original design proxied streaming to each provider — Jellyfin served its own HLS, local files were either served directly or transcoded individually. This made it impossible to: + +- Inject SCTE-35 markers for mid-roll breaks +- Insert timed metadata for overlays +- Stitch interstitial content between program items into a continuous stream +- Guarantee consistent stream format across providers + +## Decision + +K-TV owns the stream end-to-end via a Playout Service. Providers expose a source URI (network URL or local file path) instead of a viewer-facing playback URL. The Playout Service reads from the source URI using FFmpeg on-demand (no full file download), and produces the HLS output with all audio/subtitle tracks, segmentation, SCTE-35, and timed metadata. + +## Alternatives considered + +- **Continue proxying to provider streams** — rejected because mid-roll breaks, interstitials, and overlays require playlist-level control that provider-owned streams don't offer. +- **Download files locally then transcode** — rejected because it duplicates storage and defeats the purpose of having providers manage content. + +## Consequences + +- Provider port changes from "give me a playback URL" to "give me a source URI." +- Every stream goes through FFmpeg — CPU cost scales with concurrent viewers. Caching segments mitigates repeat access. +- Subtitles and audio tracks come from the source container — no separate subtitle API needed. +- All viewers get a uniform HLS experience regardless of provider. +- Disk space management becomes critical — HLS segments must be cleaned up. diff --git a/crates/docs/adr/0003-database-backed-event-queue.md b/crates/docs/adr/0003-database-backed-event-queue.md new file mode 100644 index 0000000..d6f8aaf --- /dev/null +++ b/crates/docs/adr/0003-database-backed-event-queue.md @@ -0,0 +1,23 @@ +# ADR-0003: Database-backed event queue with DLQ + +## Status + +Accepted + +## Context + +The system is moving from a single binary with in-process tokio::sync::broadcast to three separate binaries (presentation, worker, playout). In-process channels don't work across process boundaries. The operator already runs NATS with JetStream on their homelab but adding a broker dependency for the initial release is unnecessary overhead. + +## Decision + +Use a database-backed event queue (SQLite table) for inter-process communication. Events are written by publishers, polled by consumers. Failed events go to a dead-letter queue (DLQ) after exhausting retries. + +The EventPublisher/EventConsumer ports remain abstract — swapping in a NATS JetStream adapter later is a wiring change, not a redesign. + +## Consequences + +- No additional infrastructure beyond SQLite. +- Polling introduces small latency (sub-second with aggressive poll interval, tunable). +- DLQ prevents poison messages from blocking the queue. +- Events must be serializable (already Clone + Debug, need Serialize/Deserialize). +- NATS migration path is clean: implement the same ports with a NATS adapter, swap in presentation/worker wiring. diff --git a/k-tv-frontend/CONTEXT.md b/k-tv-frontend/CONTEXT.md new file mode 100644 index 0000000..f7db18b --- /dev/null +++ b/k-tv-frontend/CONTEXT.md @@ -0,0 +1,3 @@ +# K-TV Frontend — Domain Glossary + +Frontend terms will be captured here as they are resolved. See `../CONTEXT-MAP.md` for the full context map and `../crates/CONTEXT.md` for backend domain terms.