Compare commits

...
Author SHA1 Message Date
timothy c113142a3d fix(api): standardize error response contract
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #46
2026-06-28 14:58:48 +02:00
timothy 7c646a6829 Merge pull request 'feat(api): add collections REST endpoints' (#45) from feature/35-rest-api-collections into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m50s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m51s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m41s
Merge pull request #45 from timothy/feature/35-rest-api-collections

feat(api): add collections REST endpoints
2026-06-28 07:37:13 +00:00
timothy 82f3a1e633 docs(api): document collection REST response codes
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #35
2026-06-28 09:03:59 +02:00
timothy 2f306ce59f docs(api): refresh OpenAPI for collections REST
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 6m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 7m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #35
2026-06-27 22:26:26 +02:00
timothy fb3f2856da feat(api): add collections REST endpoints
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Refs #35
2026-06-27 21:43:11 +02:00
timothyandClaude Opus 4.8 8f7d240fc8 feat(api): REST foundation + Channels CRUD (#34)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m41s
First slice of the REST API (ersatztv#2). Idiomatic REST over existing MediatR handlers; design in docs/rest-api.md.

Foundation: NotFoundError : BaseError (Core) to split 404 from 422; ApiResults mapping helpers (Either/Option -> 201+Location / 200 / 204 / 404 / 422), additive (existing *ToActionResult untouched); ApiKeyAuthorizationFilter (optional X-Api-Key on mutating actions, gated by Api:WriteKey, no-op when unset) — independent of the IPTV JWT toggle so Jellyfin/Dispatcharr reads are unaffected, applied at controller-class level so every mutating action incl. ResetPlayout is covered fail-safe.

Channels: POST /api/channels (201+Location), PUT/{id} (200/404), DELETE/{id} (204/404), GET/{id} (200/404). Request DTOs -> existing commands; responses reuse ChannelViewModel. Ported page-only validations into handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + FFmpegProfile/Watermark/Filler existence on update for create-parity. Fixed a latent 500: the .Filter(c>0) existence pattern threw TaskCanceledException on not-found; rewritten to AnyAsync.

Tests (NUnit, TZ=UTC): handler success/404/422, ApiResults mapping, API-key filter, controller status/Location/mapping, a controller-security regression net, and a create->read->delete EF integration test on a new in-memory SQLite harness. ErsatzTV.Tests 46/46, Architecture 5/5.

Refs #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:51:06 +02:00
timothyandClaude Opus 4.8 12e6a07e3c test: make ErsatzTV.Tests execute its tests (add test SDK + deps)
ErsatzTV.Tests had NUnit3TestAdapter + Shouldly but lacked Microsoft.NET.Test.Sdk and NSubstitute, so 'dotnet test' ran zero tests (no test host); the pre-existing MultiSelectBaseTests never executed. Add Microsoft.NET.Test.Sdk, NSubstitute, and Testably.Abstractions.Testing by name — versions already centralized in Directory.Packages.props (CPM preserved, no Version= attributes).

Surfaced while wiring tests for #34. Refs #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:44:24 +02:00
timothyandClaude Opus 4.8 d22688a730 docs: REST API Phase 2 design doc + increment plan (#2, #34-#38)
Phase 1 sign-off landed. Idiomatic REST, dedicated API-key write auth
(decoupled from IPTV JWT toggle), 201/204/404/422 mapping + NotFoundError,
request DTOs in / ViewModels out. Sub-issues #34-#38 under tracker #2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:44:24 +02:00
timothyandClaude Opus 4.8 fe4cd39070 fix: make filler scheduling tests timezone-independent (fixes #24)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 5m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m4s
Should_Not_Crash_Mid_Roll_{Zero,One}_Chapters passed 'new PlayoutItem()'
to AddFiller, whose Start defaults to DateTime.MinValue. The mid-roll Pad
path (PlayoutModeSchedulerBase.cs:609) subtracts 'currentMinute' minutes
from StartOffset; since StartOffset does ToLocalTime(), a non-zero local
offset makes currentMinute non-zero, underflowing DateTimeOffset.MinValue
and throwing ArgumentOutOfRangeException. Under UTC the offset is 0 so it
stays in range (hence CI was green). Unreachable in production — real
playout items never start at MinValue — so this is a test-data artifact,
not a product bug.

Fix: seed the fixtures with a real Start (startState.CurrentTime.UtcDateTime),
matching the convention in the passing AddFiller tests. Verified across
UTC, Europe/Brussels, and Asia/Kolkata (+5:30); full Core.Tests now passes
under any timezone (484/0). Updated docs/contributing.md §8 per its §10
deviation policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:22:44 +02:00
timothyandClaude Opus 4.8 38e743d3cf test: golden tests for XMLTV guide output (refs #28)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m30s
Drive the real GetChannelGuideHandler against a Testably fake filesystem
(seeded channels.xml + per-channel fragments) and a SQLite-backed
IDbContextFactory with one visible + one ShowInEpg=false channel, then
snapshot ChannelGuide.ToXml(). Covers the {RequestBase}/{AccessTokenUri}
substitution (the #1 regression surface), etv:-tag stripping, hidden-channel
exclusion, and assembly — with/without access token and with a base URL.
The volatile ?v={mtime} cache-buster is normalized. Same [CallerFilePath]
Goldens + ETV_UPDATE_GOLDENS harness as the M3U goldens (#11).

Core.Tests now references ErsatzTV.Application (handler lives there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 14:04:45 +02:00
timothyandClaude Opus 4.8 7dfd4c37bb docs: add ersatztv#2 REST API handoff (Phase 1 design → Phase 2 implement)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m35s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m51s
Two-phase handoff for a parallel session: Phase 1 investigates the
existing API/CQRS/validation patterns and produces a design + increment
split (Channels-first); Phase 2 implements per vertical slice via
workflows/ultracode. refs #2

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:19:51 +02:00
timothyandClaude Opus 4.8 a638b4f54c docs: close #1 — M3U localhost is config/topology, not a code bug
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m37s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m40s
Re-investigated ersatztv#1 against the live stack: ErsatzTV emits
request-host-driven URLs (44 cached guide fragments use {RequestBase},
zero baked localhost), and Jellyfin no longer tuners ErsatzTV directly —
it funnels through Dispatcharr. The historical localhost came from a
client fetching with Host: localhost:8409, a path that no longer exists.

Document the live topology + evidence + resolution; reframe the optional
base-URL setting as not-built; note the latent fragility. Remove the now
spent handoff doc.

refs #1

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:01:08 +02:00
timothyandClaude Opus 4.8 421710499c ci: gate image build on migrations; add #1 handoff
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m24s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m30s
- docker-build.yml: the image `build` job now `needs: [test, migrations]`, so a
  broken migration blocks the image build (not just the test gate). main branch
  protection also now requires the "EF migration integrity (SQLite + MySql)" check
  on PR merges. (follow-through on #13)
- docs/handoffs/m3u-tvg-logo-fix.md: working brief for the next task (#1, M3U
  tvg-logo localhost), incl. the finding that the issue's GenerateChannelLogoUrl
  root-cause is a red herring (that localhost is the ffmpeg watermark path, fetched
  in-container; investigate the request flow / forwarded headers / cached values
  instead). References the #11 golden + #16 E2E regression nets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:22:57 +02:00
timothyandClaude Opus 4.8 969a23909a docs: add retroactive contributors/style guide (#10)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m43s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m32s
docs/contributing.md — a descriptive guide to the established patterns, derived from
the existing code: architecture/layering, CQRS handler conventions, LanguageExt
functional style, Blazor/MudBlazor UI, EF Core + dual-provider migrations, the FFmpeg
pipeline pattern, naming/formatting/analyzers, testing, and build/CI — each with
concrete file references — plus the deviation policy (match the established style;
diverge only with a concrete, stated reason). Several sections are now CI-enforced
(#12 layering, #15 analyzers, #13 migrations, #11 golden tests). CLAUDE.md points to
it so it's loaded as project guidance.

Survey across the sections done with parallel read-only agents; all file references
verified to exist.

Refs #10

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:45:38 +02:00
timothyandClaude Opus 4.8 2edf3dab49 ci: assert key IPTV endpoints in the container smoke test (#16)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m4s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m5s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m46s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m47s
Extend the image smoke test from "serves HTTP" to a real E2E: after HTTP readiness,
assert the Jellyfin-facing surfaces on the freshly built image — /iptv/channels.m3u
returns 2xx containing #EXTM3U, and /iptv/xmltv.xml returns 2xx containing a <tv root.
xmltv.xml needs channels.xml (the scheduler writes it a few seconds after boot), so each
endpoint is polled with a deadline; container logs are dumped on failure. Catches
routing / base-URL (#1) / migration regressions that leave the app "up" but serving
broken playlists/guides.

Validated the exact checks against the real :latest image on a fresh container (both 200
with the expected shape) before committing.

Refs #16

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:22:47 +02:00
timothyandClaude Opus 4.8 307da32f34 ci: EF migration integrity checks for SQLite + MySql (#13)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m56s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m59s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m33s
New `migrations` job in docker-build.yml runs on every push/PR and, per provider:
- `dotnet ef migrations has-pending-model-changes` — fails on model drift (an entity
  changed without a matching migration).
- `dotnet ef database update` against a fresh empty DB — applies all migrations,
  catching broken/un-orderable ones.

SQLite (the prod provider) uses a throwaway file; MySql uses ServerVersion.AutoDetect
(connects at config time), so the job runs a `services: mysql:8.4` container (the
act_runner uses Docker execution on network downloadswarm, so the service is reachable
as `mysql:3306`). Independent gate for now (not a `needs:` of the image build) so the
MySql-service dependency can't block image builds until proven stable.

Validated both providers locally against real DBs: SQLite 787 migrations -> 139 tables;
MySql 8.4 305 migrations -> 137 tables; both model-drift checks clean.

Docs: ci-cd.md "Migration integrity" (dual-provider discipline, EF CLI pattern, the
non-transactional caveat) + CLAUDE.md convention.

Refs #13

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:58:41 +02:00
timothyandClaude Opus 4.8 194d3fdf34 test: golden-file tests for M3U output (#11)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m49s
Lock the M3U shape Jellyfin/Kodi consume (ChannelPlaylist.ToM3U) so a regression
— e.g. while implementing the paused #1 (tvg-logo base URL) — fails CI instead of
only surfacing as Jellyfin misbehaving in prod.

Six scenarios snapshot today's output: simple channel (generated logo), internal
artwork logo, external-URL logo, multi-channel (sorted by number), Kodi user-agent
(KODIPROP lines), and base-URL + access-token threading. Deterministic: fixed
UniqueIds, and a guard skips (Assume) when ETV_INSTANCE_ID is set (it feeds the
tvg-id). Goldens live next to the test under Goldens/, located via [CallerFilePath];
regenerate via the explicit Regenerate_goldens test or ETV_UPDATE_GOLDENS=1.

Verified: 6/6 pass, and proven to have teeth (tampering a golden fails the test).
XMLTV guide + channel-logo image golden coverage split to #28 (DB/filesystem
fixtures / non-deterministic across platforms).

Refs #11

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 10:18:57 +02:00
timothyandClaude Opus 4.8 c788ae57f1 test: enforce layer dependency direction with NetArchTest (#12)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m45s
Add ErsatzTV.Architecture.Tests asserting the solution's layering, so a
"just import it here" violation fails the build instead of eroding the
architecture over time:
- Core must not depend on Application/Infrastructure*/Scanner, nor on
  EF Core / Pomelo / Microsoft.Data.Sqlite / Dapper.
- FFmpeg (lowest layer) must not depend on any other ErsatzTV layer.
- Application must not depend on the concrete Infrastructure.Sqlite/.MySql
  providers (only the Infrastructure abstraction).
- Infrastructure must not depend on Application or the concrete providers.

Uses NetArchTest.eNhancedEdition — the maintained fork; the original
NetArchTest.Rules is unmaintained since 2021 and its older Mono.Cecil
doesn't reliably parse .NET 10 assemblies. Runs via the existing `dotnet
test` in CI. Rules verified with a negative control (a known-true
dependency asserted forbidden fails as expected — so they're not vacuous).

Refs #12

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:53:09 +02:00
f2d0a36ed6 build: adopt static-analysis packs at suggestion (#15, increment 1)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m19s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m31s
Add Roslynator, SonarAnalyzer.CSharp, Meziantou.Analyzer, and AsyncFixer as central
analyzer references (Directory.Build.targets, CPM-versioned, guarded on
ManagePackageVersionsCentrally so the gitignored .mcp tool isn't affected).
.editorconfig defaults dotnet_analyzer_diagnostic.severity to `suggestion` so the packs
surface findings without failing the TreatWarningsAsErrors (TWAE) build; rules are
promoted to warning/error incrementally (promotion = enforcement via the existing TWAE
build, so no separate lint step is needed).

StyleCop.Analyzers is intentionally excluded: its latest stable (1.1.118) crashes
(AD0001) on C# records and overlaps the existing .editorconfig/Roslynator.

Blazor .razor: editorconfig severity overrides don't reach analyzer diagnostics in Razor
@code (source-generator limitation; dotnet format can't fix them either), so the
currently-firing SonarAnalyzer rules are temporarily NoWarn-ed in ErsatzTV.csproj and
burned down rule-by-rule in #25. The same rules run at suggestion on .cs.

Formatting normalization (mixed UTF-8 BOM + whitespace) is deferred to its own PR.

Full-solution Release build green (0 errors, 0 warnings).

Refs #15

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 07:37:15 +00:00
timothyandClaude Opus 4.8 f1ff39954f docs(renovate): Dockerfile manager now enabled (HTTP registry) [skip ci]
The previously-deferred dockerfile manager is live: it manages docker/Dockerfile
(mcr dotnet bases + internal ersatztv-ffmpeg) and reads the HTTP-only Gitea
registry via a RENOVATE_HOST_RULES host rule (insecureRegistry + read creds) set
in the workflow env. Vestigial arm/ffmpeg-tests Dockerfiles excluded; compose
manager unused (build-only).

refs server-management#484

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 02:22:08 +02:00
2430927b40 ci(renovate): enable Dockerfile manager for the HTTP Gitea registry
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m8s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m41s
Turn on the `dockerfile` manager so Renovate also proposes base-image bumps for
docker/Dockerfile (mcr.microsoft.com/dotnet/* and our internal
192.168.1.95:3000/timothy/ersatztv-ffmpeg). The internal registry is HTTP-only,
so the workflow passes a host rule (insecureRegistry + registry read creds reused
from REGISTRY_USER/REGISTRY_PASSWORD) via RENOVATE_HOST_RULES — kept in the
workflow env, not in the committed renovate.json.

- Scope: only the built amd64 docker/Dockerfile; the vestigial upstream
  arm32v7/arm64/ffmpeg-tests Dockerfiles (archived ghcr base) are disabled.
- Group mcr.microsoft.com/dotnet/* base images into one PR.
- Compose files are build-only (no image tags) -> docker-compose manager not needed.

refs server-management#484

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:18:47 +00:00
timothyandClaude Opus 4.8 74815da024 docs: NCalcSync/SQLitePCLRaw advisories resolved (#8) [skip ci]
Update ci-cd.md: the scheduled vuln scan is green now that #8 cleared the
advisories (red = a new one); note the NU1901-1903 demotion is kept by design
(criticals still block; lower severities surface via the scan + Renovate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 02:17:55 +02:00
timothyandClaude Opus 4.8 01647b6e50 fix(deps): clear NCalcSync and SQLitePCLRaw vulnerability advisories
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m41s
NCalcSync 5.11.0 -> 6.3.2 clears CVE-2026-55254 / GHSA-3w5p-95mh-gq75 (the
factorial-DoS advisory on NCalc.Core/NCalcSync). NCalc 6 split its assemblies and
renamed the custom-function API, so port OpacityExpressionHelper:
FunctionArgs -> FunctionEventArgs, and args.Parameters[i].Evaluate() ->
args.Parameters.Evaluate(i) (FunctionData.Count / Evaluate(index)). Add a
regression test covering the migrated opacity wiring (the feature had no tests).

NCalc 6 transitively requires Microsoft.Extensions.Logging.Abstractions >= 10.0.7,
so bump the centrally-pinned Microsoft.Extensions.* family 10.0.2 -> 10.0.7 to
avoid the NU1605 downgrade error (a .NET 10 servicing patch bump).

SQLitePCLRaw: EF Core 9's Sqlite provider pulls the vulnerable bundle 2.1.10
(GHSA-2m69-gcr7-jv3q, outdated bundled SQLite). Directly pin
SQLitePCLRaw.bundle_e_sqlite3 3.0.3 in Infrastructure.Sqlite to override the
transitive version with the patched native (lib.e_sqlite3 3.50.3); core 3.0.3
satisfies Microsoft.Data.Sqlite's >= 2.1.10 requirement under EF Core 9.

Verified: `dotnet list package --vulnerable --include-transitive` reports 0
vulnerable projects; restore + Release build clean; full test suite green under
UTC. (2 pre-existing PlayoutModeSchedulerBase filler tests fail only under
non-UTC local timezones, unrelated to these deps; they pass in CI.)

Refs #8

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 02:03:41 +02:00
timothyandClaude Opus 4.8 97bf50cb5b docs(renovate): document live Renovate setup [skip ci]
Renovate is now live (PR #18/#19): self-hosted weekly Gitea Actions job that
opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard, with
patch-level auto-merge scoped to test/dev-only packages. Document it under
docs/ci-cd.md → Dependency management, plus a CLAUDE.md conventions pointer.

refs server-management#484

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:57:11 +02:00
timothyandClaude Opus 4.8 350ca3b4b6 ci(renovate): fix GitHub PAT secret name + bump image pin
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m24s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m51s
Two follow-ups to the initial Renovate setup (merged in PR #18):
- Gitea reserves the GITHUB_ secret-name prefix (same as GitHub Actions), so the
  GITHUB_COM_TOKEN secret was rejected (HTTP 400). Rename to GH_COM_TOKEN and map
  it to RENOVATE_GITHUB_COM_TOKEN in the workflow.
- Pin renovate/renovate:43 (current major; 41 was already stale — latest is 43.x).

refs server-management#484

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 01:45:37 +02:00
a5cbf0fbf7 ci: add self-hosted Renovate (NuGet CPM + Gitea Actions)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 1m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m32s
Stand up Renovate for this repo via a scheduled Gitea Actions workflow on the
shared act_runner. Managers: nuget (Central Package Management) + github-actions.
Dockerfile/docker-compose managers deferred until the HTTP Gitea-registry
handling is verified.

- renovate.json: config:recommended, dependency dashboard, OSV vulnerability
  alerts, family grouping (Microsoft.Extensions/AspNetCore/EF Core/Serilog/
  Refit/Lucene.Net), and patch-level auto-merge scoped to test/dev-only packages
  (NUnit/NSubstitute/Shouldly/coverlet/Test.Sdk/Testably/threading analyzer).
- .gitea/workflows/renovate.yml: weekly cron + workflow_dispatch (defaults to a
  safe dry run); bot identity + tokens from repo Actions secrets.

Supersedes the *proposing* half that dependency-scan.yml (ersatztv#14) left out;
the scan stays as a cheap in-repo detector for now.

refs server-management#484

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 23:38:58 +00:00
timothyandClaude Opus 4.8 d9cdff8154 docs: record Central Package Management + scheduled vuln scan (#14) [skip ci]
- ci-cd.md: new "Dependency management" section (CPM, NuGet audit, scheduled
  dependency-scan.yml); Dockerfile notes now list Directory.Packages.props and
  why it's required before restore.
- CLAUDE.md: dependency convention (edit central Directory.Packages.props, never
  re-add Version=); correct the test framework note (NUnit, not xUnit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:33:44 +02:00
timothyandClaude Opus 4.8 2fa79edfdc ci: add scheduled NuGet vulnerability scan
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m16s
A Gitea-native stand-in for Dependabot (#14): a weekly (cron) + workflow_dispatch
job running `dotnet list package --vulnerable --include-transitive` over the full
solution, failing the run when advisories are present (the command itself exits 0,
so the marker line is parsed). Detection only; automated update PRs are tracked in
server-management#484 (self-hosted Renovate).

Expect RED until #8 clears the current NCalcSync/SQLitePCLRaw advisories; after
that a red run signals a NEW advisory.

Part of #14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:16:05 +02:00
timothyandClaude Opus 4.8 77a28fcefc build: adopt Central Package Management (Directory.Packages.props)
Move all 154 PackageReference versions (96 distinct packages) out of the 14
project files into a single central Directory.Packages.props with
ManagePackageVersionsCentrally=true. No version changes — every package was
already pinned identically across projects (no conflicts detected), so this is a
pure relocation: updates become one-line and cross-project version drift is
structurally impossible.

Also copy Directory.Packages.props into the Docker image build before restore:
with CPM the csproj carry no versions, so the image's `dotnet restore` fails
without the central manifest (verified: NU1015 across every project).

Restore + Release build verified locally, plus a simulation of the image's
restore layer under linux-x64 (0 errors; only the pre-existing
NCalcSync/SQLitePCLRaw advisories remain, demoted to warnings, tracked in #8).

Part of #14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 01:07:52 +02:00
timothyandClaude Opus 4.8 0e5e6ebc72 docs: note Gitea drops runs on rapid back-to-back pushes [skip ci]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:44:23 +02:00
timothyandClaude Opus 4.8 8a2ceeddee release: v26.3.1 — first fork build (infra rebuild of upstream 26.3.0)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m43s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m56s
Re-trigger: Gitea dropped the run records for the prior push/tag of this
same tree (runner online, workflow active, but no ActionRun created).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:35:35 +02:00
timothyandClaude Opus 4.8 c7aad4689e docs: enshrine the fork versioning scheme
Document upstream's vYY.<release-seq>.<patch> scheme (year, sequential
release-in-year, patch) in docs/ci-cd.md + CLAUDE.md so we follow it going
forward: <release-seq> is NOT the calendar month (v25.2.0 shipped in June,
v26.3.0 in Feb), it's a per-year counter that resets each January. v26.3.1
= our infra-only rebuild of upstream 26.3.0; v26.4.0 reserved for the first
release with app changes. Also notes the [skip ci]-on-a-tagged-commit gotcha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:29:13 +02:00
timothyandClaude Opus 4.8 c0c37d8987 docs: point CLAUDE.md deployment at the fork image/pipeline [skip ci]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:15:52 +02:00
timothy 45627ede82 ci: harden build pipeline per adversarial review
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 1m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 24s
- Add concurrency group so the single jazz runner can't run the
  push-main-then-push-tag release flow in parallel (shared :buildcache
  + smoke container would collide).
- Add pull_request trigger running the test job only (PRs had no gate);
  skip the build job on PRs.
- Only push images from main or a v* tag (workflow_dispatch from other
  refs now builds without publishing, instead of clobbering :latest/:prod).
- Replace the log-grep smoke check with a real HTTP readiness probe
  (docker exec python3 -> http://localhost:8409/), unique container name,
  and trap-based cleanup to avoid leaks on cancel.
- dotnet test now runs -c Release --no-build (was rebuilding in Debug).
- Directory.Build.props: WarningsAsErrors=NU1904 so critical NuGet
  advisories block in every project, not just ones with
  TreatWarningsAsErrors.
- Dockerfile copies global.json + .editorconfig too, so the image build
  matches CI's SDK pin and analyzer severities.
- Remove dead .github/dependabot.yml + FUNDING.yml (upstream-pointed).
- Rewrite docs/ci-cd.md to the implemented pipeline.

Refs #4, #3, #8.
2026-06-27 00:01:22 +02:00
timothy 8c9bd680a4 ci: fix smoke-test readiness marker for ErsatzTV's Serilog output
ErsatzTV logs via Serilog and never emits Kestrel's 'Now listening on'
line, so the smoke test grep never matched even though the app booted
fully in ~6s. Match ErsatzTV's actual startup logs instead ('… service
started', 'Located ffmpeg'). The image build itself was already passing.

Refs #4.
2026-06-26 23:42:29 +02:00
timothy 46e2fbf0f7 build: don't fail restore on low/moderate/high NuGet audit advisories
.NET 10's NuGet audit promotes vulnerable transitive packages to NU1901-1904
warnings during restore; the app projects set TreatWarningsAsErrors=true, so
restore failed on NCalcSync 5.11.0 (moderate) and SQLitePCLRaw 2.1.10 (high).

Demote NU1901/NU1902/NU1903 to warnings in Directory.Build.props (NU1904
critical still errors). Also COPY Directory.Build.props/.targets in the
Dockerfile before restore so the image build honors the same props as
local/CI builds (it previously copied only the .sln). Underlying deps tracked
in #8.

Refs #4, #8.
2026-06-26 22:22:48 +02:00
timothyandClaude Opus 4.8 9ad0c4a393 ci: add Gitea Actions build pipeline, retire upstream workflows
Adds .gitea/workflows/docker-build.yml: a test-gated, amd64-only Docker
build that pushes the fork's own image to the Gitea container registry
(192.168.1.95:3000). The .NET test job gates the image build; the build
job stamps INFO_VERSION from git, uses buildx registry layer caching, and
smoke-tests that the built image boots before finishing.

Tagging:
  - push to main -> :latest + :<short-sha> (test image; prod untouched)
  - push tag v*  -> :prod + :<version> + :<short-sha> (prod release)

Updates docker/Dockerfile to pull the FFmpeg base image from our Gitea
registry (192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1) instead of the
archived upstream ghcr.io image.

Removes the upstream .github/workflows/* — they target GHCR/DockerHub and
Azure/Apple signing secrets we don't have, call reusable workflows at dead
ersatztv/ersatztv@main paths, and were running as noise (incl. a daily
stale-issue cron) on the Gitea runner. Upstream is archived, so there are
no future merges to preserve them for.

Refs #4, #3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:17:48 +02:00
timothyandClaude Opus 4.8 bbfc9e720a docs: confirm root cause of M3U logo issue (#1), document URL-generation architecture
Live Host-header test against the running container proves tvg-logo follows
the request Host header — there is no hardcoded localhost in the M3U/XMLTV
path (the localhost in GenerateChannelLogoUrl only feeds the FFmpeg watermark
overlay). Record the confirmed cause, the operational vs. durable fixes, and
the key architectural fact for future work: ErsatzTV has no advertised/base-URL
setting, so every absolute URL derives from the request Host; ConfigElement is
key/value so adding a setting needs no EF migration. Code fix for #1 deferred
until CI/CD test/prod envs (#3, #4) exist.

Refs #1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:02:19 +02:00
timothyandClaude Opus 4.6 e58bb9af21 Move CI/CD reference docs from memory to in-repo docs/
Build / Calculate version information (push) Successful in 12s
Build / build_and_upload (push) Failing after 0s
Build / build_images (push) Failing after 0s
Close stale issues / stale (push) Successful in 3s
Moves ci-cd.md (59 lines) from Claude memory into docs/ alongside
existing architecture docs. Slims MEMORY.md from 42 to 18 lines by
removing sections duplicated in CLAUDE.md (Tech Stack, Key Patterns,
Architecture Docs index).

Total memory load per session: 101 → 18 lines.

Fixes #7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:03:31 +01:00
timothyandClaude Opus 4.6 aa6d8eae4c Add Task Completion Protocol to CLAUDE.md
Build / Calculate version information (push) Successful in 17s
Build / build_and_upload (push) Failing after 0s
Build / build_images (push) Failing after 0s
Close stale issues / stale (push) Successful in 13s
Replace informal implementer workflow with structured 7-step protocol
including mandatory root cause analysis for bug fixes. References /done
skill for automated enforcement.

Part of adversarial-reviewer #260.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:29:45 +01:00
timothyandClaude Opus 4.6 f1e97b94a7 Add architecture docs and fork maintenance strategy (#6)
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>
2026-03-17 22:42:07 +01:00
timothyandClaude Opus 4.6 5034941a79 Add Claude Code project setup
Build / Calculate version information (push) Successful in 22s
Build / build_and_upload (push) Failing after 0s
Build / build_images (push) Failing after 0s
Close stale issues / stale (push) Successful in 14s
- CLAUDE.md with architecture overview and development guide
- .mcp.json with docker, ssh, gitea, csharp-lsp, and nuget MCP servers
- Skills for ersatztv and jellyfin
- .gitignore: exclude .mcp/ (built MCP tools)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:50:41 +01:00
Jason DoveandGitHub 0d301df5e8 remove external dependencies (bugsnag, trakt) (#2840)
* remove bugsnag

* remove trakt client id (that will expire)
2026-02-26 10:43:48 -06:00
Jason Dove d26ae336cb prep for release v26.3.0 [no ci] 2026-02-24 15:27:51 -06:00
Jason DoveandGitHub 875069b927 fix stream seek value in graphics engine (#2838) 2026-02-23 14:54:28 -06:00
Jason DoveandGitHub fd86cb55f9 optimize qsv h264 stream startup (#2835) 2026-02-22 10:13:18 -06:00
Jason DoveandGitHub 0c30c47ba9 nvidia - decode 10-bit h264 in software (#2833)
* output progress/speed even when copying video

* nvidia - decode 10-bit h264 in software

* fixes

* fix tests
2026-02-20 23:00:15 -06:00
Jason DoveandGitHub 08cbf59527 lower gop size and keyframe interval (#2832)
* lower gop size and keyframe interval

* update changelog

* fix build using latest dotnet sdk

* fixes
2026-02-19 13:35:27 -06:00
a91de68a5c Add instance id support (#2828)
* Add instance id support

* actually use env variable for instance ID

* Default to ersatztv.org for instance id

* simplify

* fix ordering

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2026-02-18 09:09:44 -06:00
Jason DoveandGitHub 3e3bfbd5f5 use heuristic to work around some qsv av desync cases (#2829)
* check for multiple h264 profiles using qsv decoding

* fix build

* update changelog

* pass cancellation token
2026-02-16 12:37:40 -06:00
Jason Dove 31b07305ef remove more discord references [no ci] 2026-02-15 12:44:16 -06:00
Jason DoveandGitHub 49adcf7c37 replace discord links with new contact link (#2825) 2026-02-15 11:05:44 -06:00
Jason DoveandGitHub c0b8ff1a06 generate slug instead of probing and transcoding resource (#2824)
* generate slug instead of probing and using slug resource

* refactor

* more fixes
2026-02-15 09:46:07 -06:00
Jason DoveandGitHub c6d538e012 add channel slugs (#2823)
* add channel slugs

* safety
2026-02-14 19:57:35 -06:00
Jason DoveandGitHub 3dbde17f68 pin dotnet sdk in docker to 10.0.102 (#2822) 2026-02-12 08:42:54 -06:00
Jason DoveandGitHub 794d209941 use latest authorization method with jellyfin api (#2821)
* use latest authorization method with jellyfin api

* temp pin dotnet sdk version to 10.0.102

* fix parameter name
2026-02-12 08:29:47 -06:00
Jason DoveandGitHub 7b9197d48d fix trakt api calls with new client id (#2820) 2026-02-10 11:26:09 -06:00
Jason DoveandGitHub 2ad6547349 scheduler context improvements (#2819)
* improve classic scheduling context display

* add basic block scheduling context

* add scheduling context to classic filler

* improve parsing
2026-02-09 20:19:52 -06:00
Jason DoveandGitHub 4fa11b6943 add scheduling context to playout details table (#2817)
* add scheduling context to playout details table

* fix missing context copies
2026-02-05 13:45:05 -06:00
Jason DoveandGitHub 440d9f708e improve shuffle stability when reset (#2816) 2026-02-05 12:03:16 -06:00
4d469ec8fd Add Polish (pl) localization for ErsatzTV: Part II (#2815)
* Add Polish localization for MainLayout

* Add Polish language option to UI settings

* Add Polish to supported UI cultures

* Add Polish localization for channel UI

Added Polish translations for channel-related UI elements.

* Add Polish translation for 'Rows per Page' label

* Update Polish translation for rows per page label

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2026-02-05 09:38:02 -06:00
Jason DoveandGitHub a77a2d56ae prepare channels list for localization (#2814)
* prepare channels list for localization

* define supported ui cultures/languages in a single location

* fixes
2026-02-04 14:20:43 -06:00
240a329526 Add Polish (pl) localization to ErsatzTV (#2812)
* Add Polish localization for MainLayout

* Add Polish language option to UI settings

* Add Polish to supported UI cultures

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2026-02-04 08:30:31 -06:00
Jason DoveandGitHub 45e7d61676 update dependencies (#2813) 2026-02-04 07:39:47 -06:00
Jason DoveandGitHub 93811876e0 improve resource organization (#2810) 2026-02-03 14:57:45 -06:00
Jason DoveandGitHub 607d9b0662 add ui localization framework (#2809)
* move dark/light mode toggle to ui settings page

* separate current culture (formatting) and ui culture (language)

* add some more sample translations

* update changelog

* fix cancellation token
2026-02-03 13:52:52 -06:00
Jason DoveandGitHub f47134d2d0 log warnings when transcoding speed is potentially insufficient (#2808)
* refactor parsing ffmpeg progress/speed

* log warnings when transcoding speed is potentially insufficient

* dont log progress on hls direct; fix tests
2026-02-03 08:49:07 -06:00
321 changed files with 39602 additions and 2296 deletions
+167
View File
@@ -0,0 +1,167 @@
---
name: ersatztv
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
---
# ErsatzTV Channel Management
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
Web UI: internal only (`http://localhost:8409` via SSH)
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
## Architecture
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
- **POST endpoints**: library scan, playout reset, show scan
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
## REST API
```bash
# Via docker exec
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
```
### Read Endpoints (GET)
```
/api/channels # List channels
/api/collections # List collections
/api/schedules # List schedules
/api/playouts # List playouts
/api/shows # List shows
/api/movies # List movies
/api/artists # List artists
/api/search # Search items
/api/ffmpeg/profiles # FFmpeg profiles
/api/watermarks # Watermarks
/iptv/channels.m3u # M3U playlist (for Jellyfin)
/iptv/xmltv.xml # XMLTV guide data
```
### Mutation Endpoints (POST)
```bash
# Library scan
POST /api/libraries/{id}/scan
# Scan single show
POST /api/libraries/{id}/scan-show \
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
# Reset channel playout (rebuilds schedule)
POST /api/channels/{channelNumber}/playout/reset
```
## SQLite DB Operations
```bash
# Read queries (safe while running, WAL mode)
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
# Write queries — stop container first
docker stop ersatztv
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
docker start ersatztv
```
### Key Queries
```sql
-- List channels
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
-- List collections with item counts
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
-- List schedules
SELECT Id, Name FROM ProgramSchedule;
-- Playout (channel-schedule links)
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
-- Media counts
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
-- Jellyfin source
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
-- Library sync status
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
```
### Channel Setup Workflow (DB)
**Show-specific channel** (single TV show, shuffled):
```sql
-- 1. Schedule
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
-- 2. Schedule item (CollectionType=1 for Show, PlaybackOrder=3 for Shuffle)
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
-- 3. Channel
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
-- 4. Playout
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
```
**Collection-based channel** (multiple shows, shuffled):
```sql
-- 1. Collection + items (MediaItemId = Show.Id)
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
-- 3-4. Channel + Playout same as show-specific
```
After creating: `POST /api/channels/{number}/playout/reset`
## Volume Mounts (matches Jellyfin)
| Host Path | Container Path |
|-----------|---------------|
| `~/downloadswarm/ersatztv` | `/config` |
| `/mnt/teramind/episodes` | `/data/tvshows` (ro) |
| `/mnt/episodes` | `/data/episodes` (ro) |
| `/mnt/media/movies` | `/data/movies` (ro) |
| `/mnt/media/standup` | `/data/standup` (ro) |
| `/mnt/media/music_videos` | `/data/music` (ro) |
## FFmpeg & Hardware
- QSV (Intel Quick Sync) hardware acceleration
- Resolution: 1920x1080, H264, AAC stereo
- Device: `/dev/dri` passed through
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
## Jellyfin Integration
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
## Gotchas
- DB owned by root — always use `sudo sqlite3`
- WAL mode: reads OK while running, stop container for writes
- No REST API for channel/collection/schedule CRUD — DB scripting only
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
+105
View File
@@ -0,0 +1,105 @@
---
name: jellyfin
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
---
# Jellyfin Management
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
API Token: `978033be716d46678a5d3c54ae0e0ff9`
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
## Access Pattern
```bash
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
```
## Volume Mounts
| Host Path | Container Path | Content |
|-----------|---------------|---------|
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
| `/mnt/episodes` | `/data/episodes` | More episodes |
| `/mnt/media/movies` | `/data/movies` | Movies |
| `/mnt/media/standup` | `/data/standup` | Standup |
| `/mnt/media/music_videos` | `/data/music` | Music videos |
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
## API Endpoints
### System
```
GET /System/Info # Server info, version
GET /System/Info/Public # Public info (no auth needed)
POST /System/Restart # Restart server
```
### Items (Search & Browse)
```bash
# Search items
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
# Get item details
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
# Get all movies
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
# Get series
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
# Get episodes for a series
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
# Filter by library (parentId)
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
```
### Libraries
```
GET /Library/VirtualFolders # List all libraries
POST /Library/Refresh # Trigger full library scan
POST /Items/{id}/Refresh # Refresh single item metadata
```
### Streaming
```bash
# Test stream URL
GET /Videos/{itemId}/stream?static=true
# Get playback info
GET /Items/{itemId}/PlaybackInfo
```
### Users
```
GET /Users # List users
GET /Users/{userId} # User details
```
## Library IDs
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
## Live TV
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
- Configured in Jellyfin Admin > Live TV
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
## Gotchas
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
- Music videos are typed as "Movie" in Jellyfin
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
- Items return 404 on stream if source volume is unmounted
- Jellyfin preserves item IDs across restarts unless files are renamed
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
+16
View File
@@ -104,3 +104,19 @@ ij_json_wrap_long_lines = false
[*.cs]
# disable CA1848: Use the LoggerMessage delegates`
dotnet_diagnostic.ca1848.severity = none
# --- Static-analysis pack adoption (ersatztv#15) ---
# Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are referenced centrally
# (Directory.Build.targets). Default every analyzer diagnostic to `suggestion` so the new
# packs don't fail the TreatWarningsAsErrors build; high-value rules get promoted to
# warning/error one at a time (see ersatztv#15 / docs/contributing.md). Explicit per-rule
# severities (e.g. ca1848 above) still take precedence over this bulk default.
dotnet_analyzer_diagnostic.severity = suggestion
# Blazor components: analyzers run on .razor/.cshtml @code too, and TWAE would otherwise
# turn their default-severity findings into build errors — keep them at suggestion as well.
[*.razor]
dotnet_analyzer_diagnostic.severity = suggestion
[*.cshtml]
dotnet_analyzer_diagnostic.severity = suggestion
+58
View File
@@ -0,0 +1,58 @@
name: Dependency vulnerability scan
# Scheduled NuGet advisory scan — a Gitea-native stand-in for Dependabot (ersatztv#14).
# Surfaces vulnerable direct/transitive packages on a schedule instead of only when a
# `dotnet restore` happens to break. This is DETECTION ONLY; automated update PRs are
# tracked separately (self-hosted Renovate — server-management#484).
#
# Scans the FULL solution (including the Scanner project, which the image build strips)
# so coverage isn't narrower than the code we ship.
#
# NOTE: Gitea runs `schedule` triggers only from the default branch (main); the workflow
# must be merged to main before the cron registers. Use `workflow_dispatch` to run on demand.
on:
workflow_dispatch:
schedule:
# Mondays 06:00 UTC
- cron: '0 6 * * 1'
# Independent of the build pipeline's concurrency group; a stale scan can be cancelled.
concurrency:
group: ersatztv-depscan
cancel-in-progress: true
jobs:
scan:
name: NuGet vulnerable packages
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore ErsatzTV.sln
- name: Scan for vulnerable packages (direct + transitive)
# bash + `set -euo pipefail` so a failing `dotnet list` (e.g. the audit source
# is unreachable while restore served from cache) fails the job instead of
# falling through to a false "no vulnerable packages" green.
shell: bash
run: |
set -euo pipefail
echo "Running: dotnet list package --vulnerable --include-transitive"
dotnet list ErsatzTV.sln package --vulnerable --include-transitive 2>&1 | tee depscan.txt
# `dotnet list package --vulnerable` exits 0 even when advisories exist, so detect
# findings by the report marker and fail the run if any are present. Expect this to
# be RED until ersatztv#8 clears the current NCalcSync / SQLitePCLRaw advisories;
# after that, a red run means a NEW advisory has appeared.
if grep -q "has the following vulnerable packages" depscan.txt; then
echo "::error::Vulnerable NuGet packages detected — see report above (tracked: ersatztv#8)."
exit 1
fi
echo "No vulnerable packages found."
+251
View File
@@ -0,0 +1,251 @@
name: Build ErsatzTV Image
# Builds the fork's own amd64 image and pushes it to the Gitea container registry.
# pull_request -> test job only (no image build/push)
# push to main -> :latest + :<short-sha> (test image; does NOT touch prod)
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
#
# Runner + registry provisioned in server-management#172. The Gitea registry is
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
# inherit the host daemon's insecure-registries setting).
#
# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins
# `:prod`, never `:latest` (enforced in the prod compose — server-management#481).
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
tags:
- 'v*'
# Single runner on jazz: serialize all runs so the push-main-then-tag release
# flow can't collide on the shared :buildcache tag or the smoke container.
concurrency:
group: ersatztv-build
cancel-in-progress: false
env:
REGISTRY: 192.168.1.95:3000
IMAGE: 192.168.1.95:3000/timothy/ersatztv
jobs:
test:
name: Build & test (.NET)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Strip Scanner project ref (matches Docker build)
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
migrations:
name: EF migration integrity (SQLite + MySql)
runs-on: ubuntu-latest
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
# can't block image builds until it's proven reliable on the runner. Promote to a
# required check / build dependency once green. (ersatztv#13)
services:
mysql:
image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: ersatztv
MYSQL_DATABASE: ersatztv_migrations
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
--health-interval=5s
--health-timeout=5s
--health-retries=30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Install dotnet-ef
run: dotnet tool install --global dotnet-ef --version 9.0.12
# SQLite is the prod provider; both checks validated locally.
- name: SQLite — model drift + apply all migrations to a fresh DB
run: |
set -euo pipefail
export PATH="$PATH:$HOME/.dotnet/tools"
echo "::group::SQLite model drift (has-pending-model-changes)"
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
echo "::endgroup::"
echo "::group::SQLite apply all migrations to a fresh DB"
export ETV_CONFIG_FOLDER="$(mktemp -d)" ETV_TRANSCODE_FOLDER="$(mktemp -d)"
dotnet ef database update --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
echo "::endgroup::"
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
- name: MySql — model drift + apply all migrations to a fresh DB
env:
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;"
run: |
set -euo pipefail
export PATH="$PATH:$HOME/.dotnet/tools"
echo "::group::MySql model drift (has-pending-model-changes)"
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
echo "::endgroup::"
echo "::group::MySql apply all migrations to a fresh DB"
dotnet ef database update --no-build --configuration Release \
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
echo "::endgroup::"
build:
name: Build & push image (amd64)
runs-on: ubuntu-latest
needs: [test, migrations]
if: github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version and tags
id: meta
run: |
SHORT=$(git rev-parse --short HEAD)
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
INFO_VERSION="${VERSION}"
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
else
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
INFO_VERSION="${DESC#v}-${SHORT}"
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
fi
echo "info_version=${INFO_VERSION}" >> "$GITHUB_OUTPUT"
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
{
echo "tags<<__EOT__"
printf '%s\n' "${TAGS[@]}"
echo "__EOT__"
} >> "$GITHUB_OUTPUT"
echo "INFO_VERSION=${INFO_VERSION}"
printf 'tag: %s\n' "${TAGS[@]}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."192.168.1.95:3000"]
http = true
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64
# only publish from main or a v* tag; other refs (e.g. branch dispatch) build only
push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
provenance: false
build-args: |
INFO_VERSION=${{ steps.meta.outputs.info_version }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
- name: Smoke + IPTV E2E (assert key endpoints)
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
run: |
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
NAME="etv-smoke-${{ github.run_id }}"
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
echo "Pulling ${IMG}"
docker pull "$IMG"
docker run -d --name "$NAME" --memory 2g \
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
"$IMG"
# probe ErsatzTV's web server from inside the container (image ships python3)
cat > probe.py <<'PY'
import urllib.request, urllib.error, sys
try:
urllib.request.urlopen("http://localhost:8409/", timeout=3)
except urllib.error.HTTPError:
pass # any HTTP status means the server is serving
except Exception:
sys.exit(1) # not listening yet
PY
ok=0
for _ in $(seq 1 60); do
if [ -z "$(docker ps -q --filter name="$NAME" --filter status=running)" ]; then
echo "Container exited early"; break
fi
if docker exec -i "$NAME" python3 - < probe.py >/dev/null 2>&1; then
ok=1; break
fi
sleep 2
done
if [ "$ok" != "1" ]; then
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
echo "Smoke test FAILED: ErsatzTV did not serve HTTP on :8409"
exit 1
fi
echo "HTTP ready; asserting key IPTV endpoints (ersatztv#16)"
# E2E: assert the real Jellyfin-facing surfaces serve a valid playlist + guide, not just
# that the app answers HTTP. xmltv.xml needs channels.xml, which the scheduler writes a
# few seconds after boot, so poll each endpoint until it returns 2xx with the right shape.
# urlopen() returns only on 2xx (raises on 4xx/5xx), so reaching sys.exit means status OK.
check() {
local path="$1" needle="$2" i
for i in $(seq 1 20); do
if docker exec "$NAME" python3 -c "import urllib.request,sys; b=urllib.request.urlopen('http://localhost:8409$path',timeout=5).read(512).decode('utf-8','replace'); sys.exit(0 if '$needle' in b else 1)" 2>/dev/null; then
echo " OK $path (2xx, contains '$needle')"; return 0
fi
sleep 3
done
echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1
}
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv"; then
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide"
else
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
exit 1
fi
+70
View File
@@ -0,0 +1,70 @@
name: Renovate
# Self-hosted Renovate for the ErsatzTV fork (server-management#484).
#
# Opens dependency-update PRs against this repo (managers: nuget via CPM, github-actions).
# Runs on the shared Gitea act_runner (bumblebee). It supersedes the *proposing* half that
# the dependency-scan.yml (ersatztv#14) deliberately left out — that scan stays as a cheap
# in-repo detector for now.
#
# Config: repo-root renovate.json (package rules, grouping, automerge policy).
# Bot identity + tokens are injected from repo Actions secrets:
# RENOVATE_TOKEN — PAT of the dedicated `renovate` Gitea bot (write:repository,
# read:user, write:issue, read:organization)
# GH_COM_TOKEN — no-scope github.com PAT for changelog/release-note fetching
# (Renovate needs this on non-GitHub platforms; optional, degrades
# gracefully to anonymous if unset). Named GH_, not GITHUB_, because
# Gitea reserves the GITHUB_ secret-name prefix.
#
# NOTE: Gitea runs `schedule` triggers ONLY from the default branch (main); this file must
# be on main before the cron registers. Use workflow_dispatch to run on demand — it defaults
# to a DRY RUN (logs only, no PRs); dispatch with "Dry run" cleared to create real PRs.
on:
workflow_dispatch:
inputs:
dryRun:
description: 'Dry run (full = log only, no PRs; clear for a live run)'
type: choice
options:
- 'full'
- ''
default: 'full'
logLevel:
description: 'Log level'
type: choice
options:
- 'info'
- 'debug'
default: 'info'
schedule:
# Mondays 03:00 UTC — ahead of the 06:00 vulnerability scan
- cron: '0 3 * * 1'
concurrency:
group: ersatztv-renovate
cancel-in-progress: false
jobs:
renovate:
name: Renovate
runs-on: ubuntu-latest
container:
image: renovate/renovate:43
steps:
- name: Run Renovate
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: http://192.168.1.95:3000/api/v1
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GH_COM_TOKEN }}
RENOVATE_REPOSITORIES: timothy/ersatztv
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@tblindustries.be>'
# Let the dockerfile manager query our HTTP-only Gitea container registry for the
# ersatztv-ffmpeg base image. Creds (reused from the image-push secrets) + insecureRegistry
# live here, NOT in renovate.json, so they stay out of the committed config.
RENOVATE_HOST_RULES: '[{"matchHost":"192.168.1.95:3000","hostType":"docker","username":"${{ secrets.REGISTRY_USER }}","password":"${{ secrets.REGISTRY_PASSWORD }}","insecureRegistry":true}]'
RENOVATE_DRY_RUN: ${{ inputs.dryRun }}
LOG_LEVEL: ${{ inputs.logLevel || 'info' }}
run: renovate
-2
View File
@@ -1,2 +0,0 @@
github: jasongdove
custom: "https://www.paypal.me/jasongdove"
+3 -3
View File
@@ -3,9 +3,9 @@ contact_links:
- name: Feature Requests
url: https://features.ersatztv.org
about: Features
- name: Discord
url: https://discord.ersatztv.org
about: Chat
- name: Contact
url: https://ersatztv.org/contact
about: Chat Options
- name: Community
url: https://discuss.ersatztv.org
about: Forum
+1 -1
View File
@@ -12,7 +12,7 @@ body:
label: "This issue respects the following points:"
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
options:
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://discord.ersatztv.org) first to troubleshoot with volunteers before creating a report.
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://ersatztv.org/contact) first to troubleshoot with volunteers before creating a report.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
-26
View File
@@ -1,26 +0,0 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: daily
assignees:
- jasongdove
- package-ecosystem: docker
directory: "/docker"
schedule:
interval: daily
assignees:
- jasongdove
- package-ecosystem: docker
directory: "/docker/nvidia"
schedule:
interval: daily
assignees:
- jasongdove
- package-ecosystem: docker
directory: "/docker/vaapi"
schedule:
interval: daily
assignees:
- jasongdove
-336
View File
@@ -1,336 +0,0 @@
name: Build Artifacts
on:
workflow_call:
inputs:
release_tag:
description: 'Release tag'
required: true
type: string
release_version:
description: 'Release version number (e.g. v0.3.7-alpha)'
required: true
type: string
info_version:
description: 'Informational version number (e.g. 0.3.7-alpha)'
required: true
type: string
secrets:
apple_developer_certificate_p12_base64:
required: true
apple_developer_certificate_password:
required: true
ac_username:
required: true
ac_password:
required: true
gh_token:
required: true
azure_client_id:
required: true
azure_tenant_id:
required: true
azure_subscription_id:
required: true
permissions:
id-token: write
contents: write
jobs:
build_and_upload_mac:
name: Mac Build & Upload
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-14
kind: macOS
target: osx-x64
- os: macos-14
kind: macOS
target: osx-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target}}"
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
p12-password: ${{ secrets.apple_developer_certificate_password }}
- name: Calculate Release Name
shell: bash
run: |
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
- name: Build
shell: bash
run: |
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Bundle
shell: bash
run: |
brew install coreutils
plutil -replace CFBundleShortVersionString -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
plutil -replace CFBundleVersion -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
scripts/macOS/bundle.sh
- name: Sign
shell: bash
run: scripts/macOS/sign.sh
- name: Create DMG
shell: bash
run: |
brew install create-dmg
create-dmg \
--volname "ErsatzTV" \
--volicon "artwork/ErsatzTV.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "ErsatzTV.app" 200 190 \
--hide-extension "ErsatzTV.app" \
--app-drop-link 600 185 \
--skip-jenkins \
--no-internet-enable \
"ErsatzTV.dmg" \
"ErsatzTV.app/"
- name: Notarize
shell: bash
run: |
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
xcrun stapler staple ErsatzTV.dmg
- name: Cleanup
shell: bash
run: |
mv ErsatzTV.dmg "${{ env.RELEASE_NAME }}.dmg"
rm -r publish
rm -r ErsatzTV.app
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
if: ${{ inputs.release_tag == 'develop' }}
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
fail-if-no-assets: false
assets: "*${{ matrix.target }}.dmg"
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: "${{ env.RELEASE_NAME }}.dmg"
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
build_and_upload_linux:
name: Build & Upload Linux
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
kind: linux
target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-musl-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: ubuntu-24.04-arm
kind: linux
target: linux-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target }}"
- name: Build
shell: bash
run: |
# Define some variables for things we need
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
# Build everything
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
mkdir "$release_name"
mv scanner/* "$release_name/"
mv main/* "$release_name/"
tar czvf "${release_name}.tar.gz" "$release_name"
# Delete output directory
rm -r "$release_name"
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
if: ${{ inputs.release_tag == 'develop' }}
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
fail-if-no-assets: false
assets: "*${{ matrix.target }}.tar.gz"
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: "${{ env.RELEASE_NAME }}.tar.gz"
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
build_dotnet_windows:
name: Build dotnet for Windows
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "win-x64"
- name: Build dotnet projects
shell: bash
run: |
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Upload .NET Artifact
uses: actions/upload-artifact@v4
with:
name: dotnet-windows-build
path: |
scanner/
main/
retention-days: 1
package_and_upload_windows:
name: Package & Upload Windows
runs-on: windows-latest
needs: build_dotnet_windows
steps:
- name: Download dotnet artifacts
uses: actions/download-artifact@v4
with:
name: dotnet-windows-build
path: dotnet-build
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ secrets.azure_client_id }}
tenant-id: ${{ secrets.azure_tenant_id }}
subscription-id: ${{ secrets.azure_subscription_id }}
enable-AzPSSession: true
- name: Sign dotnet artifacts
uses: azure/trusted-signing-action@v0
with:
endpoint: https://eus.codesigning.azure.net/
trusted-signing-account-name: ArtifactSigning
certificate-profile-name: ErsatzTV
files-folder: ${{ github.workspace }}/dotnet-build
files-folder-recurse: true
files-folder-filter: ErsatzTV.exe,ErsatzTV.Scanner.exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Download rust launcher
uses: suisei-cn/actions-download-file@v1.3.0
with:
url: "https://github.com/ErsatzTV/ErsatzTV-Windows/releases/download/v1.0.0/ErsatzTV-Windows.exe"
target: rust-build/
- name: Download ffmpeg
uses: suisei-cn/actions-download-file@v1.3.0
id: downloadffmpeg
with:
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
target: ffmpeg/
- name: Package artifacts
shell: bash
run: |
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
mkdir "$release_name"
mv dotnet-build/scanner/* "$release_name/"
mv dotnet-build/main/* "$release_name/"
# dotnet shouldn't copy the resources here, but it does
rm -rf "$release_name/Resources"
mv rust-build/ErsatzTV-Windows.exe "$release_name/ErsatzTV-Windows.exe"
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
rm -f "$release_name/ffplay.exe"
(cd "${release_name}" && 7z a "../${release_name}.zip" .)
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
if: ${{ inputs.release_tag == 'develop' }}
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
fail-if-no-assets: false
assets: "*win-x64.zip"
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: "${{ env.RELEASE_NAME }}.zip"
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
-62
View File
@@ -1,62 +0,0 @@
name: Build
on:
workflow_dispatch:
push:
branches:
- main
jobs:
calculate_version:
name: Calculate version information
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Docker Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}"
short=$(git rev-parse --short HEAD)
final="${tag2}-${short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
short=$(git rev-parse --short HEAD)
final="${tag}-${short}"
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:
git_tag: ${{ env.GIT_TAG }}
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
info_version: ${{ env.INFO_VERSION }}
build_and_upload:
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
needs: calculate_version
with:
release_tag: develop
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
info_version: ${{ needs.calculate_version.outputs.info_version }}
secrets:
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
with:
base_version: develop
info_version: ${{ needs.calculate_version.outputs.git_tag }}
tag_version: ${{ github.sha }}
secrets:
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
-141
View File
@@ -1,141 +0,0 @@
name: Build & Publish to Docker Hub
on:
workflow_call:
inputs:
base_version:
description: 'Base version (latest or develop)'
required: true
type: string
info_version:
description: 'Informational version number (e.g. 0.3.7-alpha)'
required: true
type: string
tag_version:
description: 'Docker tag version (e.g. v0.3.7)'
required: true
type: string
secrets:
docker_hub_username:
required: true
docker_hub_access_token:
required: true
jobs:
build_images:
name: Build ${{ matrix.name }} image
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- name: amd64
os: ubuntu-latest
path: ''
suffix: '-amd64'
platform: 'linux/amd64'
- name: arm32v7
os: ubuntu-latest
path: 'arm32v7/'
suffix: '-arm'
platform: 'linux/arm/v7'
- name: arm64
os: ubuntu-24.04-arm
path: 'arm64/'
suffix: '-arm64'
platform: 'linux/arm64'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
if: ${{ matrix.name == 'arm32v7' }}
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
provenance: false
platforms: ${{ matrix.platform }}
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
outputs: |
type=image,name=jasongdove/ersatztv,name-canonical=true,push-by-digest=true
type=image,name=ghcr.io/ersatztv/ersatztv,name-canonical=true,push-by-digest=true
- name: Save digest to artifact
run: echo ${{ steps.build.outputs.digest }} > digest.txt
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.name }}
path: digest.txt
merge_manifests:
name: Merge Manifests
runs-on: ubuntu-latest
needs: build_images
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download all digest artifacts
uses: actions/download-artifact@v4
with:
path: digests/
- name: Read digests from artifacts
id: digests
run: |
AMD64_HASH=$(cat digests/digest-amd64/digest.txt)
ARM32V7_HASH=$(cat digests/digest-arm32v7/digest.txt)
ARM64_HASH=$(cat digests/digest-arm64/digest.txt)
DOCKER_HUB_DIGESTS="jasongdove/ersatztv@${AMD64_HASH} jasongdove/ersatztv@${ARM64_HASH} jasongdove/ersatztv@${ARM32V7_HASH}"
GHCR_DIGESTS="ghcr.io/ersatztv/ersatztv@${AMD64_HASH} ghcr.io/ersatztv/ersatztv@${ARM64_HASH} ghcr.io/ersatztv/ersatztv@${ARM32V7_HASH}"
echo "docker_hub_digests=${DOCKER_HUB_DIGESTS}" >> $GITHUB_OUTPUT
echo "ghcr_digests=${GHCR_DIGESTS}" >> $GITHUB_OUTPUT
- name: Create and push manifests
run: |
docker manifest create jasongdove/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.docker_hub_digests }}
docker manifest push jasongdove/ersatztv:${{ inputs.base_version }}
docker manifest create jasongdove/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.docker_hub_digests }}
docker manifest push jasongdove/ersatztv:${{ inputs.tag_version }}
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.ghcr_digests }}
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.ghcr_digests }}
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}
-27
View File
@@ -1,27 +0,0 @@
name: 'Close stale issues'
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
ascending: true
days-before-stale: 120
days-before-pr-stale: -1
days-before-close: 21
days-before-pr-close: -1
operations-per-run: 500
exempt-issue-labels: 'regression,security,roadmap,future,feature,enhancement,confirmed'
stale-issue-label: 'stale'
stale-issue-message: |-
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
If you have any questions you can use one of several ways to [contact us](https://ersatztv.org).
close-issue-message: |-
This issue was closed due to inactivity.
-118
View File
@@ -1,118 +0,0 @@
name: Pull Request
on:
pull_request:
jobs:
build_and_analyze:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore /p:EnableThreadingAnalyzers=true
build_and_test_windows:
runs-on: windows-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
build_and_test_linux:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: linux-x64
- os: ubuntu-latest
target: linux-musl-x64
- os: ubuntu-latest
target: linux-arm
- os: ubuntu-24.04-arm
target: linux-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -p:RestoreEnablePackagePruning=true -r "${{ matrix.target }}"
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build ErsatzTV/ErsatzTV.csproj --runtime "${{ matrix.target }}" --configuration Release --no-restore && dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
build_and_test_mac:
runs-on: macos-14
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
-56
View File
@@ -1,56 +0,0 @@
name: Release
on:
release:
types: [ published ]
jobs:
calculate_version:
name: Calculate version information
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Docker Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
echo "ARTIFACTS_VERSION=${tag}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:
git_tag: ${{ env.GIT_TAG }}
docker_tag: ${{ env.DOCKER_TAG }}
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
info_version: ${{ env.INFO_VERSION }}
build_and_upload:
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
needs: calculate_version
with:
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
info_version: ${{ needs.calculate_version.outputs.info_version }}
secrets:
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
with:
base_version: latest
info_version: ${{ needs.calculate_version.outputs.git_tag }}
tag_version: ${{ needs.calculate_version.outputs.docker_tag }}
secrets:
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+3
View File
@@ -3,6 +3,9 @@
project.lock.json
.DS_Store
*.pyc
# Claude Code
.mcp/
nupkg/
# Visual Studio Code
+56
View File
@@ -0,0 +1,56 @@
{
"mcpServers": {
"docker-mcp": {
"command": "uvx",
"args": [
"mcp-server-docker"
],
"env": {
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
}
},
"ssh-mcp": {
"command": "npx",
"args": [
"-y",
"ssh-mcp",
"--",
"--host=192.168.1.99",
"--user=timothy"
],
"env": {}
},
"gitea": {
"command": "gitea-mcp-server",
"args": [
"-t", "stdio",
"-host", "http://192.168.1.95:3000",
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
],
"env": {}
},
"csharp-lsp": {
"command": "/usr/local/share/dotnet/dotnet",
"args": [
"run",
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
"-c", "Release"
],
"env": {
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
}
},
"nuget": {
"command": "/usr/local/share/dotnet/dotnet",
"args": [
"dnx",
"NuGet.Mcp.Server",
"--source", "https://api.nuget.org/v3/index.json",
"--yes"
],
"env": {
"DOTNET_ROOT": "/usr/local/share/dotnet"
}
}
}
}
+46 -1
View File
@@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Changed
- Remove BugSnag error reporting integration
- Remove developer's personal Trakt API key
- Users who want to continue to use Trakt must create an API app and set the `Client ID` as the environment variable `TRAKT__CLIENTID`
### Fixed
- Support adding trakt lists using `app.trakt.tv` domain (instead of just `trakt.tv`)
## [26.3.0] - 2026-02-24
### Added
- Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback
- Log messages will include media item id, channel number and transcoding speed
- Add UI language setting to **Settings** > **UI**
- A small number of translations have been added for `Português (Brasil)` and `Polski`
- Translation contributions are always welcome!
- Add `Troubleshoot` button to playout details table to show info that may be helpful in determining the source of a playout item
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
- Block schedule info includes block, block item, playback order, random seed, collection index
- E.g. items with the same random seed are part of the same shuffle
- Add channel setting `Slug Seconds`
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
- If this feature turns out to be popular, methods to correct the drift may be investigated
- Add `ETV_INSTANCE_ID` environment variable to disambiguate EPG data from multiple ErsatzTV instances
- When set, the value will be used in channel identifiers before the final `.ersatztv.org`
- Show warning message when selecting audio format `aac (latm)` for general streaming use when it is only intended for DVB-C
### Changed
- Move dark/light mode toggle to **Settings** > **UI**
- Use latest (non-deprecated) authorization method with Jellyfin API
- Replace direct Discord links with new contact page https://ersatztv.org/contact which also includes other options like Matrix
- Lower GOP size and keyframe interval from four seconds to two seconds in accordance with HLS2 draft spec recommendations
### Fixed
- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time
- Fix Trakt list sync
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
- This only applies to content that *might* be problematic (using a heuristic)
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
- Graphics engine: fix stream seek value used throughout graphics engine
- This should fix loading EPG data when used with chapters/mid-roll
- This should also fix graphics element visibility when using start_seconds on content with chapters/mid-roll
- This bug was caused by stream seek including the playout item in-point (the chapter start time)
- Stream seek should only be non-zero when first joining a channel (i.e. in the middle of a playout item or chapter)
## [26.2.0] - 2026-02-02
### Added
@@ -3155,7 +3199,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.3.0...HEAD
[26.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...v26.3.0
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
+96
View File
@@ -0,0 +1,96 @@
# ErsatzTV Fork
Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https://github.com/ErsatzTV/ErsatzTV) after upstream archival (Feb 2026, v26.3.0). Our fork lives on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
## Architecture
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
- **Functional C#**: Language Ext (Option, Either monads throughout)
### Project Layout
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
| `ErsatzTV.Scanner/` | Media library scanning |
### Key Files
- **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs``ToM3U()`
- **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
- **IPTV controller**: `ErsatzTV/Controllers/IptvController.cs``/iptv/*` routes
- **Logo generation**: `ErsatzTV.Core/Images/ChannelLogoGenerator.cs`
- **Channel entities**: `ErsatzTV.Core/Domain/Channel.cs`
- **DB context**: `ErsatzTV.Infrastructure/Data/TvContext.cs`
## Deployment
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod container still runs upstream `ghcr.io/ersatztv/ersatztv:latest` pending cutover (server-management#481). Pipeline details: `docs/ci-cd.md`.
## Development
```bash
# Build
dotnet build ErsatzTV.sln
# Run locally (needs FFmpeg in PATH)
dotnet run --project ErsatzTV
# Docker build
docker build -f docker/Dockerfile -t ersatztv:dev .
```
## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep Blazor pages thin — delegate to MediatR handlers
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
## Task Completion Protocol
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers 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**: `~/homelab-docs/Operations/Project Boundaries.md` (https://docs.tblindustries.be).
**ErsatzTV docs**: `~/homelab-docs/Docker/ErsatzTV.md` + project-local `docs/` (fork strategy, channels, M3U/XMLTV).
+10
View File
@@ -3,5 +3,15 @@
<InformationalVersion>develop</InformationalVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
packages as NU1901-1904 warnings. Several projects set
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
on advisories we can't immediately fix. Demote low/moderate/high audit
advisories to warnings (still printed in build logs); NU1904 (critical)
stays an error so criticals still block. Track fixes separately.
WarningsAsErrors promotes NU1904 in EVERY project (even those without
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
</PropertyGroup>
</Project>
+27 -1
View File
@@ -6,10 +6,36 @@
<ItemGroup>
<PackageReference
Include="Microsoft.VisualStudio.Threading.Analyzers"
Version="17.14.15"
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!-- Curated static-analysis packs (ersatztv#15), applied to every project. Versions are
central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored .mcp tool
(which opts out of CPM) doesn't pull versionless references. They start at `suggestion`
severity in .editorconfig so they don't fail the TreatWarningsAsErrors build; high-value
rules are promoted to warning/error incrementally. -->
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
<PackageReference Include="Roslynator.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="SonarAnalyzer.CSharp">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Meziantou.Analyzer">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<!-- StyleCop.Analyzers intentionally omitted: its latest stable (1.1.118) crashes
(AD0001) on C# records and its rules overlap the existing .editorconfig/Roslynator.
Revisit via the record-compatible 1.2.0-beta if StyleCop is specifically wanted. (#15) -->
<PackageReference Include="AsyncFixer">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
+113
View File
@@ -0,0 +1,113 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageVersion Include="BlazorSortable" Version="5.2.1" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
<PackageVersion Include="CliWrap" Version="3.10.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageVersion Include="Flurl" Version="4.0.0" />
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
<PackageVersion Include="Heron.MudCalendar" Version="3.4.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
<PackageVersion Include="Jint" Version="4.5.0" />
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
<PackageVersion Include="LanguageExt.Transformers" Version="4.4.8" />
<PackageVersion Include="Lennox.NvEncSharp" Version="2.0.0" />
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="MediatR" Version="[12.5.0]" />
<PackageVersion Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="[9.0.12,10)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="[9.0.12,10)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.12,10)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="[9.0.12,10)" />
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="[8.0.2]" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" />
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
<PackageVersion Include="MudBlazor" Version="8.15.0" />
<PackageVersion Include="NaturalSort.Extension" Version="4.4.1" />
<PackageVersion Include="NCalcSync" Version="6.3.2" />
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NUnit" Version="4.4.0" />
<PackageVersion Include="NUnit.Analyzers" Version="4.11.2" />
<PackageVersion Include="NUnit3TestAdapter" Version="6.1.0" />
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageVersion Include="Refit" Version="9.0.2" />
<PackageVersion Include="Refit.HttpClientFactory" Version="9.0.2" />
<PackageVersion Include="Refit.Newtonsoft.Json" Version="9.0.2" />
<PackageVersion Include="Refit.Xml" Version="9.0.2" />
<PackageVersion Include="RichTextKit.Stbear" Version="0.4.167.3" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.12.32" />
<PackageVersion Include="Scriban.Signed" Version="6.5.2" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageVersion Include="SkiaSharp" Version="3.119.1" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.27.0.140913" />
<!-- Direct pin to override EF Core 9's transitive SQLitePCLRaw 2.1.10 (vulnerable
bundled SQLite, GHSA-2m69-gcr7-jv3q). The 3.x line ships the patched native
(lib.e_sqlite3 3.50.3); core 3.0.3 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
<PackageVersion Include="Testably.Abstractions.Testing" Version="5.1.0" />
<PackageVersion Include="TimeSpanParserUtil" Version="1.2.0" />
<PackageVersion Include="TimeZoneConverter" Version="7.2.0" />
<PackageVersion Include="VueCliMiddleware" Version="6.0.0" />
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.1" />
<PackageVersion Include="Winista.MimeDetect" Version="1.1.0" />
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
</Project>
@@ -11,6 +11,7 @@ public record ChannelViewModel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
@@ -0,0 +1,60 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Validation rules shared by <see cref="CreateChannelHandler" /> and
/// <see cref="UpdateChannelHandler" />. These were previously enforced only by the Blazor page
/// (<c>ChannelEditViewModelValidator</c>); porting them into the handlers makes them apply to
/// the REST API as well. Failures map to HTTP 422.
/// </summary>
internal static class ChannelValidations
{
/// <summary>
/// A channel must belong to a non-empty group; the value is used as the M3U
/// <c>group-title</c>. Mirrors the Blazor rule <c>RuleFor(x =&gt; x.Group).NotEmpty()</c>.
/// </summary>
internal static Validation<BaseError, string> ValidateGroup(string group)
{
// Use explicit returns (not a ternary): BaseError has an implicit string conversion, so a
// ternary would collapse both branches to BaseError and always produce a Fail.
if (string.IsNullOrWhiteSpace(group))
{
return BaseError.New("Channel group is required");
}
return group;
}
/// <summary>A disabled channel may not be shown in the EPG.</summary>
internal static Validation<BaseError, bool> ValidateShowInEpg(bool isEnabled, bool showInEpg)
{
if (!isEnabled && showInEpg)
{
return BaseError.New("Disabled channels cannot be shown in EPG");
}
return showInEpg;
}
/// <summary>
/// A logo path that is an absolute URI must be a valid external (http/https) url.
/// Relative/local logo paths and empty values are allowed.
/// </summary>
internal static Validation<BaseError, string> ValidateLogo(string logoPath)
{
if (string.IsNullOrWhiteSpace(logoPath))
{
return string.Empty;
}
bool isAbsoluteUri = Uri.TryCreate(logoPath, UriKind.Absolute, out _);
if (isAbsoluteUri && !Artwork.IsExternalUrl(logoPath))
{
return BaseError.New("External logo url is invalid");
}
return logoPath;
}
}
@@ -10,6 +10,7 @@ public record CreateChannel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
@@ -36,18 +37,23 @@ public class CreateChannelHandler(
return new CreateChannelResult(channel.Id);
}
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) =>
(ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken)
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId,
_,
_,
_) =>
{
var artwork = new List<Artwork>();
@@ -80,6 +86,7 @@ public class CreateChannelHandler(
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = request.SlugSeconds,
PlayoutSource = request.PlayoutSource,
PlayoutMode = request.PlayoutMode,
MirrorSourceChannelId = request.MirrorSourceChannelId,
@@ -124,6 +131,11 @@ public class CreateChannelHandler(
return channel;
});
// combine the page-only Group rule with the channel validation (keeps tuple arity within
// LanguageExt's supported applicative range while still accumulating all errors)
return (ValidateGroup(request.Group), channelValidation).Apply((_, channel) => channel);
}
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
@@ -148,16 +160,20 @@ public class CreateChannelHandler(
});
}
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
TvContext dbContext,
CreateChannel createChannel,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.CountAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => createChannel.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
CancellationToken cancellationToken)
{
bool exists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken);
if (exists)
{
return createChannel.FFmpegProfileId;
}
return BaseError.New($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
TvContext dbContext,
@@ -169,12 +185,14 @@ public class CreateChannelHandler(
return Option<int>.None;
}
return await dbContext.ChannelWatermarks
.CountAsync(w => w.Id == createChannel.WatermarkId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.WatermarkId))
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
bool exists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == createChannel.WatermarkId, cancellationToken);
if (exists)
{
return Optional(createChannel.WatermarkId);
}
return BaseError.New($"Watermark {createChannel.WatermarkId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
@@ -187,14 +205,15 @@ public class CreateChannelHandler(
return Option<int>.None;
}
return await dbContext.FillerPresets
bool exists = await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.CountAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.FallbackFillerId))
.Map(o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
.AnyAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken);
if (exists)
{
return Optional(createChannel.FallbackFillerId);
}
return BaseError.New($"Fallback filler {createChannel.FallbackFillerId} does not exist.");
}
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
@@ -1,6 +1,7 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -31,8 +32,17 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request, cancellationToken);
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
Option<Channel> maybeChannel = await dbContext.Channels
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
return await maybeChannel.Match(
Some: async channel =>
{
await DoDeletion(dbContext, channel, cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
},
None: () => Task.FromResult(
Left<BaseError, Unit>(new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
@@ -54,14 +64,4 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
return Unit.Default;
}
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext,
DeleteChannel deleteChannel,
CancellationToken cancellationToken)
{
Option<Channel> maybeChannel = await dbContext.Channels
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId, cancellationToken);
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
}
}
@@ -11,6 +11,7 @@ public record UpdateChannel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
@@ -4,10 +4,13 @@ using System.Threading.Channels;
using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
using static ErsatzTV.Application.Channels.Mapper;
using Channel = ErsatzTV.Core.Domain.Channel;
@@ -24,8 +27,23 @@ public class UpdateChannelHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
Option<Channel> maybeChannel = await dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
return await maybeChannel.Match(
Some: async channel =>
{
Validation<BaseError, Channel> validation =
await Validate(dbContext, request, channel, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult(
Left<BaseError, ChannelViewModel>(
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
@@ -52,6 +70,7 @@ public class UpdateChannelHandler(
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.SlugSeconds = update.SlugSeconds;
c.StreamSelectorMode = update.StreamSelectorMode;
c.StreamSelector = update.StreamSelector;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
@@ -161,23 +180,82 @@ public class UpdateChannelHandler(
private static async Task<Validation<BaseError, Channel>> Validate(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken) =>
(await ChannelMustExist(dbContext, request, cancellationToken),
ValidateName(request),
Channel channel,
CancellationToken cancellationToken)
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((_, _, _, _, _) => channel);
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
// fallback filler) with the channel validation; splitting keeps tuple arity within
// LanguageExt's supported applicative range while still accumulating all errors
return (ValidateGroup(request.Group),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
channelValidation)
.Apply((_, _, _, _, c) => c);
}
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
TvContext dbContext,
UpdateChannel updateChannel,
CancellationToken cancellationToken) =>
dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.Include(c => c.Playouts)
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
UpdateChannel request,
CancellationToken cancellationToken)
{
bool exists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
if (exists)
{
return request.FFmpegProfileId;
}
return BaseError.New($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.WatermarkId is null)
{
return Option<int>.None;
}
bool exists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == request.WatermarkId, cancellationToken);
if (exists)
{
return Optional(request.WatermarkId);
}
return BaseError.New($"Watermark {request.WatermarkId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.FallbackFillerId is null)
{
return Option<int>.None;
}
bool exists = await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.AnyAsync(w => w.Id == request.FallbackFillerId, cancellationToken);
if (exists)
{
return Optional(request.FallbackFillerId);
}
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
}
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
+1
View File
@@ -14,6 +14,7 @@ internal static class Mapper
channel.Group,
channel.Categories,
channel.FFmpegProfileId,
channel.SlugSeconds,
GetLogo(channel),
channel.StreamSelectorMode,
channel.StreamSelector,
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
@@ -0,0 +1,17 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetSlugSecondsByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSlugSecondsByChannelNumber, Option<double>>
{
public async Task<Option<double>> Handle(GetSlugSecondsByChannelNumber request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Channels
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken)
.Map(c => Optional(c?.SlugSeconds));
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateUiSettings, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
UpdateUiSettings request,
CancellationToken cancellationToken)
{
return await ApplyUpdate(request.UiSettings, cancellationToken);
}
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
{
await configElementRepository.Upsert(
ConfigElementKey.PagesIsDarkMode,
uiSettings.IsDarkMode,
cancellationToken);
await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
return Unit.Default;
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetUiSettings : IRequest<UiSettingsViewModel>;
@@ -0,0 +1,25 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetUiSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetUiSettings, UiSettingsViewModel>
{
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
{
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
ConfigElementKey.PagesIsDarkMode,
cancellationToken);
Option<string> pagesLanguage = await configElementRepository.GetValue<string>(
ConfigElementKey.PagesLanguage,
cancellationToken);
return new UiSettingsViewModel
{
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
Language = await pagesLanguage.IfNoneAsync("en")
};
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Application.Configuration;
public class UiSettingsViewModel
{
public bool IsDarkMode { get; set; }
public string Language { get; set; }
}
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,16 +10,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
<PackageReference Include="CliWrap" />
<PackageReference Include="Humanizer.Core" />
<PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" />
<PackageReference Include="WebMarkupMin.Core" />
<PackageReference Include="Winista.MimeDetect" />
</ItemGroup>
<ItemGroup>
@@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler(
foreach (string ffmpegPath in maybeFFmpegPath)
{
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
Option<string> maybeFFprobePath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
@@ -31,15 +31,18 @@ public class
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
GetHardwareAccelerationKinds,
ffmpegPath => GetHardwareAccelerationKinds(ffmpegPath, cancellationToken),
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
}
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(
string ffmpegPath,
CancellationToken cancellationToken)
{
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
IFFmpegCapabilities ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
{
@@ -74,7 +74,8 @@ public class
ffmpegPath,
originalPath,
withExtension,
request.MaxHeight.Value);
request.MaxHeight.Value,
cancellationToken);
CommandResult resize = await process.ExecuteAsync(cancellationToken);
@@ -36,8 +36,10 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
private async Task<Validation<BaseError, Parameters>> Validate(SaveJellyfinSecrets request)
{
var connectionParameters = new JellyfinConnectionParameters(request.Secrets.Address, request.Secrets.ApiKey, 0);
Either<BaseError, JellyfinServerInformation> maybeServerInformation = await _jellyfinApiClient
.GetServerInformation(request.Secrets.Address, request.Secrets.ApiKey);
.GetServerInformation(connectionParameters.Address, connectionParameters.AuthorizationHeader);
return maybeServerInformation.Match(
info => Validation<BaseError, Parameters>.Success(new Parameters(request.Secrets, info)),
@@ -38,7 +38,7 @@ public class
.MapT(p => SynchronizeLibraries(p, cancellationToken))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
private Task<Validation<BaseError, ConnectionAndSource>> Validate(SynchronizeJellyfinLibraries request) =>
MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
@@ -48,43 +48,48 @@ public class
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
private Validation<BaseError, ConnectionAndSource> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)
{
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
return maybeConnection.Map(connection => new ConnectionAndSource(
new JellyfinConnectionParameters(connection.Address, string.Empty, connection.JellyfinMediaSourceId),
jellyfinMediaSource))
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
private async Task<Validation<BaseError, ConnectionAndSource>> MediaSourceMustHaveApiKey(
ConnectionAndSource connectionAndSource)
{
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
return Optional(secrets.Address == connectionAndSource.ConnectionParameters.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.Map(_ => connectionAndSource with
{
ConnectionParameters = connectionAndSource.ConnectionParameters with { ApiKey = secrets.ApiKey }
})
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private async Task<Unit> SynchronizeLibraries(
ConnectionParameters connectionParameters,
ConnectionAndSource connectionAndSource,
CancellationToken cancellationToken)
{
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
connectionAndSource.ConnectionParameters.Address,
connectionAndSource.ConnectionParameters.AuthorizationHeader);
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_logger.LogWarning(
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
connectionParameters.JellyfinMediaSource.ServerName,
connectionAndSource.MediaSource.ServerName,
error.Value);
}
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
{
var existing = connectionParameters.JellyfinMediaSource.Libraries
var existing = connectionAndSource.MediaSource.Libraries
.OfType<JellyfinLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
@@ -92,7 +97,7 @@ public class
var toUpdate = libraries
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.JellyfinMediaSource.Id,
connectionAndSource.MediaSource.Id,
toAdd,
toRemove,
toUpdate,
@@ -107,10 +112,7 @@ public class
return Unit.Default;
}
private sealed record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
private sealed record ConnectionAndSource(
JellyfinConnectionParameters ConnectionParameters,
JellyfinMediaSource MediaSource);
}
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -8,16 +7,13 @@ namespace ErsatzTV.Application.Maintenance;
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
{
private readonly IClient _client;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public EmptyTrashHandler(
IClient client,
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_client = client;
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
@@ -27,7 +23,6 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
CancellationToken cancellationToken)
{
SearchResult result = await _searchIndex.Search(
_client,
"state:FileNotFound",
string.Empty,
0,
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -42,8 +43,15 @@ public class AddItemsToCollectionHandler :
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await maybeCollection.Match(
Some: async collection =>
{
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
}
private async Task<Unit> ApplyAddItemsRequest(
@@ -89,22 +97,23 @@ public class AddItemsToCollectionHandler :
private async Task<Validation<BaseError, Collection>> Validate(
TvContext dbContext,
AddItemsToCollection request,
Collection collection,
CancellationToken cancellationToken) =>
(await CollectionMustExist(dbContext, request, cancellationToken),
await ValidateMovies(request),
(await ValidateMovies(request),
await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request))
.Apply((collection, _, _, _, _) => collection);
await ValidateEpisodes(request),
await ValidateMediaItems(dbContext, request, cancellationToken))
.Apply((_, _, _, _, _) => collection);
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext,
AddItemsToCollection request,
CancellationToken cancellationToken) =>
dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
.Map(identity);
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
_movieRepository.AllMoviesExist(request.MovieIds)
@@ -133,4 +142,30 @@ public class AddItemsToCollectionHandler :
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
TvContext dbContext,
AddItemsToCollection request,
CancellationToken cancellationToken)
{
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
int existingCount = await dbContext.MediaItems
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
return existingCount == ids.Count
? Unit.Default
: BaseError.New("Media item does not exist");
}
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToCollection request) =>
request.MovieIds
.Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.Append(request.RemoteStreamIds);
}
@@ -110,10 +110,10 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
return maybeList.Map(_ => Unit.Default);
}
[GeneratedRegex(@"https:\/\/trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
private static partial Regex UriTraktListRegex();
[GeneratedRegex(@"https:\/\/trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
private static partial Regex UriTraktListRegex2();
[GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -23,8 +24,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request, cancellationToken);
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await maybeCollection.Match(
Some: collection => DoDeletion(dbContext, collection, cancellationToken).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
}
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken)
@@ -35,11 +39,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
return Unit.Default;
}
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext,
DeleteCollection request,
CancellationToken cancellationToken) =>
dbContext.Collections
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"Collection {request.CollectionId} does not exist."));
.Map(identity);
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -29,11 +30,14 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(
Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
dbContext,
request,
cancellationToken);
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
return await maybeSmartCollection.Match(
Some: smartCollection => DoDeletion(dbContext, smartCollection, cancellationToken).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"SmartCollection {request.SmartCollectionId} does not exist.")));
}
private async Task<Unit> DoDeletion(
@@ -48,11 +52,11 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
return Unit.Default;
}
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
DeleteSmartCollection request,
CancellationToken cancellationToken) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
.Map(identity);
}
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@@ -35,20 +36,29 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c, cancellationToken));
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await maybeCollection.Match(
Some: collection => ApplyRemoveItemsRequest(dbContext, request, collection, cancellationToken),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.MediaCollectionId} does not exist.")));
}
private async Task<Unit> ApplyRemoveItemsRequest(
private async Task<Either<BaseError, Unit>> ApplyRemoveItemsRequest(
TvContext dbContext,
RemoveItemsFromCollection request,
Collection collection,
CancellationToken cancellationToken)
{
List<int> requestedIds = request.MediaItemIds.Distinct().ToList();
var itemsToRemove = collection.MediaItems
.Filter(m => request.MediaItemIds.Contains(m.Id))
.Filter(m => requestedIds.Contains(m.Id))
.ToList();
if (itemsToRemove.Count != requestedIds.Count)
{
return new NotFoundError("Collection item does not exist.");
}
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
@@ -67,18 +77,12 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
return Unit.Default;
}
private static Task<Validation<BaseError, Collection>> Validate(
TvContext dbContext,
RemoveItemsFromCollection request,
CancellationToken cancellationToken) =>
CollectionMustExist(dbContext, request, cancellationToken);
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext,
RemoveItemsFromCollection request,
CancellationToken cancellationToken) =>
dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.MediaCollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
.Map(identity);
}
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
@@ -35,8 +36,15 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await maybeCollection.Match(
Some: async collection =>
{
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
}
private async Task<Unit> ApplyUpdateRequest(
@@ -69,17 +77,16 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
private static async Task<Validation<BaseError, Collection>> Validate(
TvContext dbContext,
UpdateCollection request,
CancellationToken cancellationToken) =>
(await CollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
Collection collection) =>
(await ValidateName(dbContext, request)).Map(_ => collection);
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext,
UpdateCollection updateCollection,
CancellationToken cancellationToken) =>
dbContext.Collections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
.Map(identity);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
@@ -41,8 +42,18 @@ public class
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
dbContext,
request,
cancellationToken);
return await maybeSmartCollection.Match(
Some: async smartCollection =>
{
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, smartCollection);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, UpdateSmartCollectionResult>>(
new NotFoundError($"SmartCollection {request.Id} does not exist.")));
}
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
@@ -73,16 +84,16 @@ public class
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
UpdateSmartCollection request,
CancellationToken cancellationToken) => ValidateName(dbContext, request)
.BindT(_ => SmartCollectionMustExist(dbContext, request, cancellationToken));
SmartCollection smartCollection) => ValidateName(dbContext, request)
.MapT(_ => smartCollection);
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
UpdateSmartCollection updateCollection,
CancellationToken cancellationToken) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
.Map(identity);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
@@ -1,5 +1,4 @@
using System.Threading.Channels;
using Bugsnag;
using EFCore.BulkExtensions;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Subtitles;
@@ -22,7 +21,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly IExternalJsonPlayoutBuilder _externalJsonPlayoutBuilder;
@@ -35,7 +33,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private readonly IScriptedPlayoutBuilder _scriptedPlayoutBuilder;
public BuildPlayoutHandler(
IClient client,
IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder,
IBlockPlayoutBuilder blockPlayoutBuilder,
@@ -49,7 +46,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<BuildPlayoutHandler> logger)
{
_client = client;
_dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder;
_blockPlayoutBuilder = blockPlayoutBuilder;
@@ -348,7 +344,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
newBuildStatus.Success = false;
newBuildStatus.Message = $"Timeout building playout for channel {channelName}";
_client.Notify(ex);
return BaseError.New(
$"Timeout building playout for channel {channelName}; this may be a bug!");
}
@@ -359,7 +354,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
newBuildStatus.Success = false;
newBuildStatus.Message = $"Unexpected error building playout for channel {channelName}: {ex}";
_client.Notify(ex);
return BaseError.New(
$"Unexpected error building playout for channel {channelName}: {ex.Message}");
}
+1
View File
@@ -23,6 +23,7 @@ internal static class Mapper
playoutItem.StartOffset,
playoutItem.FinishOffset,
playoutItem.GetDisplayDuration(),
playoutItem.SchedulingContext,
Some(playoutItem.FillerKind));
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
@@ -2,4 +2,10 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Playouts;
public record PlayoutItemViewModel(string Title, DateTimeOffset Start, DateTimeOffset Finish, string Duration, Option<FillerKind> FillerKind);
public record PlayoutItemViewModel(
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
string Duration,
string SchedulingContext,
Option<FillerKind> FillerKind);
@@ -112,7 +112,8 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
gap.FinishOffset,
TimeSpan.FromSeconds(Math.Round(gapDuration.TotalSeconds)).ToString(
gapDuration.TotalHours >= 1 ? @"h\:mm\:ss" : @"mm\:ss",
CultureInfo.CurrentUICulture.DateTimeFormat),
CultureInfo.CurrentCulture.DateTimeFormat),
string.Empty,
None
);
}).ToList();
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.ProgramSchedules;
public record ProcessSchedulingContext(string SerializedContext) : IRequest<Option<string>>;
@@ -0,0 +1,218 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace ErsatzTV.Application.ProgramSchedules;
public class ProcessSchedulingContextHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ProcessSchedulingContext, Option<string>>
{
private static readonly JsonSerializerOptions Options = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
public async Task<Option<string>> Handle(ProcessSchedulingContext request, CancellationToken cancellationToken)
{
try
{
using JsonDocument doc = JsonDocument.Parse(request.SerializedContext);
if (doc.RootElement.TryGetProperty(nameof(ClassicSchedulingContext.ScheduleId), out _))
{
var classicContext = JsonSerializer.Deserialize<ClassicSchedulingContext>(request.SerializedContext, Options);
if (classicContext is not null && classicContext.ScheduleId > 0)
{
return await GetClassicDetails(classicContext, cancellationToken);
}
}
else if (doc.RootElement.TryGetProperty(nameof(BlockSchedulingContext.BlockId), out _))
{
var blockContext = JsonSerializer.Deserialize<BlockSchedulingContext>(request.SerializedContext, Options);
if (blockContext is not null && blockContext.BlockId > 0)
{
return await GetBlockDetails(blockContext, cancellationToken);
}
}
}
catch (JsonException)
{
// not a valid json string, or not a context we can process
}
return request.SerializedContext;
}
private async Task<Option<string>> GetClassicDetails(
ClassicSchedulingContext classicContext,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
string scheduleName = await dbContext.ProgramSchedules
.Where(s => s.Id == classicContext.ScheduleId)
.Select(s => s.Name)
.SingleOrDefaultAsync(cancellationToken);
var scheduleItem = await dbContext.ProgramScheduleItems
.AsNoTracking()
.AsSplitQuery()
.Include(si => si.Collection)
.Include(si => si.MultiCollection)
.Include(si => si.Playlist)
.Include(si => si.RerunCollection)
.Include(si => si.SmartCollection)
.SingleOrDefaultAsync(si => si.Id == classicContext.ItemId, cancellationToken);
var name = "Classic";
ClassicContextFiller filler = null;
if (classicContext.FillerPresetId is > 0)
{
name = "Classic - Filler";
var fillerPreset = await dbContext.FillerPresets
.AsNoTracking()
.AsSplitQuery()
.Include(p => p.Collection)
.Include(p => p.MultiCollection)
.Include(p => p.Playlist)
.Include(p => p.SmartCollection)
.SingleOrDefaultAsync(p => p.Id == classicContext.FillerPresetId, cancellationToken);
if (fillerPreset is not null)
{
string collectionName = fillerPreset.CollectionType switch
{
CollectionType.Collection => fillerPreset.Collection.Name,
CollectionType.MultiCollection => fillerPreset.MultiCollection.Name,
CollectionType.Playlist => fillerPreset.Playlist.Name,
CollectionType.SmartCollection => fillerPreset.SmartCollection.Name,
_ => null
};
filler = new ClassicContextFiller(
fillerPreset.Id,
fillerPreset.Name,
fillerPreset.FillerKind,
fillerPreset.FillerMode,
fillerPreset.CollectionType,
collectionName);
}
}
ClassicContextScheduleItem item;
if (scheduleItem is not null)
{
string collectionName = scheduleItem.CollectionType switch
{
CollectionType.Collection => scheduleItem.Collection.Name,
CollectionType.MultiCollection => scheduleItem.MultiCollection.Name,
CollectionType.Playlist => scheduleItem.Playlist.Name,
CollectionType.RerunRerun or CollectionType.RerunFirstRun => scheduleItem.RerunCollection.Name,
CollectionType.SmartCollection => scheduleItem.SmartCollection.Name,
_ => null
};
item = new ClassicContextScheduleItem(scheduleItem.Id, scheduleItem.CollectionType, collectionName);
}
else
{
item = new ClassicContextScheduleItem(classicContext.ItemId, null, null);
}
var context = new ClassicContext(
new ContextScheduler(name, classicContext.Scheduler),
new ClassicContextSchedule(classicContext.ScheduleId, scheduleName ?? string.Empty),
item,
filler,
new ContextEnumerator(classicContext.Enumerator, classicContext.Seed, classicContext.Index));
return JsonSerializer.Serialize(context, Options);
}
private async Task<Option<string>> GetBlockDetails(BlockSchedulingContext blockContext, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var block = await dbContext.Blocks
.AsNoTracking()
.Include(b => b.BlockGroup)
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockId, cancellationToken);
var blockItem = await dbContext.BlockItems
.AsNoTracking()
.AsSplitQuery()
.Include(si => si.Collection)
.Include(si => si.MultiCollection)
.Include(si => si.SmartCollection)
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockItemId, cancellationToken);
BlockContextBlockItem item;
if (blockItem is not null)
{
string collectionName = blockItem.CollectionType switch
{
CollectionType.Collection => blockItem.Collection.Name,
CollectionType.MultiCollection => blockItem.MultiCollection.Name,
CollectionType.SmartCollection => blockItem.SmartCollection.Name,
_ => null
};
item = new BlockContextBlockItem(blockItem.Id, blockItem.CollectionType, collectionName);
}
else
{
item = new BlockContextBlockItem(blockContext.BlockItemId, null, null);
}
var context = new BlockContext(
new ContextScheduler("Block", null),
new BlockContextBlock(
blockContext.BlockId,
block?.BlockGroup?.Name ?? string.Empty,
block?.Name ?? string.Empty),
item,
new ContextEnumerator(blockContext.Enumerator, blockContext.Seed, blockContext.Index));
return JsonSerializer.Serialize(context, Options);
}
private sealed record ClassicContext(
ContextScheduler Scheduler,
ClassicContextSchedule Schedule,
ClassicContextScheduleItem ScheduleItem,
ClassicContextFiller Filler,
ContextEnumerator Enumerator);
private sealed record ContextScheduler(string Type, string Mode);
private sealed record ClassicContextSchedule(int Id, string Name);
private sealed record ClassicContextScheduleItem(int Id, CollectionType? CollectionType, string CollectionName);
private sealed record ClassicContextFiller(
int Id,
string Name,
FillerKind Kind,
FillerMode Mode,
CollectionType CollectionType,
string CollectionName);
private sealed record ContextEnumerator(string Name, int Seed, int Index);
private sealed record BlockContext(
ContextScheduler Scheduler,
BlockContextBlock Block,
BlockContextBlockItem BlockItem,
ContextEnumerator Enumerator);
private sealed record BlockContextBlock(int Id, string Group, string Name);
private sealed record BlockContextBlockItem(int Id, CollectionType? CollectionType, string CollectionName);
}
@@ -1,10 +1,9 @@
using Bugsnag;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex searchIndex)
public class QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex)
: IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
{
public async Task<SearchResultAllItemsViewModel> Handle(
@@ -23,7 +22,7 @@ public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex search
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, cancellationToken));
private async Task<List<int>> GetIds(string type, string query, CancellationToken cancellationToken) =>
(await searchIndex.Search(client, $"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
(await searchIndex.Search($"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
.Map(i => i.Id)
.ToList();
}
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexArtistsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexArtistsHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
@@ -17,7 +16,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexEpisodesHandler(
IClient client,
ISearchIndex searchIndex,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
@@ -31,7 +29,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexImagesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexImages, ImageCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexImagesHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -10,7 +9,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexMoviesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
@@ -20,7 +18,6 @@ public class QuerySearchIndexMoviesHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
@@ -15,7 +14,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexMusicVideosHandler(
IClient client,
ISearchIndex searchIndex,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
@@ -28,7 +26,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -10,7 +9,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexOtherVideosHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexOtherVideos, OtherVideoCardResultsViewModel>
@@ -20,7 +18,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexRemoteStreamsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexRemoteStreams, RemoteStreamCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexRemoteStreamsHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -11,7 +10,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexSeasonsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
@@ -21,7 +19,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -11,7 +10,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexShowsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
@@ -21,7 +19,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -8,7 +7,7 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchIndex, IDbContextFactory<TvContext> dbContextFactory)
public class QuerySearchIndexSongsHandler(ISearchIndex searchIndex, IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexSongs, SongCardResultsViewModel>
{
public async Task<SongCardResultsViewModel> Handle(
@@ -16,7 +15,6 @@ public class QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchInd
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
@@ -11,10 +10,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchMoviesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
: SearchUsingSearchIndexHandler(searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken)
{
@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
@@ -10,10 +9,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchTelevisionSeasonsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex),
: SearchUsingSearchIndexHandler(searchIndex),
IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(
@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
@@ -11,10 +10,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchTelevisionShowsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex),
: SearchUsingSearchIndexHandler(searchIndex),
IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(
@@ -1,18 +1,16 @@
using System.Collections.Immutable;
using Bugsnag;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
public abstract class SearchUsingSearchIndexHandler(IClient client, ISearchIndex searchIndex)
public abstract class SearchUsingSearchIndexHandler(ISearchIndex searchIndex)
{
private const int PageSize = 10;
protected async Task<ImmutableHashSet<int>> Search(string type, string query, CancellationToken cancellationToken)
{
var searchResult = await searchIndex.Search(
client,
$"type:{type} AND *{query.Replace(" ", @"\ ")}*",
string.Empty,
0,
@@ -1,6 +1,5 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using Bugsnag;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics;
using ErsatzTV.Application.Maintenance;
@@ -22,7 +21,6 @@ namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly IClient _client;
private readonly IFileSystem _fileSystem;
private readonly IConfigElementRepository _configElementRepository;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
@@ -42,7 +40,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
IHlsInitSegmentCache hlsInitSegmentCache,
IServiceScopeFactory serviceScopeFactory,
IMediator mediator,
IClient client,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger,
@@ -57,7 +54,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_hlsInitSegmentCache = hlsInitSegmentCache;
_serviceScopeFactory = serviceScopeFactory;
_mediator = mediator;
_client = client;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_logger = logger;
@@ -129,7 +125,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_ => new HlsSessionWorker(
_serviceScopeFactory,
_graphicsEngine,
_client,
OutputFormatKind.Hls,
_hlsPlaylistFilter,
_hlsInitSegmentCache,
@@ -6,5 +6,7 @@ public enum HlsSessionState
ZeroAndWorkAhead,
SeekAndRealtime,
ZeroAndRealtime,
SlugAndWorkAhead,
SlugAndRealtime,
PlayoutUpdated
}
@@ -5,9 +5,9 @@ using System.IO.Abstractions;
using System.IO.Pipelines;
using System.Text;
using System.Timers;
using Bugsnag;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -27,7 +27,6 @@ namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
private readonly IClient _client;
private readonly OutputFormatKind _outputFormatKind;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = [];
@@ -54,11 +53,11 @@ public class HlsSessionWorker : IHlsSessionWorker
private Timer _timer;
private DateTimeOffset _transcodedUntil;
private string _workingDirectory;
private Option<double> _slugSeconds;
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
IGraphicsEngine graphicsEngine,
IClient client,
OutputFormatKind outputFormatKind,
IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache,
@@ -71,7 +70,6 @@ public class HlsSessionWorker : IHlsSessionWorker
_serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_graphicsEngine = graphicsEngine;
_client = client;
_outputFormatKind = outputFormatKind;
_hlsInitSegmentCache = hlsInitSegmentCache;
_hlsPlaylistFilter = hlsPlaylistFilter;
@@ -215,6 +213,10 @@ public class HlsSessionWorker : IHlsSessionWorker
new GetPlayoutIdByChannelNumber(_channelNumber),
cancellationToken);
_slugSeconds = await _mediator.Send(
new GetSlugSecondsByChannelNumber(_channelNumber),
cancellationToken);
// time shift on-demand playout if needed
foreach (int playoutId in maybePlayoutId)
{
@@ -307,9 +309,6 @@ public class HlsSessionWorker : IHlsSessionWorker
var sw = Stopwatch.StartNew();
try
{
DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8);
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist");
@@ -320,6 +319,10 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("Playlist exists");
// start the segment-wait deadline only after the playlist file appears,
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
var segmentCount = 0;
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
@@ -382,6 +385,14 @@ public class HlsSessionWorker : IHlsSessionWorker
// after seeking and NOT completing the item, seek again, transcode method will accelerate if needed
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
// switch back to normal item after slug
HlsSessionState.SlugAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
HlsSessionState.SlugAndRealtime => HlsSessionState.ZeroAndRealtime,
// after completing the item, insert a slug
HlsSessionState.ZeroAndWorkAhead or HlsSessionState.SeekAndWorkAhead when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndWorkAhead,
HlsSessionState.ZeroAndRealtime or HlsSessionState.SeekAndRealtime when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndRealtime,
// after seeking and completing the item, start at zero
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
@@ -441,6 +452,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{
HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime,
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime,
HlsSessionState.SlugAndWorkAhead => HlsSessionState.SlugAndRealtime,
_ => _state
};
@@ -456,19 +468,32 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("HLS session state: {State}", _state);
DateTimeOffset now = wasSeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime
or HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
bool isSlug = _state is HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
FFmpegProcessRequest request = isSlug
? new GetSlugProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
_slugSeconds)
: new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
// _logger.LogInformation("Request {@Request}", request);
@@ -528,9 +553,12 @@ public class HlsSessionWorker : IHlsSessionWorker
linkedCts.Token);
}
var progressParser = new FFmpegProgress();
CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(_workingDirectory)
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
@@ -538,12 +566,20 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_logger.LogDebug("HLS process has completed for channel {Channel}", _channelNumber);
_logger.LogDebug(
"Transcoded until: {Until} - Buffer: {Buffer} seconds",
"Transcoded until: {Until} - Buffer: {Buffer} seconds - Speed {Speed}",
processModel.Until,
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds,
progressParser.Speed);
_transcodedUntil = processModel.Until;
_state = NextState(_state, processModel);
_hasWrittenSegments = true;
progressParser.LogSpeed(
processModel.MediaItemId,
processModel.IsWorkingAhead,
_channelNumber,
_logger);
return true;
}
else
@@ -620,15 +656,6 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_logger.LogError(ex, "Error transcoding channel {Channel} - {Message}", _channelNumber, ex.Message);
try
{
_client.Notify(ex);
}
catch (Exception)
{
// do nothing
}
return false;
}
finally
@@ -36,7 +36,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
saveReports,
channel,
request.Scheme,
request.Host);
request.Host,
cancellationToken);
return new PlayoutItemProcessModel(
process,
@@ -32,7 +32,8 @@ public class GetErrorProcessHandler(
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
process,
@@ -1,6 +1,5 @@
using System.IO.Abstractions;
using System.Text;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -13,7 +12,6 @@ using Newtonsoft.Json;
namespace ErsatzTV.Application.Streaming;
public class GetLastPtsTimeHandler(
IClient client,
IFileSystem fileSystem,
ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository,
@@ -181,9 +179,9 @@ public class GetLastPtsTimeHandler(
logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
// do nothing
}
}
@@ -321,7 +321,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
if (request.IsTroubleshooting)
{
channel.Number = ".troubleshooting";
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
}
if (_isDebugNoSync)
@@ -337,7 +337,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
doesNotExistProcess,
@@ -498,7 +499,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<TimeSpan> maybeDuration = maybeNextStart.Map(s => s - now);
// limit working ahead on errors to 1 minute
if (!request.HlsRealtime && maybeDuration.IfNone(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
if (!request.HlsRealtime && await maybeDuration.IfNoneAsync(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
{
maybeNextStart = now.AddMinutes(1);
maybeDuration = TimeSpan.FromMinutes(1);
@@ -508,7 +509,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
if (request.IsTroubleshooting)
{
channel.Number = ".troubleshooting";
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
maybeDuration = TimeSpan.FromSeconds(30);
finish = now + TimeSpan.FromSeconds(30);
@@ -534,7 +535,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
offlineProcess,
@@ -558,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
doesNotExistProcess,
@@ -582,7 +585,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
errorProcess,
@@ -766,7 +770,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ToListAsync(cancellationToken);
// always play min(duration to next item, version.Duration)
TimeSpan duration = maybeDuration.IfNone(version.Duration);
TimeSpan duration = await maybeDuration.IfNoneAsync(version.Duration);
if (version.Duration < duration)
{
duration = version.Duration;
@@ -21,19 +21,21 @@ public class GetSeekTextSubtitleProcessHandler(
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
ffmpegPath => GetProcess(request, ffmpegPath),
ffmpegPath => GetProcess(request, ffmpegPath, cancellationToken),
error => Task.FromResult<Either<BaseError, SeekTextSubtitleProcess>>(error.Join()));
}
private async Task<Either<BaseError, SeekTextSubtitleProcess>> GetProcess(
GetSeekTextSubtitleProcess request,
string ffmpegPath)
string ffmpegPath,
CancellationToken cancellationToken)
{
Command process = await ffmpegProcessService.SeekTextSubtitle(
ffmpegPath,
request.PathAndCodec.Path,
request.PathAndCodec.Codec,
request.Seek);
request.Seek,
cancellationToken);
return new SeekTextSubtitleProcess(process);
}
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.Streaming;
public record GetSlugProcessByChannelNumber(
string ChannelNumber,
StreamingMode Mode,
DateTimeOffset Now,
bool HlsRealtime,
DateTimeOffset ChannelStart,
TimeSpan PtsOffset,
Option<FrameRate> TargetFramerate,
Option<double> SlugSeconds) : FFmpegProcessRequest(
ChannelNumber,
Mode,
Now,
StartAtZero: true,
HlsRealtime,
ChannelStart,
PtsOffset,
FFmpegProfileId: Option<int>.None);
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming;
public class GetSlugProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: FFmpegProcessHandler<GetSlugProcessByChannelNumber>(dbContextFactory)
{
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetSlugProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
var duration = TimeSpan.FromSeconds(await request.SlugSeconds.IfNoneAsync(0));
if (duration <= TimeSpan.Zero)
{
return BaseError.New("Slug seconds must be non-zero");
}
DateTimeOffset finish = request.Now.Add(duration);
var playoutItemResult = await ffmpegProcessService.Slug(
ffmpegPath,
channel,
request.Now,
duration,
request.HlsRealtime,
request.PtsOffset,
cancellationToken);
var result = new PlayoutItemProcessModel(
playoutItemResult,
Option<GraphicsEngineContext>.None,
duration,
finish,
isComplete: true,
request.Now.ToUnixTimeSeconds(),
Option<int>.None,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
return Right<BaseError, PlayoutItemProcessModel>(result);
}
}
@@ -185,7 +185,7 @@ public class PrepareTroubleshootingPlaybackHandler(
{
Artwork = [],
Name = "ETV",
Number = ".troubleshooting",
Number = FileSystemLayout.TranscodeTroubleshootingChannel,
FFmpegProfile = ffmpegProfile,
StreamingMode = request.StreamingMode,
StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting,
@@ -2,10 +2,10 @@ using System.IO.Pipelines;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Interfaces.Troubleshooting;
@@ -17,7 +17,7 @@ using Serilog.Events;
namespace ErsatzTV.Application.Troubleshooting;
public partial class StartTroubleshootingPlaybackHandler(
public class StartTroubleshootingPlaybackHandler(
ITroubleshootingNotifier notifier,
IMediator mediator,
IEntityLocker entityLocker,
@@ -138,14 +138,23 @@ public partial class StartTroubleshootingPlaybackHandler(
linkedCts.Token);
}
var progressParser = new FFmpegProgress();
CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(FileSystemLayout.TranscodeTroubleshootingFolder)
.WithStandardErrorPipe(PipeTarget.Null)
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
progressParser.LogSpeed(
request.MediaItemInfo.Map(i => i.Id),
true,
FileSystemLayout.TranscodeTroubleshootingChannel,
logger);
try
{
IEnumerable<string> logs = logService.Sink.GetLogs(request.SessionId);
@@ -160,26 +169,11 @@ public partial class StartTroubleshootingPlaybackHandler(
// do nothing
}
Option<double> maybeSpeed = Option<double>.None;
Option<string> maybeFile = Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "ffmpeg*.log").HeadOrNone();
foreach (string file in maybeFile)
{
await foreach (string line in File.ReadLinesAsync(file, linkedCts.Token))
{
Match match = FFmpegSpeed().Match(line);
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
{
maybeSpeed = speed;
break;
}
}
}
await mediator.Publish(
new PlaybackTroubleshootingCompletedNotification(
commandResult.ExitCode,
Option<Exception>.None,
maybeSpeed),
progressParser.Speed),
linkedCts.Token);
if (commandResult.ExitCode != 0)
@@ -210,7 +204,4 @@ public partial class StartTroubleshootingPlaybackHandler(
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel;
}
}
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
private static partial Regex FFmpegSpeed();
}
@@ -161,7 +161,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
videoToolboxCapabilities.AppendLine();
}
var ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value);
var ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value, cancellationToken);
aviSynthDemuxer = ffmpegCapabilities.HasDemuxFormat(FFmpegKnownFormat.AviSynth);
aviSynthInstalled = _hardwareCapabilitiesFactory.IsAviSynthInstalled();
}
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NetArchTest.eNhancedEdition" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NUnit.Analyzers">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" />
</ItemGroup>
<!-- Reference every layer so its assembly lands in the test output for NetArchTest to analyze. -->
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
<ProjectReference Include="..\ErsatzTV.FFmpeg\ErsatzTV.FFmpeg.csproj" />
<ProjectReference Include="..\ErsatzTV.Application\ErsatzTV.Application.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure.Sqlite\ErsatzTV.Infrastructure.Sqlite.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure.MySql\ErsatzTV.Infrastructure.MySql.csproj" />
<ProjectReference Include="..\ErsatzTV.Scanner\ErsatzTV.Scanner.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,104 @@
using System.Reflection;
using NetArchTest.Rules;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Architecture.Tests;
// Enforces the dependency direction of the solution's layers (see docs/ci-cd.md / contributors guide).
// NetArchTest analyses the compiled assemblies, so a "just import it here" violation fails the build
// instead of eroding the architecture over time. Rules below reflect the intended layering and the
// current project-reference graph:
// FFmpeg (lowest) <- Core <- Infrastructure <- {Infrastructure.Sqlite, Infrastructure.MySql}
// Core <- Application; everything <- ErsatzTV (host / composition root)
[TestFixture]
public class LayeringTests
{
private static Assembly Core => Assembly.Load("ErsatzTV.Core");
private static Assembly FFmpeg => Assembly.Load("ErsatzTV.FFmpeg");
private static Assembly Application => Assembly.Load("ErsatzTV.Application");
private static Assembly Infrastructure => Assembly.Load("ErsatzTV.Infrastructure");
[Test]
public void Core_should_not_depend_on_outer_layers()
{
TestResult result = Types.InAssembly(Core)
.Should()
.NotHaveDependencyOnAny(
"ErsatzTV.Application",
"ErsatzTV.Infrastructure",
"ErsatzTV.Scanner")
.GetResult();
result.IsSuccessful.ShouldBeTrue(Describe(result));
}
[Test]
public void Core_should_not_depend_on_a_database_or_orm()
{
TestResult result = Types.InAssembly(Core)
.Should()
.NotHaveDependencyOnAny(
"Microsoft.EntityFrameworkCore",
"Pomelo.EntityFrameworkCore",
"Microsoft.Data.Sqlite",
"Dapper")
.GetResult();
result.IsSuccessful.ShouldBeTrue(Describe(result));
}
[Test]
public void FFmpeg_should_not_depend_on_other_layers()
{
TestResult result = Types.InAssembly(FFmpeg)
.Should()
.NotHaveDependencyOnAny(
"ErsatzTV.Core",
"ErsatzTV.Application",
"ErsatzTV.Infrastructure",
"ErsatzTV.Scanner")
.GetResult();
result.IsSuccessful.ShouldBeTrue(Describe(result));
}
[Test]
public void Application_should_not_depend_on_concrete_database_providers()
{
TestResult result = Types.InAssembly(Application)
.Should()
.NotHaveDependencyOnAny(
"ErsatzTV.Infrastructure.Sqlite",
"ErsatzTV.Infrastructure.MySql")
.GetResult();
result.IsSuccessful.ShouldBeTrue(Describe(result));
}
[Test]
public void Infrastructure_should_not_depend_on_application_or_concrete_providers()
{
TestResult result = Types.InAssembly(Infrastructure)
.Should()
.NotHaveDependencyOnAny(
"ErsatzTV.Application",
"ErsatzTV.Infrastructure.Sqlite",
"ErsatzTV.Infrastructure.MySql")
.GetResult();
result.IsSuccessful.ShouldBeTrue(Describe(result));
}
private static string Describe(TestResult result)
{
if (result.IsSuccessful)
{
return string.Empty;
}
IEnumerable<string> names = result.FailingTypes?.Select(t => t.FullName ?? t.Name)
?? Enumerable.Empty<string>();
return "Layering violation — offending types:\n " + string.Join("\n ", names);
}
}
+18 -18
View File
@@ -2,31 +2,31 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.2" />
<PackageReference Include="CliWrap" />
<PackageReference Include="LanguageExt.Core" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NSubstitute" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
<PackageReference Include="Serilog.Sinks.Debug" />
<PackageReference Include="Shouldly" />
<PackageReference Include="Testably.Abstractions.Testing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Application\ErsatzTV.Application.csproj" />
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure.Sqlite\ErsatzTV.Infrastructure.Sqlite.csproj" />
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
@@ -571,7 +571,8 @@ public class CustomStreamSelectorTests
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail()
[SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail_SundayFirstCulture()
{
const string YAML =
"""
@@ -608,7 +609,8 @@ public class CustomStreamSelectorTests
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match()
[SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match_SundayFirstCulture()
{
const string YAML =
"""
@@ -651,7 +653,8 @@ public class CustomStreamSelectorTests
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before()
[SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before_SundayFirstCulture()
{
// saturday from 9pm-11pm
const string YAML =
@@ -689,7 +692,8 @@ public class CustomStreamSelectorTests
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After()
[SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After_SundayFirstCulture()
{
// saturday from 9pm-11pm
const string YAML =
@@ -727,7 +731,8 @@ public class CustomStreamSelectorTests
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day()
[SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_SundayFirstCulture()
{
// saturday from 9pm-11pm
const string YAML =
@@ -765,7 +770,8 @@ public class CustomStreamSelectorTests
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match()
[SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_SundayFirstCulture()
{
// saturday from 9pm-11pm
const string YAML =
@@ -810,7 +816,7 @@ public class CustomStreamSelectorTests
[Test]
[SetCulture("fr-FR")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France()
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_MondayFirstCulture()
{
// saturday from 9pm-11pm
const string YAML =
@@ -853,6 +859,45 @@ public class CustomStreamSelectorTests
}
}
[Test]
[SetCulture("fr-FR")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_MondayFirstCulture()
{
// In a Monday-first culture, saturday is day 5 and sunday is day 6.
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Ignore_Blocked_Audio_Title()
{
@@ -0,0 +1,233 @@
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.IO;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem;
namespace ErsatzTV.Core.Tests.Iptv;
// Golden-file tests that lock the XMLTV guide output GetChannelGuideHandler produces — the surface
// Jellyfin/clients consume for EPG + channel/programme artwork. The handler reads pre-built cache
// fragments (channels.xml + {number}.xml), substitutes {RequestBase}/{AccessTokenUri}, strips
// internal etv: tags, hides ShowInEpg=false channels, then assembles via ChannelGuide.ToXml().
//
// This is the XMLTV counterpart to ChannelPlaylistGoldenTests (#11) and, like it, is the regression
// net for the {RequestBase} host substitution at the heart of #1. Goldens live under Goldens/ and are
// regenerated via the Regenerate_goldens test or ETV_UPDATE_GOLDENS=1 — review the diff before committing.
//
// The goldens faithfully enshrine current behaviour, including the raw unescaped "&text=" ampersand the
// real _channel.sbntxt template emits (technically not well-formed XML). That is why the comparison is
// string equality rather than XDocument.Parse — a parse guard would throw on today's legitimate output.
[TestFixture]
public class ChannelGuideGoldenTests
{
private const string Scheme = "https";
private const string Host = "tv.example.com";
// channels.xml is NOT filtered by ShowInEpg — every channel's <channel> def stays here; only the
// per-channel programme *data* fragment of a hidden channel is dropped. Channels 2 and 10 are visible,
// channel 3 is hidden. (2 vs 10 also proves ChannelGuide.ToXml orders fragments by DECIMAL, not string.)
private const string ChannelsXml =
"""<channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="{RequestBase}/iptv/logos/news.jpg{AccessTokenUri}" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="{RequestBase}/iptv/logos/gen{AccessTokenUri}&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="{RequestBase}/iptv/logos/hidden.jpg{AccessTokenUri}" /></channel>""";
// Visible (channel 2) — exercises {RequestBase}/{AccessTokenUri} substitution on the programme artwork
// <icon> (which must SURVIVE) AND etv:-tag stripping. etv: nodes are the documented optional graphics-
// engine feature (paired like <etv:episode_number_key>…</etv:episode_number_key>); a trailing self-
// closing <etv:…/> exercises the regex's second alternative. Both must be gone from client output.
private const string Channel2Xml =
"""<programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="{RequestBase}/iptv/artwork/posters/abc.jpg{AccessTokenUri}" /><etv:episode_number_key>5</etv:episode_number_key><etv:marker /><previously-shown /></programme>""";
// Visible (channel 10) — uses an EXTERNAL icon URL with no placeholder, proving external URLs pass
// through verbatim. Its decimal key (10) must sort AFTER channel 2 despite string order putting "10" first.
private const string Channel10Xml =
"""<programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme>""";
// Hidden (channel 3, ShowInEpg=false) — its programme data must NOT appear in the output.
private const string Channel3Xml =
"""<programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C3.etv"><title lang="en">Hidden Show</title></programme>""";
private SqliteConnection _connection;
private IDbContextFactory<TvContext> _dbContextFactory;
[OneTimeSetUp]
public async Task SetUpDatabase()
{
// Shared in-memory SQLite: the connection must stay open for the DB to live across contexts.
// Foreign keys are disabled — we only read Channel.Number/ShowInEpg, so referential integrity
// (e.g. the FFmpegProfile FK) is irrelevant and would only complicate seeding.
_connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
await _connection.OpenAsync();
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
.UseSqlite(_connection)
.Options;
_dbContextFactory = new TestTvContextFactory(options);
await using TvContext context = _dbContextFactory.CreateDbContext();
// EnsureCreated builds the schema from the model directly — sufficient here and far cheaper than
// replaying every migration just to read two Channel columns.
await context.Database.EnsureCreatedAsync();
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
context.Channels.Add(NewChannel("2", "News", showInEpg: true));
context.Channels.Add(NewChannel("10", "Movies", showInEpg: true));
context.Channels.Add(NewChannel("3", "Hidden", showInEpg: false));
await context.SaveChangesAsync();
}
[OneTimeTearDown]
public void TearDownDatabase() => _connection?.Dispose();
[Test]
public Task Guide_without_access_token() =>
Verify("guide-no-token.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null));
[Test]
public Task Guide_with_access_token() =>
Verify("guide-with-token.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: "SECRET-TOKEN"));
[Test]
public Task Guide_with_base_url() =>
Verify("guide-base-url.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "/etv", AccessToken: null));
// --- harness ---
private async Task Verify(string goldenName, GetChannelGuide request)
{
MockFileSystem fileSystem = BuildCacheFileSystem();
// ListFiles returns channels.xml too (the handler skips it via Contains("channels")) and in a
// deliberately non-decimal order, so the test proves ChannelGuide.ToXml re-sorts by decimal key.
var localFileSystem = Substitute.For<ILocalFileSystem>();
localFileSystem
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
.Returns(new[]
{
FragmentPath(fileSystem, "channels.xml"),
FragmentPath(fileSystem, "10.xml"),
FragmentPath(fileSystem, "3.xml"),
FragmentPath(fileSystem, "2.xml")
});
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem);
Either<BaseError, ChannelGuide> result = await handler.Handle(request, CancellationToken.None);
string actual = Normalize(
result.Match(
Right: guide => guide.ToXml(),
Left: error => throw new AssertionException($"Handler returned error: {error.Value}")));
string path = Path.Combine(GoldenDir(), goldenName);
if (Environment.GetEnvironmentVariable("ETV_UPDATE_GOLDENS") == "1")
{
Directory.CreateDirectory(GoldenDir());
await File.WriteAllTextAsync(path, actual);
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
return;
}
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline can't pass CI.
File.Exists(path).ShouldBeTrue(
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_GOLDENS=1) and commit it.");
string expected = Canonicalize(await File.ReadAllTextAsync(path));
actual.ShouldBe(expected);
}
[Test]
[Explicit("Regenerates all XMLTV goldens from current output; review the diff before committing.")]
public async Task Regenerate_goldens()
{
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", "1");
try
{
foreach (Func<Task> regen in new Func<Task>[]
{
Guide_without_access_token, Guide_with_access_token, Guide_with_base_url
})
{
try
{
await regen();
}
catch (InconclusiveException)
{
// expected — each Verify writes its golden then reports inconclusive
}
}
}
finally
{
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", null);
}
}
private static MockFileSystem BuildCacheFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.CreateDirectory(FileSystemLayout.ChannelGuideCacheFolder);
fileSystem.File.WriteAllText(FragmentPath(fileSystem, "channels.xml"), ChannelsXml);
fileSystem.File.WriteAllText(FragmentPath(fileSystem, "2.xml"), Channel2Xml);
fileSystem.File.WriteAllText(FragmentPath(fileSystem, "10.xml"), Channel10Xml);
fileSystem.File.WriteAllText(FragmentPath(fileSystem, "3.xml"), Channel3Xml);
return fileSystem;
}
private static string FragmentPath(MockFileSystem fileSystem, string name) =>
fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, name);
// The XMLTV cache-buster (?v={channels.xml last-write ticks}) is the only volatile token; pin it so
// the golden is deterministic. Then canonicalize to a single trailing newline (Canonicalize) so the
// golden satisfies .editorconfig insert_final_newline and a formatting pass can't break the test.
private static string Normalize(string xml) =>
Canonicalize(Regex.Replace(xml, @"\?v=\d+", "?v=MTIME"));
// Strip the UTF-8 BOM that XmlWriter emits as a preamble (the load-bearing strip — the golden files
// themselves have no BOM), normalize line endings, and force exactly one trailing newline.
private static string Canonicalize(string xml) =>
xml.TrimStart('').ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
private static Channel NewChannel(string number, string name, bool showInEpg) =>
new(Guid.NewGuid())
{
Number = number,
Name = name,
Group = "ErsatzTV",
Categories = string.Empty,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
ShowInEpg = showInEpg
};
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
{
public TvContext CreateDbContext() =>
new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
}
}
@@ -0,0 +1,180 @@
using System.Runtime.CompilerServices;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Iptv;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Iptv;
// Golden-file tests that lock the M3U output shape Jellyfin/Kodi consume (ChannelPlaylist.ToM3U).
// Baselines capture TODAY's behaviour so a regression — e.g. while implementing the paused #1
// (tvg-logo base URL) — fails CI instead of only surfacing as Jellyfin misbehaving in prod.
//
// Goldens live next to this file under Goldens/ and are located via [CallerFilePath], so they
// resolve from the checked-out source in CI and locally without embedded-resource plumbing.
// To regenerate after an INTENTIONAL output change, run the (explicit) Regenerate_goldens test
// or set ETV_UPDATE_GOLDENS=1, then review and commit the diff.
[TestFixture]
public class ChannelPlaylistGoldenTests
{
private const string Scheme = "https";
private const string Host = "tv.example.com";
private const string BaseUrl = "";
[SetUp]
public void GuardVolatileEnvironment() =>
// tvg-id (ChannelIdentifier.FromNumber) appends SystemEnvironment.InstanceId when set;
// the goldens assume it is unset (the CI/default case). Skip rather than falsely fail.
Assume.That(
string.IsNullOrEmpty(SystemEnvironment.InstanceId),
"ETV_INSTANCE_ID is set; M3U goldens assume it is unset.");
[Test]
public void Simple_channel_generated_logo() =>
Verify(
"simple-generated-logo.m3u",
new ChannelPlaylist(
Scheme,
Host,
BaseUrl,
[BuildChannel(1, "1", "News", StreamingMode.HttpLiveStreamingDirect)],
userAgent: "VLC/3.0",
accessToken: null));
[Test]
public void Channel_with_internal_logo() =>
Verify(
"internal-logo.m3u",
new ChannelPlaylist(
Scheme,
Host,
BaseUrl,
[WithLogo(BuildChannel(2, "2", "Movies", StreamingMode.HttpLiveStreamingSegmenter), "abc123def")],
userAgent: "VLC/3.0",
accessToken: null));
[Test]
public void Channel_with_external_logo() =>
Verify(
"external-logo.m3u",
new ChannelPlaylist(
Scheme,
Host,
BaseUrl,
[WithLogo(BuildChannel(3, "3", "Music", StreamingMode.TransportStream), "https://cdn.example.com/music.png")],
userAgent: "VLC/3.0",
accessToken: null));
[Test]
public void Multi_channel_sorted_by_number() =>
Verify(
"multi-channel.m3u",
new ChannelPlaylist(
Scheme,
Host,
BaseUrl,
[
BuildChannel(10, "10", "Ten", StreamingMode.HttpLiveStreamingDirect),
BuildChannel(2, "2", "Two", StreamingMode.TransportStreamHybrid),
BuildChannel(1, "1.5", "OnePointFive", StreamingMode.HttpLiveStreamingSegmenter)
],
userAgent: "VLC/3.0",
accessToken: null));
[Test]
public void Kodi_user_agent_adds_kodiprops() =>
Verify(
"kodi-user-agent.m3u",
new ChannelPlaylist(
Scheme,
Host,
BaseUrl,
[BuildChannel(4, "4", "Kids", StreamingMode.TransportStream)],
userAgent: "Kodi/21.0",
accessToken: null));
[Test]
public void With_access_token_and_base_url() =>
Verify(
"access-token-base-url.m3u",
new ChannelPlaylist(
Scheme,
Host,
baseUrl: "/etv",
[WithLogo(BuildChannel(5, "5", "Docs", StreamingMode.HttpLiveStreamingDirect), "logo5")],
userAgent: "VLC/3.0",
accessToken: "SECRET-TOKEN"));
// --- helpers ---
private static Channel BuildChannel(int seed, string number, string name, StreamingMode mode) =>
new(new Guid($"00000000-0000-0000-0000-{seed:D12}"))
{
Number = number,
Name = name,
Group = "ErsatzTV",
StreamingMode = mode,
Artwork = [],
FFmpegProfile = new FFmpegProfile
{
VideoFormat = FFmpegProfileVideoFormat.H264,
AudioFormat = FFmpegProfileAudioFormat.Aac
}
};
private static Channel WithLogo(Channel channel, string path)
{
channel.Artwork = [new Artwork { ArtworkKind = ArtworkKind.Logo, Path = path }];
return channel;
}
private static void Verify(string goldenName, ChannelPlaylist playlist)
{
string actual = playlist.ToM3U().ReplaceLineEndings("\n");
string path = Path.Combine(GoldenDir(), goldenName);
if (Environment.GetEnvironmentVariable("ETV_UPDATE_GOLDENS") == "1")
{
Directory.CreateDirectory(GoldenDir());
File.WriteAllText(path, actual);
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
return;
}
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline
// can't pass CI. Create it with the Regenerate_goldens test or ETV_UPDATE_GOLDENS=1.
File.Exists(path).ShouldBeTrue(
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_GOLDENS=1) and commit it.");
string expected = File.ReadAllText(path).ReplaceLineEndings("\n");
actual.ShouldBe(expected);
}
[Test]
[Explicit("Regenerates all M3U goldens from current output; review the diff before committing.")]
public void Regenerate_goldens()
{
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", "1");
try
{
Simple_channel_generated_logo();
Channel_with_internal_logo();
Channel_with_external_logo();
Multi_channel_sorted_by_number();
Kodi_user_agent_adds_kodiprops();
With_access_token_and_base_url();
}
catch (InconclusiveException)
{
// expected — each Verify writes its golden then reports inconclusive
}
finally
{
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", null);
}
}
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
}
@@ -0,0 +1,3 @@
#EXTM3U url-tvg="https://tv.example.com/etv/iptv/xmltv.xml?access_token=SECRET-TOKEN" x-tvg-url="https://tv.example.com/etv/iptv/xmltv.xml?access_token=SECRET-TOKEN"
#EXTINF:0 tvg-id="C5.149.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAABQ" channel-number="5" CUID="AAAAAAAAAAAAAAAAAAAABQ" tvg-chno="5" tvg-name="Docs" tvg-logo="https://tv.example.com/etv/iptv/logos/logo5.jpg?access_token=SECRET-TOKEN" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Docs
https://tv.example.com/etv/iptv/channel/5.m3u8?mode=hls-direct&access_token=SECRET-TOKEN
@@ -0,0 +1,3 @@
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
#EXTINF:0 tvg-id="C3.147.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAw" channel-number="3" CUID="AAAAAAAAAAAAAAAAAAAAAw" tvg-chno="3" tvg-name="Music" tvg-logo="https://cdn.example.com/music.png" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Music
https://tv.example.com/iptv/channel/3.ts?mode=ts-legacy
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/etv/iptv/logos/news.jpg?v=MTIME" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/etv/iptv/logos/gen?v=MTIME&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/etv/iptv/logos/hidden.jpg?v=MTIME" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/etv/iptv/artwork/posters/abc.jpg?v=MTIME" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/iptv/logos/news.jpg?v=MTIME" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/iptv/logos/gen?v=MTIME&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/iptv/logos/hidden.jpg?v=MTIME" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/iptv/artwork/posters/abc.jpg?v=MTIME" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>
@@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/iptv/logos/news.jpg?v=MTIME&amp;access_token=SECRET-TOKEN" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/iptv/logos/gen?v=MTIME&amp;access_token=SECRET-TOKEN&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/iptv/logos/hidden.jpg?v=MTIME&amp;access_token=SECRET-TOKEN" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/iptv/artwork/posters/abc.jpg?v=MTIME&amp;access_token=SECRET-TOKEN" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>
@@ -0,0 +1,3 @@
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
#EXTINF:0 tvg-id="C2.146.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAg" channel-number="2" CUID="AAAAAAAAAAAAAAAAAAAAAg" tvg-chno="2" tvg-name="Movies" tvg-logo="https://tv.example.com/iptv/logos/abc123def.jpg" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Movies
https://tv.example.com/iptv/channel/2.m3u8?mode=segmenter
@@ -0,0 +1,6 @@
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
#KODIPROP:inputstream=inputstream.ffmpegdirect
#KODIPROP:mimetype=video/mp2t
#KODIPROP:inputstream.ffmpegdirect.open_mode=ffmpeg
#EXTINF:0 tvg-id="C4.148.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAABA" channel-number="4" CUID="AAAAAAAAAAAAAAAAAAAABA" tvg-chno="4" tvg-name="Kids" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Kids" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Kids
https://tv.example.com/iptv/channel/4.ts?mode=ts-legacy
@@ -0,0 +1,7 @@
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
#EXTINF:0 tvg-id="C1.5.150.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAQ" channel-number="1.5" CUID="AAAAAAAAAAAAAAAAAAAAAQ" tvg-chno="1.5" tvg-name="OnePointFive" tvg-logo="https://tv.example.com/iptv/logos/gen?text=OnePointFive" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", OnePointFive
https://tv.example.com/iptv/channel/1.5.m3u8?mode=segmenter
#EXTINF:0 tvg-id="C2.146.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAg" channel-number="2" CUID="AAAAAAAAAAAAAAAAAAAAAg" tvg-chno="2" tvg-name="Two" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Two" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Two
https://tv.example.com/iptv/channel/2.ts
#EXTINF:0 tvg-id="C10.193.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAEA" channel-number="10" CUID="AAAAAAAAAAAAAAAAAAAAEA" tvg-chno="10" tvg-name="Ten" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Ten" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Ten
https://tv.example.com/iptv/channel/10.m3u8?mode=hls-direct

Some files were not shown because too many files have changed in this diff Show More