Add architecture docs and fork maintenance strategy (#6)
Some checks failed
Build / Calculate version information (push) Successful in 13s
Build / build_and_upload (push) Failing after 0s
Build / build_images (push) Failing after 0s
Close stale issues / stale (push) Successful in 15s

Document channel architecture, M3U/XMLTV integration with Jellyfin,
and fork maintenance strategy for the archived upstream. Also includes
CLAUDE.md updates for implementer workflow and project boundaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-17 22:42:07 +01:00
parent 5034941a79
commit f1e97b94a7
5 changed files with 292 additions and 1 deletions

View File

@@ -48,7 +48,9 @@
"--source", "https://api.nuget.org/v3/index.json",
"--yes"
],
"env": {}
"env": {
"DOTNET_ROOT": "/usr/local/share/dotnet"
}
}
}
}

View File

@@ -58,3 +58,30 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
- Keep Blazor pages thin — delegate to MediatR handlers
- Test with xUnit (existing test projects)
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
## Implementer Workflow
When working on tasks reviewed by the adversarial reviewer (`~/adversarial-reviewer`):
1. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix
2. **Close issues** when fixed; leave open with a comment if partially addressed or deferred
3. **Push changes** before replying to the reviewer
4. **Reply to the reviewer** with a summary of what was done, what was deferred, and any open questions — this is the trigger for the next review cycle
## Project Boundaries
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
**ersatztv does NOT own**:
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
- NFS mounts, Ansible, DNS, networking → server-management
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
- Jellyfin skill → server-management (symlinked)
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
**Full cross-project rules**: `docs/Operations/Project Boundaries.md` in the server-management Obsidian vault (https://docs.tblindustries.be).

98
docs/channels.md Normal file
View File

@@ -0,0 +1,98 @@
# Channel Architecture
## Channel Entity
Defined in `ErsatzTV.Core/Domain/Channel.cs`. Key fields:
- **Identity**: `Number` (e.g., "1", "2.1"), `Name`, `UniqueId` (GUID for M3U/XMLTV)
- **Encoding**: `FFmpegProfileId` — video/audio codec, bitrate, resolution, hardware acceleration
- **Streaming**: `StreamingMode` (TransportStream, HLS Direct, HLS Segmenter, TS Hybrid)
- **Behavior**: `PlayoutMode` (Continuous vs OnDemand), `IdleBehavior` (StopOnDisconnect vs KeepRunning)
- **Visual**: `WatermarkId`, `FallbackFillerId`, artwork (logos)
- **Mirroring**: `PlayoutSource` (Generated vs Mirror) — a mirror channel copies another with optional time offset
- **Display**: `Group`, `Categories`, `ShowInEpg`, `IsEnabled`
- **Audio/Subtitle defaults**: preferred language codes, subtitle mode
## Content Sources
Channels get content through a **Playout****ProgramSchedule****ProgramScheduleItem** chain.
### Collection Types
| Type | Description |
|------|-------------|
| `Collection` | Manual grouping of media items with custom playback order |
| `MultiCollection` | Aggregate of collections + smart collections |
| `SmartCollection` | Query-based (Lucene.Net) dynamic filtering |
| `Playlist` | Ordered items with per-item config (count, fillers, playback order) |
| `TelevisionShow` / `TelevisionSeason` | Structured TV hierarchy |
| `Movie`, `Episode`, `MusicVideo`, `OtherVideo`, `Song`, `Image` | Individual media items |
| `RerunCollection` | Wraps any collection with separate first-run/rerun playback orders |
| `SearchQuery` | Dynamic results from a search |
| `RemoteStream` | External stream URLs |
### Media Sources
Media items are imported from configured libraries (Jellyfin, Plex, Emby, or local filesystem). Each source type has its own entity variants (e.g., `JellyfinMovie`, `PlexEpisode`).
## Scheduling
### Schedule Kinds
- **Classic**: Traditional ProgramSchedule with items — the most common
- **Block**: Template-based block scheduling
- **Sequential**: Strict sequential ordering
- **Scripted**: External script-driven playout
- **ExternalJson**: Playout defined by external JSON file
### Schedule Item Types
Each `ProgramScheduleItem` is one of four concrete types:
1. **One** — Play exactly 1 item per cycle
2. **Multiple** — Play N items (fixed count, collection size, or playlist item size)
3. **Duration** — Fill a time window (with tail mode: none, offline, slate, or filler)
4. **Flood** — Play items continuously until the next fixed-start item
Items can have `StartType` of Fixed (anchored to clock time) or Dynamic (follows previous item).
### Playback Orders
`Chronological`, `Random`, `Shuffle`, `ShuffleInOrder`, `MultiEpisodeShuffle`, `SeasonEpisode`, `RandomRotation`, `Marathon` (group by show/season/artist/album/director).
### Filler System
`FillerPreset` defines content to fill gaps. Each schedule item can have:
- **PreRoll** — before main content
- **MidRoll** — during (chapter breaks)
- **PostRoll** — after main content
- **Tail** — pad remaining time in a duration block
- **Fallback** — channel-level default when nothing else available
Filler modes: Duration, Count, Pad (to nearest minute), RandomCount.
### Alternate Schedules
`ProgramScheduleAlternate` overrides the main schedule for specific days of week, days of month, months of year, or date ranges. Useful for seasonal programming or weekend variations.
## Playout Pipeline
```
Channel
└── Playout
├── ProgramSchedule
│ └── ProgramScheduleItems (One|Multiple|Duration|Flood)
│ └── Content source (Collection, Playlist, SmartCollection, etc.)
├── PlayoutItems (generated — the actual timeline)
│ └── MediaItem + start/finish times + filler kind + watermarks
├── PlayoutGaps (time periods with no content)
└── ProgramScheduleAlternates (day/date overrides)
```
The scheduling engine (`ErsatzTV.Core/Scheduling/`) resolves schedule items into concrete `PlayoutItem` entries with precise start/finish times. Each `PlayoutItem` references a specific `MediaItem` and includes trim points (`InPoint`/`OutPoint`), filler classification, and per-item audio/subtitle overrides.
## Watermarks
`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom upload, channel logo, or built-in resource. Positioned with percentage-based margins and z-index.
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo fetching — see issue #1 for details.

68
docs/fork-strategy.md Normal file
View File

@@ -0,0 +1,68 @@
# Fork Maintenance Strategy
Upstream ErsatzTV was archived February 2026 at v26.3.0. This fork is maintained independently on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
## Divergence Policy
We diverge freely from upstream's final state. There is no upstream to merge from, so maintaining merge compatibility serves no purpose. All changes are our own.
## Security & Dependency Updates
### .NET Runtime
- **Current**: .NET 10.0 (LTS candidate, supported through Nov 2028)
- **Upgrade path**: When .NET 11 ships (Nov 2026), upgrade by updating `TargetFramework` across all projects and `global.json`. The `rollForward: latestMinor` setting in `global.json` handles patch versions automatically.
- **Key constraint**: EF Core is pinned to `[9.0.12,10)` — a .NET 11 upgrade will likely require bumping to EF Core 10.x simultaneously.
### NuGet Packages
- No central package management (`Directory.Packages.props`) — versions are declared per-project. This means bulk updates require editing multiple .csproj files.
- Upstream had a GitHub Dependabot config (`.github/dependabot.yml`) that is not active on Gitea.
- **Current approach**: Manual periodic audits. Run `dotnet list package --outdated` to check for updates.
- **Future consideration**: Add a Gitea Actions workflow for dependency scanning, or adopt `Directory.Packages.props` to centralize version management.
### Docker Base Images
- .NET SDK/runtime images (`mcr.microsoft.com/dotnet/sdk:10.0-noble-amd64`) — update when .NET patches ship.
- FFmpeg image: forked separately at [timothy/ersatztv-ffmpeg](http://192.168.1.95:3000/timothy/ersatztv-ffmpeg). Currently `192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1`. The main Dockerfile still references the upstream `ghcr.io` image and needs updating.
### CVE Response
1. Check if the CVE affects a dependency we use (most NuGet advisories are noise)
2. Update the package version in the relevant .csproj file(s)
3. Build, run tests, deploy to test environment (port 8410)
4. Promote to prod after verification
## EF Core Migrations
- **SQLite**: 196 migrations (primary, Feb 2021 Feb 2026)
- **MySQL**: 153 migrations (secondary, Aug 2023 Feb 2026, parity maintained)
### Adding New Migrations
```bash
# SQLite (primary)
dotnet ef migrations add MigrationName \
--project ErsatzTV.Infrastructure.Sqlite \
--startup-project ErsatzTV
# MySQL (if maintaining parity)
dotnet ef migrations add MigrationName \
--project ErsatzTV.Infrastructure.MySql \
--startup-project ErsatzTV
```
We only use SQLite in the homelab. MySQL migrations can be maintained for completeness but are not tested in deployment.
## Feature Development
New features follow the existing CQRS/MediatR pattern. No compatibility constraints — we own the entire codebase now. Track work via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues).
## Key Risks
| Risk | Mitigation |
|------|------------|
| EF Core major version gap | Pin to `[9.x,10)` range; bump when .NET upgrade forces it |
| Lucene.Net stuck on beta (`4.8.0-beta00017`) | Monitor for stable release; functional as-is |
| SkiaSharp native deps | Pinned with `NativeAssets.Linux.NoDependencies`; test on Linux after updates |
| OpenAPI generator JAR (`7.15.0`) | Hardcoded in Dockerfile; update manually when needed |

96
docs/m3u-xmltv.md Normal file
View File

@@ -0,0 +1,96 @@
# M3U/XMLTV Integration
## Overview
ErsatzTV serves M3U playlists and XMLTV guide data via HTTP endpoints. Jellyfin consumes these as an IPTV tuner source.
## Endpoints
| Route | Purpose |
|-------|---------|
| `GET /iptv/channels.m3u` | M3U playlist (channel list + stream URLs) |
| `GET /iptv/xmltv.xml` | XMLTV guide (EPG data) |
| `GET /iptv/logos/{fileName}.jpg` | Uploaded channel logos |
| `GET /iptv/logos/gen?text={name}` | Generated text logos (SkiaSharp, 200x100 PNG) |
| `GET /iptv/channel/{number}.ts` | Transport stream |
| `GET /iptv/channel/{number}.m3u8` | HLS stream |
All defined in `ErsatzTV/Controllers/IptvController.cs` (streams) and `ErsatzTV/Controllers/ArtworkController.cs` (logo generation).
## M3U Generation
**Code**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs``ToM3U()`
The controller captures `Request.Scheme`, `Request.Host`, and `Request.PathBase` from the incoming HTTP request and passes them through MediatR to the playlist builder. All URLs in the M3U output use the request-derived host — they are **not** hardcoded.
Each channel entry includes:
- `tvg-id` — channel number as identifier
- `tvg-chno` — channel number for ordering
- `tvg-name` — channel display name
- `tvg-logo` — logo URL (uploaded artwork or generated text logo)
- `tvc-stream-vcodec` / `tvc-stream-acodec` — codec hints from FFmpeg profile
- `CUID` — base64-encoded UniqueId
- `group-title` — channel group for categorization
- Stream URL (format depends on channel's streaming mode)
The XMLTV guide URL is included as an `#EXTM3U` header attribute.
## XMLTV Generation
**Code**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
XMLTV data is pre-generated from Scriban templates (`ErsatzTV/Resources/Templates/_channel.sbntxt`) and cached as XML fragments. At request time, the handler:
1. Reads cached fragments from disk
2. Substitutes `{RequestBase}` with the actual `scheme://host/base` from the request
3. Substitutes `{AccessTokenUri}` with version + optional auth token
4. Filters channels by `ShowInEpg`
5. Combines channel list + program entries into the final XML
## Jellyfin Integration
### Tuner Setup
In Jellyfin, add an IPTV tuner pointing to:
```
http://ersatztv:8409/iptv/channels.m3u
```
Jellyfin reads the M3U to discover channels and their stream URLs. The `Host` header in Jellyfin's request determines what host appears in all embedded URLs — so the hostname used in the tuner URL matters.
### Guide Data
Jellyfin fetches the XMLTV URL embedded in the M3U header for EPG data. This provides program titles, descriptions, and timing for the TV guide.
### Guide Refresh
Jellyfin periodically refreshes the guide data. Channel logos are fetched from the `tvg-logo` URLs in the M3U. If those URLs are unreachable from Jellyfin's network context, logos break.
## Logo URL Issue (Gitea #1)
### The Problem
Channel logos break after Jellyfin guide refreshes. The issue was originally reported as hardcoded `localhost` in `tvg-logo` URLs.
### Investigation Findings
The M3U and XMLTV generation paths **correctly** use request-derived host:
- `ChannelPlaylist.ToM3U()` builds logo URLs from `_scheme`, `_host`, `_baseUrl` (all from the request)
- `GetChannelGuideHandler` substitutes `{RequestBase}` at request time
The **actual** hardcoded `localhost` is in `ChannelLogoGenerator.GenerateChannelLogoUrl()` (`ErsatzTV.Core/Images/ChannelLogoGenerator.cs`, line 82):
```csharp
$"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}"
```
This method is called only by `WatermarkSelector.cs` (lines 219, 268, 317, 385) — for watermark overlays during transcoding, **not** for M3U/XMLTV output. Since FFmpeg runs on the same host as ErsatzTV, `localhost` is correct for watermarks.
### Possible Explanations
1. The M3U tuner URL in Jellyfin may use a hostname that doesn't resolve correctly from Jellyfin's container (e.g., `localhost` instead of `ersatztv`)
2. There may be a reverse proxy or Docker network issue stripping/rewriting the Host header
3. The issue may actually be about watermarks appearing with broken logos, not M3U `tvg-logo`
### Current Workaround
A script downloads logos from ErsatzTV and base64-uploads them directly to Jellyfin's `/Items/{id}/Images/Primary` API, bypassing the M3U logo URLs entirely. Documented in `server-management` repo (issue #171).