Add architecture docs and fork maintenance strategy (#6)
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:
@@ -48,7 +48,9 @@
|
||||
"--source", "https://api.nuget.org/v3/index.json",
|
||||
"--yes"
|
||||
],
|
||||
"env": {}
|
||||
"env": {
|
||||
"DOTNET_ROOT": "/usr/local/share/dotnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
CLAUDE.md
27
CLAUDE.md
@@ -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
98
docs/channels.md
Normal 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
68
docs/fork-strategy.md
Normal 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
96
docs/m3u-xmltv.md
Normal 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).
|
||||
Reference in New Issue
Block a user