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
Jason Dove ae13db981d fix secrets in release workflow 2026-02-02 14:52:27 -06:00
Jason Dove b7cc8499a3 prep for release v26.2.0 [no ci] 2026-02-02 14:47:44 -06:00
Jason DoveandGitHub 36147b9e9c fix indexing collections in elasticsearch (#2806)
* fix indexing collections in elasticsearch

* more safety
2026-01-29 18:23:07 -06:00
Jason DoveandGitHub bf8c821012 improve erasing playout items and history (#2805)
* improve erasing playout items and history

* fixes
2026-01-28 09:17:56 -06:00
Jason DoveandGitHub a0f5d8d5d5 detect more local movie artwork (#2804)
* expand test coverage

* support "backdrop" files as local movie fanart fallback
2026-01-27 16:35:28 -06:00
Jason DoveandGitHub f1072b70c7 add chapter title to filler expression (#2803)
* fix transcoding tests

* pass chapter title to filler expression

* update changelog
2026-01-27 09:40:38 -06:00
Jason DoveandGitHub e10b28bc0b add normalization options (#2802)
* add new fields to database

* update editor

* audio and video normalization settings appear to work

* implement optional color normalization

* fix transcoding tests

* update changelog
2026-01-26 23:43:56 -06:00
Jason DoveandGitHub cd2bb0f2e0 fix playout build failures due to playlist enumerator access (#2801) 2026-01-26 14:44:07 -06:00
Jason DoveandGitHub e80f687612 add marathon group by director (#2800) 2026-01-26 09:10:01 -06:00
Jason DoveandGitHub 317ca1967c fix building playouts when fill with group mode is used with graphics elements (#2799) 2026-01-25 15:29:18 -06:00
Jason DoveandGitHub b86f45844c add health check to verify ffmpeg capabilities (filters) (#2798)
* add health check to verify ffmpeg capabilities (filters)

* fix loudnorm
2026-01-25 12:28:09 -06:00
Jason DoveandGitHub 353f029452 fix null ref scanning other videos with nfo file (#2797)
* fix null ref scanning other videos with nfo file

* also fix movie null ref
2026-01-25 11:34:06 -06:00
Jason DoveandGitHub 1754e7d5fb add health check for empty classic schedules (#2796) 2026-01-23 15:47:48 -06:00
Jason DoveandGitHub f96be8f99f update plex episode metadata during scan (#2795) 2026-01-21 16:59:01 -06:00
Jason DoveandGitHub 08ceb53b2b make count an expression in classic schedules (#2794)
* make count an expression in classic schedules

* add tests
2026-01-20 09:50:45 -06:00
Jason DoveandGitHub 3d81f760ee fix z-index sorting in graphics engine (#2786) 2026-01-18 09:07:21 -06:00
Jason DoveandGitHub 4ce87feac1 log graphics element z index (#2785) 2026-01-17 08:15:43 -06:00
Jason DoveandGitHub f217ba185b sync jf and emby library name and type changes (#2784) 2026-01-17 06:14:45 -06:00
Jason DoveandGitHub e925bd6913 sync plex library name changes (#2783)
* sync plex library name changes

* feedback
2026-01-16 19:45:34 -06:00
Jason DoveandGitHub 3f4c9e063b don't delete channel watermarks that are still used (#2781)
* don't delete channel watermarks that are still used

* fix folder cleanup check
2026-01-16 14:24:03 -06:00
Jason DoveandGitHub 7f361d1ea9 update dependencies (#2780)
* update messaging

* update dependencies
2026-01-16 13:57:25 -06:00
Jason DoveandGitHub 35d24ffea6 cleanup artwork cache folder (#2779)
* cleanup artwork cache folder

* fixes

* ignore watermarks that no longer exist on the file system
2026-01-16 13:38:31 -06:00
Jason DoveandGitHub a2d023ee69 local scanner artwork cleanup (#2778)
* move plex artwork removal to its own repository

* clean up old local movie artwork

* clien up old music video/artist artwork

* clean up old remote stream artwork

* clean up old song artwork

* clean up old show artwork; properly update season artwork
2026-01-16 10:23:26 -06:00
Jason DoveandGitHub 36f44f14bb fix other video artwork in xmltv (#2777) 2026-01-15 22:42:16 -06:00
Jason DoveandGitHub ccb917d0df add ffmpeg profile pad mode (#2775)
* add ffmpeg profile pad mode

* update changelog
2026-01-15 09:39:45 -06:00
Jason DoveandGitHub 343a4619a6 downmix ac3 to stereo to match output layout (#2774) 2026-01-14 10:40:49 -06:00
Jason DoveandGitHub e167c9318c fix failing unit tests (#2772) 2026-01-14 06:47:34 -06:00
Jason DoveandGitHub de230f92db fix issue reading xmltv fragments (#2771)
* fix issue reading xmltv fragments

* cleanup
2026-01-13 22:30:31 -06:00
Jason DoveandGitHub 974020a98f optimize searching for shows, seasons and movies (#2768)
* load search logging level on startup

* optimize searching for shows, seasons and movies

* use season metadata directly
2026-01-12 19:42:49 -06:00
Jason DoveandGitHub da957c9377 restore roboto font (#2767) 2026-01-12 08:49:57 -06:00
Jason DoveandGitHub b72d150775 add day_of_week to channel stream selector content_condition (#2766) 2026-01-10 11:28:14 -06:00
Jason DoveandGitHub b0b7bd17b3 respect z_index on all graphics element types (#2765) 2026-01-09 10:26:59 -06:00
Jason Dove 1f2f04f3bd more fixes 2026-01-08 21:47:28 -06:00
Jason Dove 5bc90bb245 give id-token write permission 2026-01-08 20:47:30 -06:00
Jason Dove f73a32ec13 restore permissions 2026-01-08 20:36:09 -06:00
Jason Dove 748ed1cf71 properly define secrets 2026-01-08 20:26:24 -06:00
Jason Dove f2deaa6f7a properly pass secrets 2026-01-08 20:25:20 -06:00
Jason Dove 3698fa5b7d try again 2026-01-08 20:19:47 -06:00
Jason Dove dc92cb4ac3 use separate azure login step 2026-01-08 19:59:55 -06:00
Jason Dove 69410b1a9b try to fix signing 2026-01-08 19:39:21 -06:00
Jason DoveandGitHub 4aee03e066 use code signing on all windows executables (#2764) 2026-01-08 19:27:45 -06:00
Jason Dove e16d6c67f1 prep for release v26.1.1 [no ci] 2026-01-08 16:01:29 -06:00
Jason DoveandGitHub 5d8877975d fix macos build (#2763)
* fix macos build

* also update host
2026-01-08 11:51:16 -06:00
Jason DoveandGitHub 367305d960 include web resources locally, using libman (#2762) 2026-01-08 11:24:48 -06:00
Jason DoveandGitHub aa08ad5765 optimize check for orphaned artwork (#2760) 2026-01-07 16:46:17 -06:00
Jason Dove 40c6c504fe prep for release v26.1.0 [no ci] 2026-01-06 15:15:00 -06:00
Jason Dove 933b6530e4 fix build 2026-01-06 13:41:25 -06:00
Jason DoveandGitHub 885330f8c5 rework windows launcher build process (#2758)
* update license

* download pre-compiled windows launcher instead of building it with each commit

* remove windows launcher project which has moved to its own repo
2026-01-06 13:32:20 -06:00
Jason DoveandGitHub effb96a2c2 alternate schedule and template consistency (#2757)
* refactor classic and block schedules to use same alternate schedule selector

* handle start year and end year

* add migrations for classic and block schedules

* allow editing block template start and end year

* add tests that include years

* add date range editing to classic (alternate) schedules

* fix running tests locally

* restore media files load; needed for local folder scanners

* update changelog

* feedback
2026-01-06 12:51:07 -06:00
Jason DoveandGitHub cc521326d9 hide jellyfin timing stats by default; enable with env var (#2756) 2026-01-06 12:08:55 -06:00
5c42609527 Add base URL to variant playlists (#2755)
* Add PathBase to variant playlists

* add commented code to help with testing

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2026-01-05 09:13:04 -06:00
Jason DoveandGitHub a96ef328a5 more scanning improvements related to media file table (#2754) 2026-01-04 19:16:06 -06:00
Jason DoveandGitHub 89bb3759de more jf episode scanning improvements (#2753)
* more jf episode scanning improvements

* bump log level on important message

* add transaction
2026-01-03 12:43:26 -06:00
Jason DoveandGitHub 12f2583c96 add timings for jf episode loading and saving during update (#2752) 2026-01-02 20:16:30 -06:00
Jason DoveandGitHub 7c82ecdfff use separate load queries for jf episode inserts and updates (#2751) 2026-01-02 16:28:19 -06:00
Jason DoveandGitHub 38343e3ea2 lazy load media card images (#2750) 2026-01-02 13:40:44 -06:00
Jason DoveandGitHub bcd2ea7db3 db optimizations around names and case-sensitivity (#2749)
* generate (case-insensitive) unique names for fields that should be unique

* move name case-insensitivity down to schema level

* update changelog
2026-01-02 09:25:23 -06:00
Jason DoveandGitHub 80f6e468eb collect and print timings during jellyfin show library scans (#2748)
* collect and print timings during jellyfin show library scans

* update p99
2026-01-01 20:02:24 -06:00
Jason DoveandGitHub 474e647d6d more jellyfin performance improvements (#2747)
* fix slow db and api logging so it also works in scanner project

* don't request people from jellyfin by default
2025-12-31 22:28:15 -06:00
daff1c6533 Add select all controls to media lists (#2738)
* Add select all controls to media lists

* Refine select-all helper and add coverage

* Adjust select-all button alignment

* Tighten select-all helper semantics

* Allow tests to access internal members

* Rename select-all helper and avoid shift tracking

* Simplify select-all reset helper

* Keep pager centered and move select-all right

* Add missing div

* create test project for main app; move and rename new tests

* remove core => main app reference

* cleanup unused imports

* Fix button behavior when the screen is small

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-12-31 15:18:07 -06:00
Jason DoveandGitHub 14d2dd0c3a optimize jellyfin database fields and indexes (#2746)
* optimize jellyfin database fields and indexes

* copy paste
2025-12-31 14:30:53 -06:00
Jason DoveandGitHub c606319030 add some performance troubleshooting env vars (#2745)
* add slow query logging

* add slow api logging for jellyfin

* add configurable jellyfin page size

* feedback
2025-12-31 14:04:12 -06:00
Jason DoveandGitHub 1b72b8491c improve multi-episode grouping logic (#2744) 2025-12-31 11:24:07 -06:00
Jason DoveandGitHub 9fea25a77d allow string values for count instruction in sequential schedules (#2741)
* allow string values for count instruction in sequential schedules

* fix potential div by zero
2025-12-30 18:19:33 -06:00
Jason DoveandGitHub 74b049b6e3 fix nvenc playback when color metadata changes mid-stream (#2740)
* fix nvenc playback when color metadata changes mid-stream

* update dependencies (needed to fix unit test runner)

* limit noautoscale to when it's not already present
2025-12-30 11:55:25 -06:00
Jason DoveandGitHub b2caf8ee8d fix remote stream indexing due to missing titles (#2739) 2025-12-30 08:51:06 -06:00
Jason DoveandGitHub b582b4cbf7 fix downgrade health check failure for mariadb (#2737) 2025-12-24 22:29:57 -06:00
Jason DoveandGitHub 0af81ad839 add target loudness to ffmpeg profile (#2727)
* add target loudness to ffmpeg profile

* fix filter
2025-12-19 14:46:17 -06:00
Jason DoveandGitHub 2f0cd1eb6c update dependencies (#2726) 2025-12-19 13:56:26 -06:00
Jason DoveandGitHub e4f1a93db0 fix some mysql migrations that failed on mariadb (#2725) 2025-12-17 22:40:03 -06:00
Jason DoveandGitHub 6562d616fb smart collection names must be case insensitive (#2721)
* smart collection names must be case insensitive

* use explicit ci collation for mysql
2025-12-13 18:32:02 -06:00
Jason DoveandGitHub d8122edad6 fix duplicate smart collection names (#2720)
* fix duplicate smart collection names

* fix update error
2025-12-13 14:59:06 -06:00
Jason DoveandGitHub 99b8c56a31 rework fallback filler (#2719)
* fallback fixes

* use hardware encoding for fallback filler

* rework fallback filler

* fixes
2025-12-13 09:02:48 -06:00
Jason DoveandGitHub 09858df654 fix case when cuda hw decode falls back to sw (#2718)
* fix case when cuda hw decode falls back to sw

* use a new filter
2025-12-12 15:11:32 -06:00
Jason DoveandGitHub 038286c92b use playlist item count when playlists are used as filler (#2716)
* use playlist item count when playlists are used as filler

* expand test
2025-12-10 22:14:07 -06:00
Jason DoveandGitHub 8575ab5c32 fix bt2020 playback (#2714)
* fix bt2020 playback

* update pixel format

* update changelog
2025-12-10 19:50:47 -06:00
Jason DoveandGitHub 8b768a2990 allow playlists to have no items included in epg (#2713) 2025-12-10 16:15:04 -06:00
Jason DoveandGitHub f9e4c4d386 improve build time by only running analyzers explicitly (#2710)
* improve build time by only running analyzers explicitly

* don't exclude scanner from analyzers

* Revert "don't exclude scanner from analyzers"

This reverts commit d927f9850a.

* fix sed syntax for linux
2025-12-09 14:17:05 -06:00
Jason DoveandGitHub a1f9b86fc1 add download media sample button to playback troubleshooting (#2709)
* add download media sample button to playback troubleshooting

* fixes
2025-12-09 11:49:07 -06:00
Jason DoveandGitHub 5dc20ebd1b use software pad with amd vaapi h264 main (#2708) 2025-12-06 10:03:24 -06:00
Jason DoveandGitHub d30e8b4102 only use packed headers with vaapi when supported by encoder (#2706) 2025-12-05 11:20:16 -06:00
Jason DoveandGitHub c14f373f23 implement rectangles packet for script element (#2704)
* implement rectangles packet for script element

* fixes
2025-12-04 20:57:26 -06:00
Jason DoveandGitHub a90fe26d50 script element packet spike (#2703)
* script element packet spike

* fixes
2025-12-04 15:40:18 -06:00
Jason DoveandGitHub 7a263ddaed add migration to fix any incorrect channel sort numbers (#2701) 2025-12-03 19:23:59 -06:00
Jason DoveandGitHub 3e0a9aae1e fix channel sort number when reordering channels (#2700)
* fix channel sort number when reordering channels

* tryparse
2025-12-03 18:33:47 -06:00
Jason DoveandGitHub 72dc401829 fix chronological sorting for other videos (#2699) 2025-12-03 10:38:29 -06:00
Jason DoveandGitHub 85e25ca6ea add channel start time template data (#2698)
* add channel start time template data

* rename
2025-12-03 10:16:09 -06:00
Jason DoveandGitHub 9c23b03758 fix mirror channels (#2697) 2025-12-03 09:56:52 -06:00
Jason DoveandGitHub e12888ebee fix recent regression to subtitle graphics element (#2696) 2025-12-03 07:33:17 -06:00
Jason DoveandGitHub 468ff087d4 fix loading epg entries for motion and script elements (#2693) 2025-12-02 15:46:22 -06:00
Jason DoveandGitHub 54606c76f9 framerate improvements (#2692)
* framerate improvements

* fixes
2025-12-02 12:20:09 -06:00
Jason DoveandGitHub 6bd49ffcec add remote stream metadata (#2690)
* add remote stream metadata

* use ifilesystem for last write time

* clean up unused inheritance

* optimize channel data query

* revert removeartwork change

* properly handle remote stream artwork in orphaned artwork check
2025-12-01 12:48:03 -06:00
Jason DoveandGitHub c524bc0d7d add script graphics element (#2681)
* add script graphics element

* pass template data as json to stdin

* update changelog
2025-11-30 14:20:18 -06:00
Jason DoveandGitHub 42bcadf936 work around buggy radeonsi hevc_vaapi behavior (#2680)
* try to workaround amd crop metadata ffmpeg bug

* limit workaround to hevc_vaapi encoder

* update changelog
2025-11-30 12:14:20 -06:00
Jason DoveandGitHub 1f31beab5b fix plex other video library detection (#2679) 2025-11-30 08:35:04 -06:00
Jason DoveandGitHub b45c22092d fix startup on systems unsupported by nvencsharp (#2678) 2025-11-30 06:26:57 -06:00
Jason DoveandGitHub 7bd8cefe2e more dotnet 10 updates (#2676)
* update more libraries to dotnet 10

* fix dockerfiles

* fix numeric types
2025-11-29 15:47:08 -06:00
Jason Dove f101d0b366 prep for release v25.9.0 [no ci] 2025-11-29 10:36:17 -06:00
Jason DoveandGitHub 73aabdabda fix transcoding tests (#2675) [no ci] 2025-11-29 10:34:32 -06:00
Jason DoveandGitHub bcea96d53a always log scanner exit code when it is non-zero (#2670)
* always log scanner exit code when it is non-zero

* remove test abort
2025-11-26 12:50:19 -06:00
Jason DoveandGitHub d7952e4cfa fix docker assets (#2669) 2025-11-26 11:25:56 -06:00
Jason DoveandGitHub 758399e339 fix missing net9.0 to net10.0 in docker/github (#2668) 2025-11-26 10:54:24 -06:00
Jason DoveandGitHub 6c635a4be9 upgrade to dotnet 10 (#2667)
* upgrade to dotnet 10

* remove packages that would be pruned

* properly fix tests
2025-11-26 10:49:01 -06:00
Jason DoveandGitHub 9d637cdd54 update dependencies (#2661) 2025-11-25 16:06:48 -06:00
Jason DoveandGitHub cc287ffc6e fix hls direct streams remaining open (#2660) 2025-11-24 22:40:26 -06:00
Jason DoveandGitHub 371659c5c5 cache bust new logo (#2659) 2025-11-24 20:10:57 -06:00
Jason DoveandGitHub 7afb1866ad update logo (#2658) 2025-11-24 20:01:53 -06:00
Jason DoveandGitHub 7bc1dd63fe fix file system test on windows (#2657) [no ci] 2025-11-24 13:40:04 -06:00
Jason DoveandGitHub 076b8a7188 fix editing certain playouts when using mysql (#2656) 2025-11-24 13:31:00 -06:00
Jason DoveandGitHub ec0d8ea6ac work around sequential schedule validation limit (#2655)
* remove readalltext

* remove unused method

* remove fileexists

* remove folderexists

* remove readalllines

* remove fake local file system

* show playlist name in playout build errors

* add basic sequential schedule validator tests

* work around sequential schedule validation limit
2025-11-24 12:08:43 -06:00
Jason DoveandGitHub e40d192aea limit hw sw decode downgrade polaris (#2654)
* use a more precise carve out for polaris workaround

* fix decode capability ordering
2025-11-22 12:35:29 -06:00
Jason DoveandGitHub bd7fd8984c fix 10-bit decoding with amd polaris (#2653)
* fix color conversion on amd polaris

* try software decode for polaris

* update changelog
2025-11-22 11:29:17 -06:00
Jason DoveandGitHub 2682912f5a update icon (#2652) 2025-11-21 13:34:23 -06:00
Jason DoveandGitHub 505e135482 use fonts cache folder for subtitle graphics elements (#2651) 2025-11-18 10:18:22 -06:00
Jason DoveandGitHub fdf1e70e0d fix subtitle graphics element path and fonts (#2650) 2025-11-18 10:16:03 -06:00
Jason DoveandGitHub 5c51710e2f add disabled and hidden channel indicators (#2649) 2025-11-16 10:47:10 -06:00
Jason DoveandGitHub 3cb84c2491 refresh classic playouts by default (#2647) 2025-11-15 20:45:28 -06:00
Jason DoveandGitHub 21f4439aa4 block ui improvements (#2646)
* template editor improvements

* more keyboard navigation

* replace template tree view with template table
2025-11-13 19:25:44 -06:00
Jason DoveandGitHub d88e721d2f optimize database calls related to search index (#2645) 2025-11-13 13:27:37 -06:00
Jason DoveandGitHub b6d509b9cd add search query collection type to block schedules (#2644)
* add search query collection type to block schedules

* fix history
2025-11-13 10:48:04 -06:00
Jason DoveandGitHub 6603500132 fix content_total_duration in graphics engine (#2643) 2025-11-12 20:07:54 -06:00
Jason DoveandGitHub 48b1aa3e64 fix block playout build bug (#2642) 2025-11-12 16:53:06 -06:00
Jason DoveandGitHub 42b35f7aae add channel playback troubleshooter (#2641)
* fix motion graphics loop when seeking

* add channel playback troubleshooter

* fix errors
2025-11-12 13:21:18 -06:00
Jason DoveandGitHub 8b18f2a304 expose arbitrary epg data to graphics engine (#2633) 2025-11-11 12:41:45 -06:00
Jason DoveandGitHub 1e0bba0dc6 allow custom song background images (#2632)
* allow custom song background images

* allow custom missing album art
2025-11-11 10:40:45 -06:00
Jason DoveandGitHub 3984bc7dbe add troubleshoot playback links for movies and episodes (#2631) 2025-11-11 06:33:10 -06:00
Jason DoveandGitHub e0977fa65b update dmg icon (#2630) 2025-11-11 06:12:46 -06:00
Jason DoveandGitHub d9c668c7f6 more dependency updates (#2629) 2025-11-11 05:45:46 -06:00
Jason DoveandGitHub dcea6d474f update dependencies (#2627) 2025-11-10 22:20:42 -06:00
Jason DoveandGitHub ba37c6dabe update macos again (#2626) 2025-11-10 20:19:38 -06:00
Jason DoveandGitHub d652372f78 update macos project (#2625) 2025-11-10 19:52:34 -06:00
Jason DoveandGitHub e2d8dee8cd artwork updates (#2624)
* add new logo svg; replace favicons

* replace background

* allow error/offline background customization
2025-11-10 16:01:47 -06:00
Jason DoveandGitHub d93c404607 fix interlaced check again (#2623)
* fix interlaced check again

* add interlaced ratio to media item info
2025-11-10 09:14:40 -06:00
Jason DoveandGitHub bc400de94c clear custom title when stopping guide group in scripted schedule (#2622) 2025-11-10 08:51:34 -06:00
Jason DoveandGitHub b9a73226a8 fix interlaced check (#2621)
* fix interlaced check

* reset any incorrect interlaced probe results
2025-11-10 08:39:44 -06:00
Jason DoveandGitHub d0505cd5c5 add better check for interlaced content (#2620) 2025-11-10 06:39:04 -06:00
Jason Dove d9cdbc72de fix tests [no ci] 2025-11-09 13:50:18 -06:00
Jason DoveandGitHub 2b0079fedc allow graphics elements with yml and yaml extension (#2617) 2025-11-09 13:40:34 -06:00
Jason DoveandGitHub 9d2cff53c5 fix removing tags from local libraries (#2616) 2025-11-09 13:30:37 -06:00
Jason DoveandGitHub ac361b3165 add troubleshoot button to classic schedules (#2615)
* add troubleshoot button to classic schedules

* move troubleshoot button
2025-11-09 10:17:24 -06:00
Jason DoveandGitHub dd9317e3e8 fix mpegts script on windows (#2614) 2025-11-08 10:09:56 -06:00
Jason DoveandGitHub 7530c592ff add graphics element name (#2613)
* add graphics element name

* update dependencies
2025-11-08 08:26:00 -06:00
Jason DoveandGitHub 132466b3d3 add avisynth script support to all local libraries (#2612)
* detect avisynth demuxer

* cache ffmpeg capabilities

* check for working avisynth

* scan avs files in all local libraries

* update changelog
2025-11-07 20:48:54 -06:00
Jason DoveandGitHub d709cc9f21 include streamlink in amd64 docker image (#2611)
* include streamlink in docker images

* tweak command
2025-11-07 14:12:43 -06:00
Jason DoveandGitHub 5083e748ed fix mpegts script loading (#2610) 2025-11-07 13:30:37 -06:00
Jason DoveandGitHub 053b3cd1d7 add mpegts script system (#2609)
* add basic mpegts script

* use custom mpegts script

* update changelog
2025-11-07 13:20:17 -06:00
Joe KirchoffandGitHub c3c7ff2669 Update audio codec in default FFmpeg profile name (#2608)
Quick fix for mismatch against profile audio codec setting.
2025-11-07 05:27:09 -06:00
Jason DoveandGitHub e6824cf251 graphics engine: add scaled resolution and place within source content (#2606) 2025-11-06 14:15:55 -06:00
Jason DoveandGitHub d87561d140 update subtitle titles (#2605) 2025-11-06 06:11:57 -06:00
Jason DoveandGitHub f79fa9a50a fix subtitle title updates (#2604) 2025-11-06 06:03:32 -06:00
Jason DoveandGitHub 629b3d7d9f fix effective block tests (#2600)
* fix effective block tests running on github

* update dependencies

* pass tz again

* use tzconvert for time zones in tests

* temporary logging

* maybe fix

* test cleanup
2025-11-05 06:48:49 -06:00
Jason DoveandGitHub 453737a521 add custom_title to start_epg_group (#2599) 2025-11-04 16:08:07 -06:00
Jason DoveandGitHub dd38ba19ea add collection type search query (#2598) 2025-11-04 12:22:41 -06:00
Jason DoveandGitHub 8e2a15296f sync subtitle titles from jellyfin (#2595) 2025-11-03 11:04:44 -06:00
Jason DoveandGitHub d2cbfcb79a fix error screen generation (#2594) 2025-11-02 10:41:56 -06:00
Jason DoveandGitHub 89133255d3 fix classic schedule start time calculation across a UTC offset change (#2593)
* fix classic schedule start time calculation across a UTC offset change

* update changelog
2025-11-02 09:38:18 -06:00
Jason DoveandGitHub c6245bae0c fix indexing songs with null artists/album artists (#2592) 2025-11-02 09:25:46 -06:00
Jason DoveandGitHub 2912e71c10 fix xmltv generation for on-demand playout mode (#2591) 2025-11-01 23:10:02 -05:00
Jason DoveandGitHub 9e54d42e5f reduce search index batch size (#2590) 2025-11-01 22:45:14 -05:00
Jason DoveandGitHub 63f342e6a7 fix on-demand playouts having empty xmltv (#2589) 2025-11-01 22:08:49 -05:00
Jason DoveandGitHub 5a7c59d602 insert unscheduled gaps as utc (#2588) 2025-11-01 20:28:24 -05:00
Jason DoveandGitHub 4822ba5486 fix block start time calculation (#2587) 2025-11-01 20:16:04 -05:00
Jason DoveandGitHub b24617fe7c add some graphics engine template data fields (#2586)
* move resolution out of media item template data key

* add some media item template data
2025-11-01 08:29:56 -05:00
Jason DoveandGitHub 5045a411b1 fix sequential schedule building across offsets (#2584) 2025-10-31 09:20:09 -05:00
Jason DoveandGitHub 425fb34317 show playout warnings count in left menu (#2583) 2025-10-31 09:05:25 -05:00
Jason DoveandGitHub e5ef9be09c fix explicit audio decoder on combined input (#2582) 2025-10-30 14:37:16 -05:00
Jason DoveandGitHub e9338b534b fix seeking content with dts audio (#2581)
* fix seeking content with dts audio

* formatting
2025-10-30 14:13:00 -05:00
Jason DoveandGitHub 191e694545 fix block playout ui crash (#2580) 2025-10-29 14:16:59 -05:00
Jason DoveandGitHub 727a978689 fix block history and smart_collection search bugs (#2579)
* fix block history when using mirror offset

* fix smart_collection search crashes
2025-10-29 14:01:14 -05:00
Jason DoveandGitHub 7a133d46da fix remote stream scripts (#2573) 2025-10-28 12:57:13 -05:00
Jason DoveandGitHub b1fbf651a2 log remote stream scripts (#2572) 2025-10-28 09:49:48 -05:00
Jason DoveandGitHub 0dbdcc3674 fix hls direct with jellyfin 10.11 (#2570) 2025-10-26 18:09:37 -05:00
Jason Dove 9eb7bbf0e6 prep for release v25.8.0 [no ci] 2025-10-26 09:22:02 -05:00
Jason DoveandGitHub e851a295a6 fix scripted schedule building across offsets (#2569) 2025-10-26 09:07:21 -05:00
Jason DoveandGitHub 3b254735e6 fix transcoding tests; fix vaapi subtitle crop (#2568)
* fix transcoding tests using text subtitles

* fix vaapi picture subtitle overlay with crop

* more test improvements
2025-10-26 08:51:24 -05:00
Jason DoveandGitHub 1f8834c280 block playout fixes; hls direct fixes (#2566)
* fix block playout builder with empty collection

* fix hls direct when selecting audio

* allow embedded subtitles with hls direct
2025-10-25 06:35:56 -05:00
Jason DoveandGitHub 545db4db9b update dependencies (#2565)
* logging cleanup

* update dependencies
2025-10-24 09:17:45 -05:00
Jason DoveandGitHub e590298b93 add deep scans for external collections (#2562) 2025-10-23 19:32:38 -05:00
Jason DoveandGitHub a47510fef3 add aac (latm) audio format (#2561)
* add aac (latm) audio format

* update changelog
2025-10-23 15:56:13 -05:00
Jason DoveandGitHub e089b12c2b trakt list fixes (#2560)
* show reset playout build failures

* fix scheduling trakt list playlists that contain shows
2025-10-23 11:15:55 -05:00
Jason DoveandGitHub 82e0fcaec8 maintain source fps when using qsv (#2558) 2025-10-22 14:27:01 -05:00
Jason DoveandGitHub f7c699248c fix collection and network scanners (#2557) 2025-10-22 08:59:06 -05:00
Jason DoveandGitHub 2d53063ce9 edit jellyfin and emby connection info (#2556) 2025-10-21 19:10:48 -05:00
Jason DoveandGitHub 626048f9c3 change how scanner and main process communicate (#2555)
* report scanner progress using api

* process scanner search index updates through api

* update changelog

* update dependencies
2025-10-21 15:08:49 -05:00
Jason DoveandGitHub 2ef2b0299a switch back from fmp4 to ts segments (#2554)
* restore pts offset calculation

* use ts segments again

* update changelog
2025-10-21 12:17:05 -05:00
Jason DoveandGitHub fcce53a3df fix a couple ui errors (#2551) 2025-10-20 10:54:43 -05:00
Jason DoveandGitHub d4353f6d42 add episode thumbnail to xmltv template (#2550) 2025-10-19 12:13:40 -05:00
Jason DoveandGitHub 64ea413b6f fix nvidia 10 bit text subtitles and permanent watermark (#2549)
* fix nvidia 10 bit text subtitles and watermark

* update changelog
2025-10-19 11:18:15 -05:00
Jason DoveandGitHub d14ebf3522 properly track discontinuity sequences with fmp4 (#2548)
* properly track discontinuity sequences with fmp4

* update dependencies
2025-10-19 10:31:11 -05:00
Jason DoveandGitHub 889904e70d fix management of fmp4 init segments (#2546) 2025-10-18 22:52:45 -05:00
Jason DoveandGitHub 35e7922836 fix mpegts wrapper with fmp4 segmenter source (#2545) 2025-10-18 16:37:31 -05:00
Jason DoveandGitHub ffe15629cb consolidate streaming modes (#2544)
* consolidate segmenters

* let old segmenter mode query params continue to work
2025-10-18 10:54:30 -05:00
Jason DoveandGitHub ba5a027525 reduce playout log spam (#2543) 2025-10-18 08:59:17 -05:00
Jason DoveandGitHub a33ac4a048 fix qsv audio sync (#2542)
* fix qsv audio sync

* cast a wider net

* always reset qsv pts
2025-10-17 20:01:25 -05:00
Jason DoveandGitHub 7ae028e2e9 fix trakt list sync (#2540) 2025-10-16 14:00:56 -05:00
Jason DoveandGitHub 6404dee646 update dependencies (#2539) 2025-10-15 08:41:38 -05:00
Jason DoveandGitHub 940d26419c fix buffer logging (#2535) 2025-10-14 15:55:27 -05:00
Jason DoveandGitHub 9bae8e73bf slightly increase throttled readrate in segmenter (#2534) 2025-10-14 14:53:56 -05:00
Jason DoveandGitHub f41f4b19d4 wait for two initial segments in playback troubleshooting (#2532) 2025-10-14 11:45:11 -05:00
Jason DoveandGitHub 917acf9683 fix hls segmenter fmp4 on windows (#2531)
* fix hls segmenter fmp4 on windows

* try to fix by using working directory
2025-10-14 10:29:08 -05:00
Jason DoveandGitHub da4687ac0f fix nvidia capabilities on windows (#2530) 2025-10-14 09:32:10 -05:00
Jason DoveandGitHub d1af6599f0 fix segmenter repeating content when stream first starts (#2529) 2025-10-14 08:53:22 -05:00
Jason DoveandGitHub 4e43817f8e add graphics engine text halo (#2528) 2025-10-14 06:41:32 -05:00
Jason DoveandGitHub ebcd9a35a7 fix scaling image subs with vaapi (#2526) 2025-10-13 19:42:01 -05:00
Jason DoveandGitHub ea5956a268 improve stream startup (#2525) 2025-10-13 16:26:06 -05:00
Jason DoveandGitHub 65ff1f5502 improve live stream reliability (#2524) 2025-10-13 15:41:48 -05:00
Jason DoveandGitHub 5ef8b04119 fix intermittent watermark opacity (#2523) 2025-10-13 12:00:58 -05:00
Jason DoveandGitHub 99837e808a fix speed parsing (#2522) 2025-10-13 11:49:39 -05:00
Jason DoveandGitHub 7c2083d3f2 add playback troubleshooting speed indicator (#2521)
* more api fixes

* add playback troubleshooting speed indicator
2025-10-13 11:25:56 -05:00
Jason DoveandGitHub b851a7daba api changes for ffmpeg profiles (#2520) 2025-10-13 06:44:32 -05:00
Jason DoveandGitHub 48e7c85f7b api changes to support etvcli (#2519) 2025-10-12 10:13:30 -05:00
Jason DoveandGitHub bf4182f115 add copy block button (#2518) 2025-10-11 13:35:22 -05:00
Jason DoveandGitHub fb9ca8953e add text element formatting options (#2517) 2025-10-11 12:45:20 -05:00
Jason DoveandGitHub 48310e044b optimize first run (#2516)
* do not accept ui requests until database is ready

* add empty database to greatly speed up initial startup

* remove middleware
2025-10-11 09:05:31 -05:00
Jason DoveandGitHub 144b3fe80b fix remote stream durations in playouts (#2515) 2025-10-11 08:30:24 -05:00
Jason DoveandGitHub 4a754c4e6a add helper text for sequential schedule file (#2514) 2025-10-11 06:43:59 -05:00
Jason DoveandGitHub 7059669023 add schedule file names to playouts table (#2513) 2025-10-10 21:55:22 -05:00
Jason DoveandGitHub c03f81a465 add block playout troubleshooting tool (#2512)
* rename yaml validation to sequential schedule validation

* some better exception handling

* add block playout troubleshooting page

* add paged block playout history

* add history details

* update changelog
2025-10-10 20:50:01 -05:00
Jason DoveandGitHub e3d07050bf graphics element template data improvements (#2511)
* add other video template data

* add path and related functions
2025-10-10 13:47:18 -05:00
Jason DoveandGitHub 5a88bfc310 use old ffmpeg pipeline for single permanent watermark (#2510) 2025-10-10 10:14:29 -05:00
Jason DoveandGitHub dd92a65742 more nvidia capabilities safety (#2509) 2025-10-09 22:16:30 -05:00
Jason DoveandGitHub 07ffa1642b fix nvidia troubleshooting on arm64 (#2508) 2025-10-09 22:07:07 -05:00
Jason DoveandGitHub 6fc602323f catch proper exception (#2507) 2025-10-09 21:46:54 -05:00
Jason DoveandGitHub d5fd8e7be6 another attempt at fixing nvidia startup (#2506)
* another attempt at fixing nvidia startup

* update dependencies
2025-10-09 21:36:55 -05:00
Jason DoveandGitHub dba5485300 fix nvidia startup errors on arm64 (#2505) 2025-10-09 21:26:27 -05:00
Jason Dove 6847a133ca prep for release v25.7.1 [no ci] 2025-10-09 10:40:05 -05:00
Jason DoveandGitHub fd60c120ae add more cuda logging (#2503) 2025-10-08 20:04:18 -05:00
Jason DoveandGitHub 371d1d89fb push cuda context when checking capabilities (#2502) 2025-10-08 19:44:29 -05:00
Jason DoveandGitHub ec6bc797f4 update cuda failure logging (#2501) 2025-10-08 19:13:24 -05:00
Jason DoveandGitHub 4c57167864 package windows artifacts using zip instead of 7z (#2496) 2025-10-07 10:11:51 -05:00
Jason DoveandGitHub 9016523757 cleanup some exceptions; add health check (#2495)
* handle artwork timeouts so they aren't reported

* catch some more cancellation errors

* add free space validation on startup

* add downgrade health check

* update dependencies
2025-10-07 09:50:55 -05:00
Jason Dove 6a38c91d54 update changelog [no ci] 2025-10-06 16:25:28 -05:00
Jason DoveandGitHub 9ec4d0a85c show graphics engine scriban errors in log (#2491)
* show graphics engine scriban errors in log

* better fix
2025-10-06 16:24:40 -05:00
Jason DoveandGitHub 4cb98242ba do not allow deleting default ffmpeg profile (#2490)
* remove dead code

* do not allow deleting default ffmpeg profile
2025-10-06 06:11:36 -05:00
Jason DoveandGitHub 0e2084838a use a table for blocks (#2489) 2025-10-05 09:45:29 -05:00
Jason DoveandGitHub f3f900b4ca fix playback troubleshooting (#2488) 2025-10-05 06:06:56 -05:00
Jason DoveandGitHub c39858b2d8 fix hls direct (#2487) 2025-10-04 15:36:36 -05:00
Jason DoveandGitHub 0363609923 allow h264 video profile using vaapi (#2485) 2025-10-04 08:40:10 -05:00
Jason DoveandGitHub a02aa37957 do not delete ffmpeg profile used by channel (#2484) 2025-10-04 08:29:07 -05:00
Jason Dove 86a7563da5 prep for release v25.7.0 [no ci] 2025-10-03 19:35:06 -05:00
Jason Dove cd4715a32e update changelog [no ci] 2025-10-03 10:49:00 -05:00
Jason ArendsandGitHub fc04118bf9 Emby: accept non-File protocols for Movie items (only require MediaSources present) (#2483) 2025-10-03 10:41:03 -05:00
Jason DoveandGitHub dd5fd1ef8f fix cropping jellyfin and emby content that is too small (#2481)
* fix cropping jellyfin and emby content that is too small

* fix transcoding tests with nvidia

* update dependencies
2025-10-02 20:13:40 -05:00
Jason Dove 404f898b2f fix build 2025-10-02 13:41:49 -05:00
Jason DoveandGitHub 3e8ac9914c refactor playout build errors (#2480)
* refactor classic playout builds

* refactor sequential playout builds

* refactor block playout building

* don't fail building an empty block schedule

* fix scripted playout build errors
2025-10-02 13:36:39 -05:00
Jason DoveandGitHub a0788532a0 ignore embedded text subtitles that have not been extracted (#2479)
* ignore text subtitles that have not been extracted

* fix bug with channel paging
2025-10-02 12:41:04 -05:00
Jason DoveandGitHub b3daad7c3b improve playout error formatting (#2477) 2025-10-01 21:55:55 -05:00
Jason DoveandGitHub 598de5d5d6 add playout build status (#2476)
* add playout build status

* show build status in playout list

* update changelog
2025-10-01 21:40:39 -05:00
Jason DoveandGitHub 5c174eabdb add playback troubleshooting logs (#2475) 2025-10-01 16:29:04 -05:00
Jason DoveandGitHub 18905a79dc add stream selector to playback troubleshooting (#2474) 2025-10-01 11:10:38 -05:00
Jason DoveandGitHub 077fed6cac fix vaapi h264 constrained baseline decode (#2473)
* fix vaapi h264 constrained baseline decode

* update changelog
2025-09-30 19:32:45 -05:00
Jason DoveandGitHub fac0f36d35 add codec info to multivariant playlist (#2472)
* add codec info to multivariant playlist

* upgrade dependencies
2025-09-30 13:58:24 -05:00
Jason DoveandGitHub 287adc34b5 add qsv av1 encoder (#2471) 2025-09-30 13:13:26 -05:00
Jason DoveandGitHub 6ff153f01d add vaapi av1 encoder (#2470) 2025-09-30 12:18:29 -05:00
Jason DoveandGitHub e3af0f0b69 add nvidia av1 encoder (#2469) 2025-09-30 11:43:12 -05:00
Jason DoveandGitHub b46de50801 add hls segmenter fmp4 streaming mode (#2468)
* add streaming mode segmenter fmp4

* allow hevc channel preview
2025-09-30 10:04:02 -05:00
Jason DoveandGitHub 77163e6746 use gif watermark metadata in graphics engine (#2465) 2025-09-29 14:28:02 -05:00
Jason DoveandGitHub 1763c897eb fix filler expression with playlists (#2464) 2025-09-29 14:05:27 -05:00
Jason DoveandGitHub ac45d6acd4 use cuvid to check nvidia decode capabilities (#2461)
* detect nvidia decode capabilities

* use cuvid to check b-ref mode
2025-09-27 18:44:48 +00:00
Jason DoveandGitHub 8b4b7cf16a fix nvidia in docker; minimize nvenc sessions (#2460) 2025-09-27 17:13:56 +00:00
Jason DoveandGitHub b820b798cb use nvenc to detect encoder capability (#2459) 2025-09-27 16:30:14 +00:00
Jason DoveandGitHub dc92a96bd9 fix playlist preview (#2457) 2025-09-25 13:54:44 +00:00
Jason DoveandGitHub 18523dce64 add scripted playout timeout setting (#2456)
* add setting for scripted playout build timeout

* update dependencies
2025-09-25 02:33:49 +00:00
Jason Dove ffb50a9404 fix link [no ci] 2025-09-21 20:06:56 -05:00
Jason Dove 7462039301 update readme [no ci] 2025-09-21 20:05:57 -05:00
Peter DeyandGitHub c71269058e Display hostname & build configuration when build config is not "release" (#2449)
* Display hostname & build configuration when build config is not "release" (default).

* Add missed ARG line for arm64/Dockerfile
2025-09-21 19:04:15 +00:00
Jason DoveandGitHub 9ec220c122 add page to edit channel numbers (#2454) 2025-09-21 18:31:16 +00:00
Jason Dove fc97a2da3c update issue template [no ci] 2025-09-21 09:02:13 -05:00
Jason DoveandGitHub ddc1120904 fix maintaining embedded text subtitles from media server (#2453)
* properly reset extracted flag on subtitles

* optimize subtitle updates; extract after targeted deep scan

* fix extracted text subtitle playback from media servers
2025-09-21 13:54:08 +00:00
Jason DoveandGitHub e70e4fb826 fix some invalid external subtitle data (#2451) 2025-09-21 03:13:20 +00:00
Jason DoveandGitHub b790b5944c fix external ssa subtitles from media servers (#2450) 2025-09-20 21:13:42 +00:00
Jason DoveandGitHub 0ca1859802 limit nvidia workaround to h264 (#2448) 2025-09-20 17:31:55 +00:00
Jason DoveandGitHub 07a160fcc6 work around nvidia green line (#2447) 2025-09-20 16:12:08 +00:00
Jason DoveandGitHub 788a1ecdc4 add deco break content (#2446)
* add initial models

* migrations

* edit break content mode

* add and remove break content from ui

* use autocompletes in deco editor

* save break content to db

* allow break content playlists

* refactor default filler build

* fix slow startup

* start to implement adding break content

* use clone; try to fix block breaks

* fix updating history

* use consistent removebefore values

* cleanup logging

* only allow playlist break content

* update changelog
2025-09-19 18:48:51 +00:00
Jason DoveandGitHub 004da8b7aa use better fields for filler preset duration (#2442) 2025-09-18 15:56:51 +00:00
Jason DoveandGitHub c8679144c5 add text element text_fit options (#2441) 2025-09-18 14:08:24 +00:00
Jason DoveandGitHub a389c1bbbe more search fields and highlighting (#2440) 2025-09-18 11:36:24 +00:00
Jason DoveandGitHub ecacf3960f add search fields to filter collections and schedules tables (#2439) 2025-09-18 10:44:11 +00:00
Jason DoveandGitHub aa5ba5a78e fix recent nvidia regression (#2437)
* fix recent nvidia regression

* update transcoding tests for graphics engine
2025-09-18 03:15:23 +00:00
Jason DoveandGitHub 9e111a103e fix fallback on mirror channels (#2436) 2025-09-17 18:14:25 +00:00
Jason DoveandGitHub 8bc3457de0 fix hls playlist filtering (#2435)
* add failing test

* fix hls playlist filtering
2025-09-17 15:42:23 +00:00
Jason DoveandGitHub febabcff6f fix running tests in github (timezone issue) (#2434) 2025-09-17 14:16:54 +00:00
Jason DoveandGitHub f26e48c063 block schedules: skip items and collections that will never fit (#2433)
* add first block playout builder test

* block schedules: skip items and collections that will never fit
2025-09-17 13:53:50 +00:00
Jason DoveandGitHub 6465c416ff motion end behavior (#2431)
* add end behavior enum and properties

* support loop end behavior

* implement end behavior hold

* update changelog
2025-09-17 03:35:49 +00:00
Jason DoveandGitHub a0e0bb8753 fix motion element timing (#2430) 2025-09-17 01:09:00 +00:00
Jason DoveandGitHub b9451a6585 add motion graphics elements (#2428)
* crude motion graphics element

* fix motion element rendering

* implement motion element scaling

* implement motion start seconds

* update changelog
2025-09-16 21:34:08 +00:00
Jason DoveandGitHub 0c49f4799f fix playback of content with unknown color range (#2427) 2025-09-16 16:20:43 +00:00
Jason DoveandGitHub ea008776b1 more nvidia 10-bit fixes (#2426)
* fix playback with invalid ffmpeg profile

* fix 10 bit output with nvidia and graphics engine
2025-09-16 14:44:22 +00:00
Jason DoveandGitHub 9aa7c44388 add watermarks and graphics elements to block items (#2424) 2025-09-16 02:39:25 +00:00
d855e4f20d Add initial support for Rockchip Media Process Platform (rkmpp) hardware acceleration (#2418)
* Add Rockchip Media Process Platform (rkmpp) acceleration

* remove fourcc stuff; it's exclusive to videotoolbox

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-09-16 00:51:56 +00:00
Jason DoveandGitHub 9da655e210 keep at least 10 bit color throughout nvidia tonemap pipeline (#2423) 2025-09-15 20:47:15 +00:00
Jason DoveandGitHub 03b9db7835 fix green output with libplacebo and nvidia 10 bit (#2422) 2025-09-15 19:40:27 +00:00
Jason DoveandGitHub 245165c9d9 add rerun collection type (#2421)
* rename collection type

* split collections into separate pages

* add rerun collection types, migration, editor

* add rerun to classic schedule items

* rerun plumbing in classic playout builder

* start to implement rerun enumerator

* add scheduledAt to enumerator movenext

* maintain rerun history in db

* fix shuffle

* fix rerun allowed playback orders

* fix updating rerun collections

* update changelog; fix editing

* update changelog
2025-09-15 18:27:55 +00:00
Jason Dove fe5dd80f70 prep for release v25.6.0 [no ci] 2025-09-14 12:54:06 -05:00
Jason DoveandGitHub 8155e2e441 add shuffle in order for collections in playlists (#2417) 2025-09-14 13:08:42 +00:00
Jason DoveandGitHub 307b9dadd2 partial v4l2m2m accel support (#2416)
* start to add v4l2m2m accel

* add v4l2m2m pipeline

* add encoders

* fix decoders and encoders

* output software frames from decoders

* more buffers

* hide v4l2m2m from ui
2025-09-14 11:48:24 +00:00
Jason DoveandGitHub 5379a893f7 generate fake epg data for graphics elements when troubleshooting (#2415) 2025-09-14 03:01:16 +00:00
Jason DoveandGitHub 64bb1b0d61 fix classic schedule flood bug (#2414)
* clean up some logging

* fix classic schedule flood bug
2025-09-14 02:36:50 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
89968f722a Bump jetbrains.resharper.globaltools from 2025.2.0 to 2025.2.1 (#2378)
---
updated-dependencies:
- dependency-name: jetbrains.resharper.globaltools
  dependency-version: 2025.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-13 15:11:19 +00:00
Jason DoveandGitHub bc721755f5 add graphics elements to decos (#2413)
* add deco graphics elements, selector and tests

* add migrations

* edit deco graphics elements from ui

* update changelog
2025-09-13 15:02:48 +00:00
Jason DoveandGitHub 9182a8ad18 cleanup graphics element loading (#2412) 2025-09-13 13:39:20 +00:00
Jason DoveandGitHub c5265943f5 fix inefficient database migration (#2411) 2025-09-13 03:42:54 +00:00
Jason DoveandGitHub 07a55da76e process graphics element yaml files with scriban (#2410)
* add content rating to media item template

* process graphics element yaml files with scriban
2025-09-13 03:04:46 +00:00
Jason DoveandGitHub 3722bc8c9c add some database startup logging (#2409)
* add some database startup logging

* fix unscheduled playout gap offset

* fix database logging
2025-09-13 00:50:47 +00:00
87bc779d48 Show fillers in the playout view in alternative shading (#2405)
* Add shading to filler rows in the playout view

* Insert rows in Playout listing for gaps in the playout (station offline)

* Make FillerKind in PlayoutItemViewModel optional.
Remove Unscheduled enum in FillerKind.

* Correctly handle "Show Filler" also for Unscheduled fillers.
* Moved the Unscheduled item generation for the playout view to GetFuturePlayoutItemsByIdHandle to handle ShowFiller
* Includes for the PlayoutItemDetails moved to an extension for maintainability.
* Bugfix: Page size was more than the desired for pagination because of the inserted unscheduled items.

* Add specified colours for playout fillers to make them less intense.

* use common queryable

* add playout gap model and migrations

* insert playout gaps after playout build

* optimize get future playout items handler

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-09-13 00:30:34 +00:00
Jason DoveandGitHub f124554fba add some debug logging for music video scanning (#2408) 2025-09-12 16:56:29 +00:00
Jason DoveandGitHub 17c7774603 add playlist item count and shuffle playlist items (#2407)
* marathon cleanup

* add playlist item count, and shuffle playlist items
2025-09-12 14:19:05 +00:00
Jason DoveandGitHub 4e065fe922 update dependencies (#2406) 2025-09-11 18:04:48 +00:00
Jason Dove 2cca3123aa fix issue [no ci] 2025-09-11 09:43:52 -05:00
Jason Dove dabc67976a update github issues 2025-09-11 09:41:46 -05:00
Jason DoveandGitHub bd6954121f log api requests (#2404) 2025-09-10 00:50:03 +00:00
Jason DoveandGitHub a2fd23a131 fix deco selection logic (#2403) 2025-09-09 18:07:50 +00:00
Jason DoveandGitHub 388623f82e fix changing playout source from mirror to generated (#2402) 2025-09-09 17:19:00 +00:00
Jason DoveandGitHub 6b275f8a13 fix hwaccel health check on mobile (#2401)
* fix hw accel health check on mobile

* allow classic schedules to fast forward
2025-09-09 16:57:49 +00:00
Jason DoveandGitHub 0d69dd58a4 add classic schedule marathon (#2400) 2025-09-09 15:38:07 +00:00
Jason DoveandGitHub 79e8fa0877 ignore specials when using season, episode order (#2399) 2025-09-08 15:12:51 +00:00
Jason DoveandGitHub 044c8b7ad3 fix graphics engine with scaling behavior crop (#2398) 2025-09-08 13:36:13 +00:00
Jason DoveandGitHub e8b51e8442 fix watermarks and graphics when using mid-roll (#2397) 2025-09-08 10:59:25 +00:00
Jason DoveandGitHub d8e8abb691 channel mirror validation improvements (#2396)
* improve channel mirror validation

* fix playout offset
2025-09-08 01:12:58 +00:00
Jason DoveandGitHub e9093d0c48 fix block playout build crash from empty collections (#2395) 2025-09-08 00:42:02 +00:00
cb78b21d1c add support for aif, aifc, aiff (#2325)
* adds .aiff to supported audio file extension in local folder scanner

* add support for aif, aifc, aiff

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-09-07 19:24:43 +00:00
Jason DoveandGitHub 906ec44a6e fix scripted pre-roll overlap (#2394)
* fix scripted pre-roll overlap

* cleanup
2025-09-07 18:36:10 +00:00
Jason DoveandGitHub e96ac0202b add mirror playout offset (#2391) 2025-09-07 16:45:41 +00:00
Jason DoveandGitHub 5e7da19e5e add channel mirror (#2390)
* add channel playout source (doesn't do anything yet)

* configure mirror channel

* fix mirror playback

* sync epg for mirror channel

* update changelog
2025-09-07 16:00:27 +00:00
Jason DoveandGitHub e25b669cc4 fix scaling content in certain locales (#2389) 2025-09-07 14:20:11 +00:00
Jason DoveandGitHub d80c6737a9 fix mysql permissions (#2388) 2025-09-07 13:21:48 +00:00
Jason DoveandGitHub ef5de99f9c add create_playlist, pre_roll_on, pre_roll_off to scripted schedules (#2387) 2025-09-06 20:47:44 +00:00
Jason DoveandGitHub e047812a68 rework some scripted schedule model names (#2386) 2025-09-06 18:52:34 +00:00
Jason DoveandGitHub 3da5144a0d update entrypoint so reset_playout is not required to return an updated context (#2385) 2025-09-06 16:22:07 +00:00
Jason DoveandGitHub 31355ab887 add pad_until_exact and wait_until_exact scripted schedule calls (#2384) 2025-09-06 15:43:07 +00:00
Jason DoveandGitHub 63bac272cd fix adding classic schedule items (#2383) 2025-09-06 00:15:05 +00:00
Jason DoveandGitHub 8d5a208129 fix adding single items to playlists (#2382) 2025-09-05 18:44:08 +00:00
Jason DoveandGitHub 487d99dc69 openapi improvements (#2381)
* generate openapi definitions as separate build task

* first defns

* install etv-client module in docker

* include python entrypoint in docker

* update changelog
2025-09-05 17:11:43 +00:00
Jason DoveandGitHub 40ab7c2cff add graphics elements on classic schedule items (#2380) 2025-09-05 11:53:29 +00:00
Jason DoveandGitHub a6de96c2ea fix openapi client generation again (#2379) 2025-09-05 10:52:34 +00:00
Jason DoveandGitHub 0d82e0234b fix scripted schedule validation (#2377) 2025-09-05 01:27:16 +00:00
Jason DoveandGitHub 1e9e41b808 use tagged openapi for docs; no tags for client (#2376) 2025-09-05 01:01:28 +00:00
Jason DoveandGitHub 57e9b4d264 more api docs (#2375) 2025-09-04 18:00:48 +00:00
Jason DoveandGitHub 73b8d68a09 add existing api endpoints to scalar docs (#2374) 2025-09-04 16:36:26 +00:00
Jason DoveandGitHub 03921e1ff7 more docs reorganization (#2373) 2025-09-04 15:20:02 +00:00
Jason DoveandGitHub 70aae67873 reorganize scalar docs (#2372) 2025-09-04 14:59:17 +00:00
Jason DoveandGitHub 67cb931a47 fix verbs, add scalar docs (#2371) 2025-09-04 14:01:16 +00:00
Jason DoveandGitHub 704c1ec535 return context from scripted schedule api calls that modify the context (#2370) 2025-09-04 13:42:00 +00:00
Jason DoveandGitHub 06332c8360 allow custom scripted schedule arguments (#2368) 2025-09-03 21:39:30 +00:00
Jason DoveandGitHub 03b4419f67 rework scripted schedules (#2367)
* start to reorganize scripted playout building

* add openapi

* add all content fns

* add playout instructions

* add control instructions

* add request models

* prevent build loop

* rename

* update changelog

* tweak changelog
2025-09-03 20:53:14 +00:00
Jason DoveandGitHub 7ac93c6aad fix transcoding bt709/pc (#2363) 2025-09-02 18:28:09 +00:00
Jason Dove 6ca72baa00 prep for release v25.5.0 [no ci] 2025-09-01 19:59:13 -05:00
Jason DoveandGitHub 6b953ab5ca fix long season placeholder text (#2362) 2025-09-02 00:35:22 +00:00
Jason DoveandGitHub 272f528f7a fix segmenter v2 with videotoolbox accel (#2361)
* fix segmenter v2 with videotoolbox

* more capabilities checks
2025-09-01 13:35:37 +00:00
Jason DoveandGitHub 07c1156a63 update yaml schema for new pad_to_next fields (#2360) 2025-09-01 01:53:17 +00:00
Jason DoveandGitHub eadacc7f8c add stop_before_end and offline_tail to pad_to_next (#2359) 2025-08-31 23:03:46 +00:00
Jason DoveandGitHub 380070731a startup improvements (#2356)
* redirect to index when initializing

* clear stale sqlite migration lock on startup
2025-08-30 13:16:54 +00:00
Jason DoveandGitHub 7720e6ba39 fix hls segmenter v2 with amf accel (#2355) 2025-08-29 20:01:31 +00:00
Jason DoveandGitHub 8a1cf72209 more alternate schedule fixes (#2354)
* always start with the first schedule item

* reset program schedule items to zero-based index on save

* log offline gaps from strict start times
2025-08-28 14:48:39 +00:00
Jason DoveandGitHub b9759c983c fix alternate schedule transitions in classic schedules (#2353) 2025-08-27 21:31:48 +00:00
Jason DoveandGitHub 9462156148 fix mysql playout builds (#2352)
* more cancellation tokens and fixes

* so much cancellation token

* fix mysql playout builds
2025-08-27 18:09:56 +00:00
Jason DoveandGitHub 1c07df5bc3 use cancellation tokens in many places (#2350)
* use cancellation tokens everywhere

* more cancellation tokens
2025-08-27 03:20:35 +00:00
Jason DoveandGitHub a6198892f0 more mysql ui fixes (#2349) 2025-08-26 03:02:40 +00:00
Jason DoveandGitHub 02a91c4e14 fix editing remote libraries with mysql/mariadb (#2348) 2025-08-26 01:44:59 +00:00
Jason DoveandGitHub b62a76d339 fix mysql migrations (#2347) 2025-08-26 00:40:19 +00:00
Jason DoveandGitHub d9f2f51aee fix fallback filler playback (#2346) 2025-08-25 21:56:00 +00:00
Jason DoveandGitHub 8e77330781 timeout all scripted playout builds (#2345)
* check for progress in is_done

* timeout all scripted playout builds
2025-08-25 18:15:08 +00:00
Jason DoveandGitHub 66c28e9b5f rework scripted schedule signatures; add start_time and finish_time (#2344) 2025-08-25 17:07:14 +00:00
Jason DoveandGitHub 51ec84c94a fix block playout history regression (#2343)
* minor tweaks

* fix block change detection bug

* history cleanup cleanup
2025-08-25 16:09:20 +00:00
Jason DoveandGitHub a072e4357e add scripted add_all, add_duration, pad_to_next, pad_until (#2342)
* add add_all

* add add_duration

* add pad_to_next

* add pad_until
2025-08-24 17:49:18 +00:00
Jason DoveandGitHub 605c57bef3 add scripted control instructions (#2341)
* add start_epg_group, stop_epg_group

* fix imports

* add graphics_on, graphics_off

* add skip_items

* add skip_to_item

* add watermark_on, watermark_off
2025-08-24 16:14:43 +00:00
Jason DoveandGitHub 4e2310d008 add all content sources to scripted schedules (#2340)
* add show content

* add multi collection content

* add smart collection content

* add playlist content

* fix infinite loop

* add marathon content
2025-08-24 14:39:34 +00:00
Jason DoveandGitHub 61a99c250a expose current_time as a python datetime (#2339) 2025-08-24 11:58:37 +00:00
Jason DoveandGitHub bbddd50f00 add new scheduling engine, basic scripted schedule system (#2337)
* start to add content to scheduling engine

* add first content instruction

* add search content

* allow scripted schedule creation

* don't use scheduling engine in sequential playout builder, yet
2025-08-24 03:11:58 +00:00
Jason DoveandGitHub 53f281ce32 add xmltv block behavior setting (#2336)
* replace playout externaljsonfile and templatefile with schedulefile

* add scripted schedule-based playout

* wip - not functional yet

* temp disable scripted playout creation

* allow fast-forwarding block playouts

* add xmltv block behavior setting
2025-08-23 20:16:12 +00:00
Jason DoveandGitHub e06ee54070 rename yaml playout to sequential schedule (#2335)
* clarify some schedule and playout terms

* more renaming
2025-08-23 14:27:32 +00:00
Jason DoveandGitHub af23c6d541 copy watermark overrides when copying schedule (#2334) 2025-08-23 12:51:04 +00:00
Jason DoveandGitHub 988ed8db04 fix changing default alternate schedule (#2331) 2025-08-18 15:37:21 +00:00
Jason DoveandGitHub 31c18162e1 add deco watermark mode merge (#2330) 2025-08-18 11:42:27 +00:00
Jason DoveandGitHub 0318e71745 refactor watermark selection (#2328)
* move watermark options into watermark selector

* fix graphics engine overlay performance

* more refactoring

* add some tests for existing watermark selector behavior

* remove extra ffprobe call on all watermarks

* remove a bunch of unused code; add failing tests

* implement new watermark selection

* add tests for new selector

* probably sufficient (though verbose) test coverage

* more tests

* remove some unused code

* simplify watermark selection

* remove old selection code and tests

* more tests
2025-08-18 01:04:36 +00:00
Jason DoveandGitHub 1e7f9a5709 fix saving yaml playout history (#2327)
* fix saving yaml playout history

* cleanup
2025-08-17 01:21:07 +00:00
Jason DoveandGitHub 330195d5e3 fix seeking into extracted text subtitles (#2326) 2025-08-17 00:23:51 +00:00
Jason DoveandGitHub 5d081ceeff fix editorconfig and run code cleanup (#2324)
* fix formatting rules

* reformat ersatztv

* reformat ersatztv.application

* reformat ersatztv.core

* refactor ersatztv.core.tests

* reformat ersatztv.ffmpeg

* reformat ersatztv.ffmpeg.tests

* reformat ersatztv.infrastructure

* cleanup infra mysql

* cleanup infra sqlite

* cleanup infra tests

* cleanup ersatztv.scanner

* cleanup ersatztv.scanner.tests

* sln cleanup

* update dependencies
2025-08-16 14:44:48 +00:00
Jason DoveandGitHub 6d32dac51b fix graphics engine opacity (#2323)
* fix skia opacity wip

* fix graphics engine opacity
2025-08-16 02:59:07 +00:00
Jason DoveandGitHub 4f02bedf69 fix image loading regression in graphics engine (#2322) 2025-08-15 21:30:54 +00:00
Jason DoveandGitHub d71443ef60 add subtitle graphics element (#2321) 2025-08-15 19:48:04 +00:00
Jason DoveandGitHub d5608ac75f multiple bug fixes (#2320)
* fix incorrect media counts in local libraries

* completely replace imagesharp with skiasharp

* fix song troubleshooting playback

* fix usings
2025-08-15 16:06:55 +00:00
Jason DoveandGitHub a6b01cbe28 convert graphics engine from imagesharp to skiasharp (#2319)
* use skiasharp in graphics engine

* start to use richtextkit

* move out some template functions

* move files

* add base graphics element

* use default style in text element

* support partial styling in text element

* fix static images

* load fonts from text element definition
2025-08-15 14:27:22 +00:00
d0af507bef add ability to deep scan just a single tv show for Plex, Emby, and Jellyfin (#2318)
* add ability to deep scan just a single tv show for Plex, Emby, and Jellyfin

Including "/api/libraries/{id:int}/scan-show" REST API endpoint to
trigger.

* restrict plex search results to the intended library

* restrict scanning to media server libraries that are marked to sync with etv

* fix previous commit

* also guard library scan api

* add scan buttons to show ui

* scan single plex show by id

* scan jellyfin and emby single shows by id

* update changelog

---------

Co-authored-by: Jeff Slutter <MrMustard@gmail.com>
Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-08-14 16:07:56 +00:00
f626954eb7 add external chapter file scanning (#2317)
* add external chapter file scanning

Support Matroska chapter xml files next to media file with extension .xml or .chapters

* only update chapters in db

---------

Co-authored-by: Jeff Slutter <MrMustard@gmail.com>
Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-08-14 03:34:47 +00:00
Jason DoveandGitHub 62e140ec98 block scheduling ui cleanup (#2316)
* sort block tree views

* fix naming validation for block scheduling

* show deco group name in deco editor

* show block group name in block editor

* show template group name in template editor

* show deco template group name in deco template editor

* fix template rename crash

* fix block rename crash

* fix deco template rename crash
2025-08-14 01:37:21 +00:00
Jason DoveandGitHub 93bb7a0531 skip unused hwaccel with amf (#2315) 2025-08-13 22:03:52 +00:00
Jason DoveandGitHub f31a48c429 fix episodes from multiple plex servers (#2314) 2025-08-13 20:56:27 +00:00
Jason DoveandGitHub 0841bc400b fix saving watermarks and graphics on playout items (#2313) 2025-08-13 19:37:46 +00:00
Jason DoveandGitHub 8cc0d30c0e add some template helper functions for text elements (#2312) 2025-08-13 18:50:04 +00:00
Jason DoveandGitHub 4b18ee6b66 add custom stream selector content_condition (#2311) 2025-08-13 16:34:17 +00:00
Jason DoveandGitHub 558e2ce333 rename opacity to opacity_percent for consistency (#2310) 2025-08-13 15:21:25 +00:00
Jason DoveandGitHub c9e6e601c2 automatically refresh graphics elements (#2309) 2025-08-13 15:11:14 +00:00
Jason DoveandGitHub d28d0a9805 fix yaml playout progress (#2308) 2025-08-13 13:21:53 +00:00
Jason DoveandGitHub ac75a67709 block history fixes (#2307)
* fix deco to only have one collection id for filler/fallback

* fix duplicate playout history for deco filler
2025-08-13 01:02:41 +00:00
Jason DoveandGitHub 5e463758da ignore unreliable anamorphic flag from jellyfin (#2306) 2025-08-12 23:32:11 +00:00
Jason DoveandGitHub 2cb0d12701 load a configurable number of epg entries for text graphics (#2305)
* wip

* load a configurable number of epg entries for text graphics

* cleanup
2025-08-12 21:00:55 +00:00
Jason DoveandGitHub 44ec0f8a0f add more template data to text graphics element (#2304) 2025-08-12 14:33:52 +00:00
Jason DoveandGitHub b149f7f2a3 fix overlapping playout items check (#2303) 2025-08-12 11:35:12 +00:00
Jason DoveandGitHub 771bfba01c fix overlapping block playout items (#2302)
* check for overlapping playout items

* tweak block filler builder

* fix overlapping block playout items

* update changelog

* minor cleanup
2025-08-12 11:14:24 +00:00
Jason DoveandGitHub 820c2a5ccc fix watermark validation (#2301) 2025-08-11 18:55:45 +00:00
Jason DoveandGitHub 91c4e8f575 add seek seconds to playback troubleshooting (#2300) 2025-08-11 18:10:36 +00:00
Jason DoveandGitHub a04adf45c0 fix green padding with vaapi i965 driver (#2298) 2025-08-11 17:39:01 +00:00
Jason DoveandGitHub 8cbc3b083a fix placing watermarks within source content (#2297)
* fix placing watermarks within source content

* formatting
2025-08-11 16:02:16 +00:00
Jason DoveandGitHub 1cac210765 fix segmenter v2 transitions (#2296) 2025-08-11 15:00:25 +00:00
Jason DoveandGitHub 6f9952924b fix adding new schedule items (#2295) 2025-08-11 12:56:24 +00:00
Jason DoveandGitHub 1bf5b9567b use graphics engine with segmenter v2 (#2294) 2025-08-11 11:56:48 +00:00
Jason DoveandGitHub a9f2037648 cleanup some unused watermark references (#2293) 2025-08-11 03:02:57 +00:00
Jason DoveandGitHub 03c5b7e664 refactor some tests; upgrade dependencies (#2292)
* refactor some tests

* upgrade dependencies

* disable new test
2025-08-11 00:17:01 +00:00
Jason DoveandGitHub 0e7ec6e3b9 fix qsv transitions when remote streaming (#2291) 2025-08-10 11:47:47 +00:00
Jason DoveandGitHub 3f247288d3 fix on demand for block and yaml schedules (#2290) 2025-08-10 00:50:59 +00:00
Jason DoveandGitHub df0801f2c6 add image graphics element (#2288) 2025-08-09 17:42:23 +00:00
Jason DoveandGitHub 908125f8a9 allow selecting multiple watermarks on decos (#2287)
* load fonts on demand

* add new table

* populate new table

* edit and use multiple watermarks in deco

* remove old field

* update changelog
2025-08-09 17:00:12 +00:00
Jason DoveandGitHub 942cf9e225 allow selecting multiple watermarks on schedule items (#2286)
* add and populate new table

* add watermark multiselect

* remove old column

* update changelog

* fix tests
2025-08-09 13:53:37 +00:00
Jason DoveandGitHub 075f3fcac7 pass music video variables to text element (#2285)
* pass music video variables to text element

* remove unused file
2025-08-09 01:29:20 +00:00
Jason DoveandGitHub f4eadae8ff set variables from yaml playout graphics_on instruction (#2284) 2025-08-08 23:02:13 +00:00
Jason DoveandGitHub 2dc5bf58a7 add graphics_on and graphics_off yaml playout instructions (#2283) 2025-08-08 20:22:07 +00:00
Jason DoveandGitHub 76a589b538 add text graphics element to playback troubleshooting (#2282)
* refactor graphics engine; async frame generation

* add text graphics element to playback troubleshooting
2025-08-08 19:18:15 +00:00
Jason DoveandGitHub 9f3db05c17 fix graphics engine on vaapi (#2281) 2025-08-08 14:15:46 +00:00
Jason DoveandGitHub 7ca2763109 allow multiple watermarks in playback troubleshooting (#2280) 2025-08-08 11:33:12 +00:00
Jason DoveandGitHub 14539d00d4 add watermark z-index (#2279) 2025-08-08 00:43:00 +00:00
Jason DoveandGitHub bd09f3dfdc fix block filler progression (#2278) 2025-08-07 21:18:45 +00:00
Jason DoveandGitHub 0c22eefad2 fix block playout progression (#2277) 2025-08-07 21:11:49 +00:00
Jason DoveandGitHub 2f06e5b6f7 add linear fade functions to watermark opacity expression (#2276)
* add linear fade functions to watermark opacity expression

* cleanup
2025-08-07 20:46:16 +00:00
Jason DoveandGitHub f9db92d5e6 add content_total_seconds to watermark opacity expression (#2275) 2025-08-07 19:56:56 +00:00
Jason DoveandGitHub f2b6f5b919 enable graphics engine in playback troubleshooting (#2274)
* enable graphics engine in playback troubleshooting

* fix text subtitles with graphics engine (watermarks)
2025-08-07 18:37:55 +00:00
Jason DoveandGitHub c7fcaf8886 refactor playout building (#2273)
* refactor playout building

* remove playout items
2025-08-07 15:20:26 +00:00
Jason DoveandGitHub 5a5c049835 support multiple watermarks in yaml schedules (#2267)
* add multiple watermarks per playout item

* fixes

* update yaml playout watermark to support multiple watermarks

* use graphics engine for intermittent watermarks
2025-08-06 21:22:20 +00:00
Jason Dove a28f40e14b remove debug log 2025-08-06 13:27:33 -05:00
Jason DoveandGitHub a2fc99229e add watermark opacity expression (#2266)
* add watermark opacity expression

* implement watermark opacity expression parameters

* minor fixes
2025-08-06 18:26:44 +00:00
Jason DoveandGitHub 036b6e63c7 add new graphics engine (#2265)
* spike new graphics engine

* fix remote watermarks; add graphics engine to vaapi

* add graphics engine to qsv
2025-08-06 15:04:43 +00:00
Jason Dove fd7c3fc25a prep for release v25.4.0 [no ci] 2025-08-05 12:08:31 -05:00
Jason DoveandGitHub 93dca6e0e0 fix framerate check for remote streams (#2264) 2025-08-05 14:35:13 +00:00
Jason DoveandGitHub e34368bf07 fix some yaml schema oneOf => anyOf (#2263) 2025-08-05 13:35:26 +00:00
Jason DoveandGitHub a4b485f562 add yaml validation tool (#2259)
* reorganize troubleshooting page

* add yaml troubleshooting tool
2025-08-05 03:07:26 +00:00
Jason DoveandGitHub 6159b6a5b2 support more music video thumbnail filenames (#2258) 2025-08-04 23:17:16 +00:00
Jason DoveandGitHub 11100a788b fix yaml guid validation (#2257) 2025-08-04 22:22:37 +00:00
Jason DoveandGitHub b40ac9ef52 replace channel active mode with is enabled and show in epg (#2256)
* add channel enabled setting

* remove channel active mode
2025-08-04 21:24:26 +00:00
Jason DoveandGitHub c055e59723 add channel transcode mode and idle behavior (#2255)
* add channel transcode mode and idle behavior

* allow custom_title on all yaml content instructions
2025-08-04 20:25:31 +00:00
Jason DoveandGitHub b52159e8db rename channel progress mode to playout mode (#2254) 2025-08-04 19:27:22 +00:00
Jason DoveandGitHub a728c5e31e add smart collection editor to support renaming (#2253) 2025-08-04 16:24:08 +00:00
Jason DoveandGitHub 61ce1bad08 always schedule full duration (#2252) 2025-08-04 15:31:04 +00:00
Jason DoveandGitHub ab2b926de0 add searching log category (#2251) 2025-08-04 14:51:13 +00:00
Jason DoveandGitHub 3b955255ce fix building yaml playouts with no imports (#2249) 2025-08-04 03:43:30 +00:00
Jason DoveandGitHub 16dd2c2d81 add yaml import section (#2248) 2025-08-04 02:21:56 +00:00
48f93b8af8 Support individual chapters as filler (#2208)
* Use chapters in duration filler

* add new option, migrations, and update filler preset editor

* Revert "Use chapters in duration filler"

This reverts commit d87a8a240a78c1cbca7b311125f8d3a84645d296.

* scaffold splitting filler by chapter

* implement chapters as filler

* update changelog

* re-add migrations

* Add duration for ChapterMediaItem

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2025-08-04 00:18:14 +00:00
Jason DoveandGitHub 8b12ee459a fix transitions on nvidia, vaapi, qsv (#2247) 2025-08-03 20:26:16 +00:00
Jason DoveandGitHub b3d0b44e77 fix qsv transitions (#2246)
* fix qsv transitions

* revert unintended change
2025-08-03 16:01:48 +00:00
Jason DoveandGitHub 163fd0c1f3 restore noautoscale in nvidia pipeline (#2245) 2025-08-03 14:57:30 +00:00
Jason DoveandGitHub b6ec16c6a7 fix transitions using nvidia accel (#2244) 2025-08-03 14:14:36 +00:00
Jason DoveandGitHub aa3bd3b750 add yaml playout rewind instruction (#2243) 2025-08-03 13:29:44 +00:00
Jason DoveandGitHub f04b7ead09 fix yaml playout builds (#2241) 2025-08-03 01:18:27 +00:00
Jason DoveandGitHub 8921273900 detect some videotoolbox decoders (#2240) 2025-08-02 18:38:49 +00:00
Jason DoveandGitHub 0489741123 add videotoolbox capabilities (#2239)
* implement videotoolbox hardware capabilities

* add videotoolbox troubleshooting info

* update changelog
2025-08-02 17:16:24 +00:00
Jason Dove c3e882085b remove extra windows Resources folder 2025-08-02 10:08:45 -05:00
Jason Dove 3ab9112c15 fix folders in windows artifact 2025-08-02 09:30:17 -05:00
Jason Dove 33b789db67 remove unneeded commands from windows build 2025-08-02 09:17:14 -05:00
Jason DoveandGitHub ed5206b855 rework windows artifact builds (#2238) 2025-08-02 14:11:54 +00:00
Jason DoveandGitHub baf7aa20d1 build windows artifacts on linux (#2237) 2025-08-02 13:50:54 +00:00
Jason DoveandGitHub 7bd0de99e1 fix gaps in yaml playouts (#2235)
* dont run multiple dotnet builds in background

* fix gaps in yaml playouts
2025-08-02 03:42:42 +00:00
Jason DoveandGitHub 96093c8cc8 build artifacts as background processes (#2234) 2025-08-02 03:27:18 +00:00
Jason DoveandGitHub 8430a3048c fix yaml playout builds after refactor (#2233) 2025-08-02 03:12:37 +00:00
Jason DoveandGitHub 06d9e59a7a add yaml mid roll instruction (#2232)
* refactor filler expression

* add yaml mid roll instruction

* schedule midroll for yaml count and all instructions

* update changelog
2025-08-02 00:16:50 +00:00
Jason DoveandGitHub 9c434079d5 add playlist support to filler preset (#2231) 2025-08-01 19:13:18 +00:00
Jason DoveandGitHub 12c88a006d add yaml post_roll instruction (#2230) 2025-08-01 17:02:36 +00:00
Jason DoveandGitHub f0ca358c2b fully validate yaml playouts (#2229) 2025-08-01 16:20:53 +00:00
Jason DoveandGitHub 093abf7ad8 add yaml playout pre_roll instruction (#2228)
* add yaml playout pre_roll instruction

* add basic yaml validation

* validate all yaml playout content items

* fix yaml to json conversion

* update changelog
2025-08-01 15:20:10 +00:00
Jason DoveandGitHub f768093df7 update dependencies (#2226) 2025-07-31 17:09:39 +00:00
Jason Dove 3830db60bf another small update for a new build 2025-07-31 12:00:36 -05:00
Jason Dove 5984b38ce0 small change to get new build 2025-07-30 16:51:26 -05:00
Jason DoveandGitHub e0175fc4e5 add light mode (#2223) 2025-07-29 18:48:12 +00:00
Jason DoveandGitHub 4f104cff5b some fixes for alternate schedules (#2222) 2025-07-29 11:55:34 +00:00
Jason DoveandGitHub a2f678fe8e fix adding new items from plex libraries (#2220) 2025-07-28 22:55:53 +00:00
Jason DoveandGitHub b3ac0c68a8 fix green padding with 10-bit content on i965 vaapi (#2219) 2025-07-28 21:42:23 +00:00
Jason DoveandGitHub 605d8a98ab fix adding new items from jellyfin and emby (#2218) 2025-07-28 20:37:34 +00:00
Jason DoveandGitHub 00f40c2568 fix migrations for new databases (#2217) 2025-07-28 20:18:08 +00:00
Jason DoveandGitHub 74733a8026 fix duplicate database migration; fix ssa subtitles (#2216) 2025-07-28 19:23:59 +00:00
Jason DoveandGitHub 1df9104854 add subtitle selection to playback troubleshooting (#2215) 2025-07-28 18:44:49 +00:00
Jason DoveandGitHub 6c6ccfa94b fix seeking with text subtitles (#2214) 2025-07-28 16:19:20 +00:00
Jason DoveandGitHub e9d494c24e add troubleshoot playback to media card overflow menu (#2210) 2025-07-27 13:06:57 +00:00
Jason DoveandGitHub deff33c76c fix pad_to_next always running over time (#2207) 2025-07-26 20:22:40 +00:00
Jason DoveandGitHub b5d1839d55 always tell ffmpeg to stop transcoding at duration (#2206) 2025-07-26 19:28:43 +00:00
Jason DoveandGitHub ab0f431c85 fix app startup with mysql (#2205)
* don't run pragma command on mysql

* add not required pathhash

* make media file path hash required

* update changelog
2025-07-26 17:48:03 +00:00
Jason Dove 9511e6e6a7 prep for release v25.3.1 [no ci] 2025-07-24 22:36:56 -05:00
Jason DoveandGitHub 7f2b5ba47f fix fallback filler playback (#2202) 2025-07-25 03:26:56 +00:00
Jason DoveandGitHub 478d19405d remove docker tag suffixes (#2201) 2025-07-25 03:00:26 +00:00
Jason Dove e363ab00bb prep for release v25.3.0 [no ci] 2025-07-24 20:43:49 -05:00
Jason DoveandGitHub dd9a6d5a06 add chapters to search index (#2199) 2025-07-24 21:03:58 +00:00
Jason Dove fde05a0299 fix docker tag typo 2025-07-24 15:29:12 -05:00
Jason DoveandGitHub d3f8163580 use updated ersatztv-ffmpeg base images (#2198) 2025-07-24 20:27:06 +00:00
Jason DoveandGitHub 07e4ff907f include docker-arm in unified image health check (#2196)
* include docker-arm in unified image health check

* update
2025-07-24 20:20:00 +00:00
Jason Dove 34874ac548 try to fix docker manifest step 2025-07-24 15:03:47 -05:00
Jason DoveandGitHub 03e4c0207b use multi-platform docker images (#2195) 2025-07-24 19:56:46 +00:00
Jason DoveandGitHub b9faf87887 don't use arm64 runner for arm32 builds (#2194) 2025-07-24 03:21:21 +00:00
Jason DoveandGitHub 2257d26173 fix some issues with live stream playback (#2193) 2025-07-24 01:58:32 +00:00
Jason DoveandGitHub 8f6d208e31 use arm64 runners for arm builds (#2192)
* use arm64 runners for arm builds

* use matrix for linux builds on prs

* remove unused "kind"
2025-07-23 21:36:20 +00:00
Jason DoveandGitHub 5ccea53131 fix media file path length for mysql (#2191) 2025-07-23 03:10:13 +00:00
Jason DoveandGitHub da6cb09658 fix tonemapping with amd vaapi (#2187)
* fix amd vaapi tonemap

* fixes
2025-07-22 17:35:06 +00:00
Jason DoveandGitHub 260949893c fix some stream continuity issues (#2186) 2025-07-22 15:56:14 +00:00
Jason DoveandGitHub 89b495dc90 qsv and pts fixes (#2184)
* try to fix qsv freezing

* update changelog

* fix unit tests
2025-07-21 19:00:07 +00:00
Jason DoveandGitHub 74d6b32828 change how qsv is initialized on windows (#2183) 2025-07-21 17:23:30 +00:00
Jason DoveandGitHub 626af6876b add start from beginning option to playback troubleshooting (#2182) 2025-07-21 16:17:16 +00:00
Jason DoveandGitHub 2a05cc6e32 add remote stream is_live property (#2181) 2025-07-21 13:19:51 +00:00
Jason DoveandGitHub 7a4c832156 add media card overflow menu (#2180)
* add media card overflow menu

* remove commented code
2025-07-21 11:00:39 +00:00
Jason DoveandGitHub 011f16da9f fix variant selection for hls remote streams (#2177) 2025-07-20 18:24:04 +00:00
Jason DoveandGitHub 79496e688b fix video stream selection for remote streams (#2176) 2025-07-20 17:31:21 +00:00
Jason DoveandGitHub 5c43ae47b1 add basic remote stream library (#2175)
* initial remote stream library support; scanning seems to work ok

* flood schedule remote streams kind of works

* switch remote stream definitions to yaml files

* implement remote stream script playback

* update changelog
2025-07-20 16:10:32 +00:00
Jason DoveandGitHub c29788bc3f add movie nfo country to search index (#2173) 2025-07-19 21:56:13 +00:00
Jason DoveandGitHub 3501e7c8d5 disable multiple mode select when not using playout mode multiple (#2172) 2025-07-19 20:42:05 +00:00
Jason DoveandGitHub 867c88d8fc add trakt playlist option (#2171)
* add generate playlist option; add system playlists

* fix official lists; sync items to playlist
2025-07-19 16:56:25 +00:00
Jason DoveandGitHub 70fbd4c746 add option to auto refresh trakt lists (#2169) 2025-07-19 14:19:07 +00:00
Jason DoveandGitHub 1cbd48cea0 log nfo file name with nfo parsing errors (#2168) 2025-07-19 02:22:06 +00:00
Jason DoveandGitHub c953176cee change watermark width and margins to allow decimals (#2167) 2025-07-18 21:28:32 +00:00
Jason DoveandGitHub e0cef62969 fix block playout epg time zone (#2166) 2025-07-18 17:14:11 +00:00
Jason DoveandGitHub 9e56f6552f support more multi-part grouping names (#2165) 2025-07-18 16:48:08 +00:00
Jason DoveandGitHub 6a84c564d6 add multi-episode group size (#2164) 2025-07-18 14:46:00 +00:00
Jason DoveandGitHub 54be3761dd add multiple mode to schedule items (#2163) 2025-07-18 14:03:56 +00:00
Jason DoveandGitHub cf6b9cf29a enable write-ahead logging on all sqlite databases (#2162) 2025-07-18 11:17:21 +00:00
Jason DoveandGitHub 464c1e2ea8 fix bugs with playout mode multiple (#2160) 2025-07-18 01:53:19 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
107e8cfded Bump Jint and System.CommandLine (#2152)
Bumps Jint from 4.3.0 to 4.4.0
Bumps System.CommandLine from 2.0.0-beta5.25306.1 to 2.0.0-beta6.25358.103

---
updated-dependencies:
- dependency-name: Jint
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
- dependency-name: System.CommandLine
  dependency-version: 2.0.0-beta6.25358.103
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-17 18:23:04 +00:00
Jason DoveandGitHub 837f824660 include hardware info in troubleshooting archive (#2159)
* add cpu and gpu info to troubleshooting general

* include capabilities in troubleshooting archive
2025-07-17 16:23:57 +00:00
Jason DoveandGitHub 223bdff8d6 playback troubleshooting improvements (#2157) 2025-07-17 14:53:11 +00:00
Jason DoveandGitHub 578cdb1e14 add playback troubleshooting tool (#2155)
* support media info for more content types

* add playback troubleshooting page

* reorganize playback troubleshooting

* fix watermarks and delay

* update changelog
2025-07-17 03:51:36 +00:00
Jason DoveandGitHub 848b88bd2d link ffmpeg health check to ersatztv-ffmpeg release (#2154)
* link ffmpeg health check to ersatztv-ffmpeg release

* bump windows ffmpeg to use the same version as linux
2025-07-16 17:13:25 +00:00
Jason DoveandGitHub b85571b159 allow uploading large watermarks (#2151) 2025-07-15 13:51:26 +00:00
Jason DoveandGitHub 43e1cbd919 yaml playout watermarks (#2149) 2025-07-14 03:25:20 +00:00
Jason DoveandGitHub 39b107eb0f add matched_points to filler expression (#2148) 2025-07-13 15:14:13 +00:00
Jason DoveandGitHub 0ee62dbc7d fix recent regression to health check links (#2147) 2025-07-13 13:15:16 +00:00
Jason DoveandGitHub 833bf3506a rework schedule items editor (#2146) 2025-07-13 00:57:27 +00:00
Jason DoveandGitHub cd75046348 rework playlist editor (#2145) 2025-07-12 23:17:47 +00:00
Jason DoveandGitHub 448d29546c rework block editor (#2143) 2025-07-12 19:47:25 +00:00
Jason DoveandGitHub f2c49bd0fd rework alternate schedule and playout template editors (#2142)
* rework alternate schedules editor

* rework playout templates editor
2025-07-12 18:51:44 +00:00
Jason DoveandGitHub 174c743cb7 more mobile layout updates (#2141)
* update trash layout

* cleanup block and yaml playout editors

* spacing cleanup

* rework multi-collection editor

* rework deco template editor

* rework template editor
2025-07-12 15:50:44 +00:00
Jason DoveandGitHub 2a9f23cce6 update layout for group editors (#2140)
* update block group editor

* update playlist group editor

* update template group editor

* update deco group editor

* update deco template group editor

* update deco editor

* update logs layout

* update changelog
2025-07-12 13:45:50 +00:00
Jason DoveandGitHub 451c534062 allow block items to disable watermarks (#2139)
* allow block items to disable watermarks

* fix test
2025-07-12 03:55:18 +00:00
Jason DoveandGitHub e16cb30ab1 add mid-roll filler expression (#2138) 2025-07-12 01:23:16 +00:00
Jason DoveandGitHub e0df454ac6 more layout updates for mobile (#2137)
* update trakt, filler, filler editor ui

* update schedules and playouts

* update playout editor

* update dependencies

* update yaml playout editor

* update path replacement editor
2025-07-11 21:08:37 +00:00
Jason DoveandGitHub e79a03b522 fix displaying local tv artwork in ui (#2136) 2025-07-11 14:44:16 +00:00
Jason DoveandGitHub 1a09bb26d7 lots of mobile updates including detail pages (#2135)
* update artist page layout

* update season page layout

* rework collection view

* cleanup

* update collection editor
2025-07-11 14:21:39 +00:00
Jason DoveandGitHub ffd3e3604c rework many media pages (#2134)
* rework many list pages

* refactor

* rework movie details and season list
2025-07-11 02:47:30 +00:00
Jason DoveandGitHub 7e40a809ff improve search results ui on mobile (#2133)
* show brief health check messages on mobile

* update libraries layout

* improve search results ui on mobile
2025-07-10 19:19:34 +00:00
Jason DoveandGitHub cecf18a7b5 improve mobile layout for some media source pages (#2132) 2025-07-10 17:28:51 +00:00
Jason DoveandGitHub 7df33425fa improve health check display on mobile (#2131) 2025-07-10 15:23:10 +00:00
Jason DoveandGitHub 5dfaa1a7ad improve mobile layout for some pages with tables (#2130) 2025-07-10 14:38:17 +00:00
Jason DoveandGitHub 28a65e74bb use new form layout for local library editor (#2129) 2025-07-10 11:48:20 +00:00
Jason DoveandGitHub 4a66f0ae43 use new form layout for watermark editor (#2127)
* use new form layout for watermark editor

* cleanup
2025-07-09 21:24:03 +00:00
Jason DoveandGitHub fb2466d32d vaapi tonemap fixes (#2125) 2025-07-07 20:58:45 +00:00
Jason DoveandGitHub beaaa62ed9 fix nvidia edge case with missing bit depth info (#2123)
* fix nvidia edge case with missing bit depth info

* revert docker-compose changes
2025-07-07 16:31:11 +00:00
Jason DoveandGitHub 0b445f8cfd cache bust more css (#2119) 2025-07-07 02:50:34 +00:00
Jason DoveandGitHub 7e30444857 dependencies and code cleanup (#2117)
* fix validation in new form layout

* pin mediatr to last oss version

* update dependencies

* cleanup code in core

* cleanup code in ffmpeg

* cleanup code in infra

* cleanup code in scanner

* cleanup code in application

* cleanup main code

* cleanup test code

* solution-wide code cleanup
2025-07-06 15:56:17 +00:00
Jason DoveandGitHub fa6a31b4fc use new form layout for schedule editor (#2116) 2025-07-06 11:32:11 +00:00
Jason DoveandGitHub b01ad9dbae restore incorrectly deleted file (#2114) 2025-07-05 17:40:00 +00:00
Jason DoveandGitHub d324967afa use new form layout for ffmpeg profile editor (#2113) 2025-07-05 13:07:01 +00:00
Jason DoveandGitHub aff4fb0deb use new form layout for channel editor (#2112) 2025-07-05 11:20:53 +00:00
Jason DoveandGitHub 93afcd2f57 more settings updates (#2111)
* update logging settings layout

* update hdhomerun settings layout

* update scanner settings layout

* update playout settings layout

* update xmltv settings layout

* update changelog
2025-07-05 00:45:32 +00:00
Jason DoveandGitHub 921a108684 ui updates (#2109)
* split settings into multiple pages

* show health check badge in nav menu

* undo transcoding test changes
2025-07-04 18:20:22 +00:00
Jason DoveandGitHub a6fa93d44e fix nvidia compatibility with ffmpeg 7.2+ (#2108)
* tweak random seed

* fix dotnet install in docker test

* fix nvidia compatibility with ffmpeg 7.2+
2025-07-03 15:08:18 +00:00
Jason DoveandGitHub a42234a7c3 update plex plot during deep scan (#2105) 2025-07-02 17:14:21 +00:00
Jason DoveandGitHub 7c5137a4af remove some decode threading limits (#2103) 2025-07-02 00:34:07 +00:00
Jason DoveandGitHub 5a9d27e196 make yaml playout count an expression (#2102) 2025-07-01 16:24:11 +00:00
Jason DoveandGitHub cd4a9c1d16 fix hdhr endpoint classification (#2101) 2025-07-01 15:13:16 +00:00
Jason DoveandGitHub f6249d9fa4 channel logo and watermark fixes (#2100)
* channel logo and watermark fixes

* update changelog
2025-07-01 13:40:30 +00:00
Jason DoveandGitHub e2ffa70529 support episodedetails and musicvideo as top-level other video nfo tags (#2098) 2025-07-01 02:59:18 +00:00
Jason DoveandGitHub 3e07bc6136 fix history for playlists in yaml playouts (#2097) 2025-07-01 00:47:53 +00:00
Jason DoveandGitHub d6bfc2fd05 marathon playout history fixes (#2096) 2025-06-30 21:39:31 +00:00
Jason DoveandGitHub 35116c64cd fix potential crash with recent marathon updates (#2095) 2025-06-30 19:23:31 +00:00
Jason DoveandGitHub 037cee873f yaml marathon history (#2094)
* better playlist tests

* fix history for marathon content in yaml playouts
2025-06-30 19:05:09 +00:00
Jason DoveandGitHub cd28afcd91 dont reload appsettings.json at runtime (#2093)
* dont reload appsettings.json at runtime

* also disable here
2025-06-29 17:15:34 +00:00
Jason DoveandGitHub 7457301d3e yaml playout skip items expression (#2092) 2025-06-29 14:31:56 +00:00
Jason DoveandGitHub 7b7d378df7 run all mac builds on macos-14 (#2091) 2025-06-29 13:35:57 +00:00
Jason DoveandGitHub f6dcaf9108 fix qsv audio sync (#2090) 2025-06-29 03:49:04 +00:00
Jason DoveandGitHub 6cc2f1de17 yaml playout improvements (#2088)
* add stop_before_end

* more yaml playout improvements
2025-06-28 16:27:58 +00:00
Jason DoveandGitHub c6ee41484e allow other videos and images to use the same folders (#2087) 2025-06-28 14:50:33 +00:00
Jason DoveandGitHub 36d38c740f only scan plex networks on plex show libraries (#2086) 2025-06-28 12:56:49 +00:00
Jason DoveandGitHub 0f795e4e2f add plex network metadata (#2085)
* initial plumbing

* scan for plex networks

* save network contents to db as tags

* eliminate network tag id churn

* add network and show_network to search index

* update last networks scan

* show networks on tv show page

* update changelog
2025-06-28 12:49:26 +00:00
Jason DoveandGitHub 583cbf7b14 add channel active mode (#2083) 2025-06-27 21:19:26 +00:00
Jason DoveandGitHub 27c701b936 fix software tonemap with nvidia (#2082) 2025-06-27 20:47:09 +00:00
Jason DoveandGitHub 6e2c19d354 process missing language as und (#2081) 2025-06-27 14:45:30 +00:00
Jason DoveandGitHub 4d83dc019c don't return stream selection when subtitles don't match (#2080) 2025-06-27 14:25:07 +00:00
Jason DoveandGitHub 462057a4b1 prioritize stream selection by language (#2079) 2025-06-27 13:31:50 +00:00
Jason DoveandGitHub a04c72788f fix arm64 docker build (#2078) 2025-06-27 11:48:21 +00:00
Jason DoveandGitHub f94a440b62 stream selector improvements (#2077)
* add tests for audio blocklist and audio allowlist

* add subtitle allow list and block list

* add subtitle condition

* add audio condition

* cache bust mudblazor css
2025-06-27 11:40:06 +00:00
Jason DoveandGitHub f80069bb97 add custom channel stream selector system (#2076)
* add some basic channel stream selector models

* change windows ffmpeg url

* implement basic stream selection

* fixes
2025-06-27 03:00:59 +00:00
Jason DoveandGitHub c2769a08b4 stop building hwaccel-specific images (#2075)
* stop building hwaccel images

* update changelog
2025-06-26 17:26:11 +00:00
Jason Dove e679fee940 update CHANGELOG for clarity [no ci] 2025-06-24 19:11:04 -05:00
Jason Dove 2271d5497b prep for release v25.2.0 [no ci] 2025-06-24 12:03:56 -05:00
Jason DoveandGitHub f71b6527c0 allow external channel logo urls (#2067) 2025-06-24 00:43:24 +00:00
Jason DoveandGitHub 20d2fe71cd update macos app (#2066) 2025-06-23 21:19:42 +00:00
Jason DoveandGitHub 1994f171d5 update windows launcher to respect ETV_UI_PORT (#2065) 2025-06-23 20:15:03 +00:00
Jason DoveandGitHub 76f7c88375 add etv env vars to troubleshooting > general info (#2064) 2025-06-23 14:54:06 +00:00
Jason DoveandGitHub 3805b9e48c update dependencies that require api migrations (#2063)
* migrate system.commandline to 2.0.0-beta5 api

* bump sixlabors.imagesharp

* migrate skiasharp to 3.x api
2025-06-23 13:49:24 +00:00
Jason DoveandGitHub 9267edbcc9 fix scanning libraries (#2062) 2025-06-23 13:09:14 +00:00
Jason DoveandGitHub c4c164df6a detect cycles in yaml sequence definitions (#2060) 2025-06-23 02:55:35 +00:00
Jason DoveandGitHub b90463e3af detect cycles in smart collection queries (#2059) 2025-06-23 01:45:17 +00:00
Jason DoveandGitHub 1fb27e3cfa fix nuget issues with transitive dependencies (#2058)
* try to fix nuget issues

* another attempt at surfacing warnings

* restore proper runtime

* remove old an unneeded dependencies

* upgrade transitive dep

* use newer dotnet in github
2025-06-22 20:22:37 +00:00
Jason DoveandGitHub 06f233e5bd upgrade to dotnet 9 (#2056) 2025-06-22 19:04:39 +00:00
Jason DoveandGitHub 9917774671 allow searching by smart collection (#2055) 2025-06-22 15:43:12 +00:00
Jason DoveandGitHub 5be929da18 add collection (name) to search index (#2054) 2025-06-22 14:51:17 +00:00
Jason DoveandGitHub 25f4fb22e5 yaml sequence improvements (#2053) 2025-06-21 19:28:41 +00:00
Jason DoveandGitHub b04b517f7b add dockerfile to run transcoding test suite (#2052) 2025-06-21 18:08:23 +00:00
Jason DoveandGitHub d756c0c7c0 properly filter vaapi driver health check to vaapi ffmpeg profile (#2050) 2025-06-21 11:36:02 +00:00
Jason DoveandGitHub 20e5b8a11a add button to clone schedule item (#2048) 2025-06-20 21:52:45 +00:00
Jason DoveandGitHub 5c8489cbed improve vaapi driver health check (#2047)
* improve vaapi driver health check

* fix duplicate check

* cleanup again
2025-06-20 19:16:52 +00:00
Jason DoveandGitHub 7cfa298c72 fix xmltv grouping with post-roll filler (#2046) 2025-06-20 14:53:04 +00:00
Jason DoveandGitHub 4b0faf4da1 unify hardware acceleration in docker (#2045) 2025-06-20 02:52:05 +00:00
Jason DoveandGitHub 07cbf9936b fix shuffle in order and fill with group mode incompatibility (#2044) 2025-06-19 22:10:30 +00:00
Jason DoveandGitHub 2138d6437c use noautoscale when also using hwaccel cuda (#2043) 2025-06-18 17:06:19 +00:00
Jason DoveandGitHub 5b9601a57b maintain cuda pixel format throughout nvidia pipeline (#2042) 2025-06-18 02:59:10 +00:00
Jason DoveandGitHub aeda5050d3 nvidia decoder fixes (#2041)
* replace FluentAssertions with Shouldly

* fix song transcoding tests

* only specify hwaccel when hardware decode is required

* update changelog
2025-06-17 18:26:49 +00:00
Jason DoveandGitHub ea46a7a5ca add tonemap algorithm setting to ffmpeg profile (#2039) 2025-06-15 00:38:37 +00:00
Jason DoveandGitHub 69a1e718df use ffmpeg 7.1.1 for nvidia docker (#2038) 2025-06-14 23:20:22 +00:00
Jason DoveandGitHub 4a59dafe51 optimize tonemapping performance (#2037)
* add env var to disable vulkan

* tonemap after scaling

* vulkan tonemapping still needs to happen before scaling
2025-06-14 21:32:38 +00:00
Jason DoveandGitHub 6b90da8982 add pad_vaapi filter (#2036) 2025-06-14 13:36:24 +00:00
Jason DoveandGitHub 1184dc565c use ffmpeg 7.1.1 for base, arm, arm64 docker (#2035)
* use ffmpeg 7.1.1 for base, arm, arm64 docker

* keep newline
2025-06-14 11:16:30 +00:00
Jason DoveandGitHub d82ccf8fb5 use hardware-accelerated tonemapping with qsv (#2034)
* add tonemap for qsv

* update changelog
2025-06-14 01:19:35 +00:00
Jason DoveandGitHub 4d83cc705f use ffmpeg 7.1.1 for vaapi docker (#2033) 2025-06-14 00:34:17 +00:00
Jason DoveandGitHub f80addacba use the clip algorithm for software tonemapping (#2032) 2025-06-13 20:12:31 +00:00
Jason DoveandGitHub 18c2a816dc use ffmpeg 7.1.1 on windows (#2031) 2025-06-13 13:36:19 +00:00
Jason DoveandGitHub 4f085c1950 fix detecting nvidia capabilities on blackwell gpus (#2030) 2025-06-13 03:14:22 +00:00
Jason DoveandGitHub dad0662fa6 use libplacebo to tonemap with nvidia/vulkan (#2029) 2025-06-12 20:49:06 +00:00
Jason DoveandGitHub dfdfa6f349 use hardware-accelerated tonemapping with vaapi (#2028)
* add tonemap_vaapi filter

* let vaapi pipeline handle hdr content

* use tonemap_opencl with vaapi

* update changelog
2025-06-12 16:13:43 +00:00
Jason DoveandGitHub 5fe3e97b31 add software tonemap filter to support hdr content (#2027) 2025-06-12 14:47:59 +00:00
Jason DoveandGitHub da1cfab5f4 remove jellyfin admin user id requirement (#2025) 2025-06-09 13:33:47 +00:00
Jason DoveandGitHub 6d152e4b4a use more accurate BANDWIDTH value in multi-variant playlist (#2023) 2025-06-07 20:45:07 +00:00
Jason DoveandGitHub 2ca722523b improvements to plex connection management (#2020) 2025-05-31 18:44:55 +00:00
Jason DoveandGitHub d9a3496bf5 add fixed start time behavior option to schedules and schedule items (#2017) 2025-05-30 09:30:09 +00:00
Jason DoveandGitHub c8f5b51d93 use cache busting to avoid ui errors after upgrading mudblazor (#2016) 2025-05-30 00:22:49 +00:00
Jason DoveandGitHub 956734ce39 globalization fixes (#2014)
* fix crashes caused by decimal separator

* improvements to playout reset ui

* remove code quality workflow
2025-05-26 16:32:25 +00:00
Jason DoveandGitHub e44a391f00 fix navigation when using ETV_BASE_URL (#2013) 2025-05-25 15:29:36 +00:00
Jason DoveandGitHub b43b66dd35 start to make ui minimally responsive (#2004) 2025-04-28 21:39:48 +00:00
Jason DoveandGitHub 140a663da4 update dependencies (#2003)
* update dependencies

* more dependency updates
2025-04-28 17:20:45 +00:00
Jason Dove 876d79c11c update changelog [no ci] 2025-04-22 15:34:28 -05:00
Jason DoveandGitHub 57a4480c3f try to push docker images to ghcr.io (#2000) 2025-04-22 20:24:14 +00:00
Jason DoveandGitHub 8f1b57eb88 another attempt at fixing separate ports behind reverse proxy (#1994) 2025-04-14 18:53:25 +00:00
Jason DoveandGitHub 70472ac84e add public port env vars; allow streaming through ui port (#1993) 2025-04-14 18:21:05 +00:00
Jason Dove 6aab8f53b8 fix changelog [no ci] 2025-04-14 11:57:01 -05:00
Jason DoveandGitHub b30b458574 allow ui and streaming to run on different ports (#1992)
* allow ui and streaming to run on different ports

* revert global.json change
2025-04-14 16:48:19 +00:00
Jason DoveandGitHub 6fe6382485 add remote ip and user agent to http request logging (#1990) 2025-04-07 18:57:08 +00:00
Jason DoveandGitHub 100ae0adda add api endpoint to empty trash (#1988) 2025-04-06 14:26:40 +00:00
Jason DoveandGitHub f0d5200843 bump image sharp lib to fix build (#1986) 2025-04-04 02:48:12 +00:00
Jason DoveandGitHub af36218cc2 add linux-musl-x64 artifact (#1985) 2025-04-04 02:45:09 +00:00
Jason DoveandGitHub eca62e8bec fix error synchronizing collections from plex server that has zero collections (#1964) 2025-01-15 21:42:22 -06:00
Jason Dove 03d4ab8c72 no longer mark releases as pre-release [no ci] 2025-01-10 12:26:53 -06:00
Jason Dove 57b590cd0f prep for release v25.1.0 [no ci] 2025-01-10 12:12:39 -06:00
Jason DoveandGitHub f0a5d89f73 fix song progress with 4:3 resolutions (#1961) 2024-12-27 16:09:58 -06:00
Jason DoveandGitHub 17a77694a0 add tag_full field to search index (#1956) 2024-12-09 13:55:47 -06:00
Jason DoveandGitHub 838c2a1661 fix default song background for 4:3 resolutions (#1951)
* fix default song background for 4:3 resolutions

* use 16:9 progress overlay for now
2024-11-28 08:56:38 -06:00
Jason DoveandGitHub 375a306edc fix vaapi capabilities detection when display is not drm (#1949)
* fix vainfo device syntax

* update logging

* don't pass vaapi_device when display is not drm

* fix hw accel

* proper accel fix

* Revert "proper accel fix"

This reverts commit 5dbadb1f79.

* Revert "fix hw accel"

This reverts commit b6b132d7b9.

* Revert "don't pass vaapi_device when display is not drm"

This reverts commit 0459210718.

* still show vaapi device
2024-11-25 14:27:21 -06:00
Jason DoveandGitHub 759052c725 add vaapi display option to ffmpeg profile (#1948)
* add vaapi display option

* fix vaapi capabilities cache key

* update logging
2024-11-25 13:14:21 -06:00
Jason DoveandGitHub dc112f0c7d add default song album art; adjust default blurhashes (#1946)
* fix song progress on white backgrounds

* remove yellow from default song backgrounds

* add default album art for generated song images

* update changelog
2024-11-20 22:27:09 -06:00
Jason DoveandGitHub c8ec87b01f add optional progress bar to generated song videos (#1945)
* optionally include progress bar in generated song video

* update progress bar size/location

* move everything up 10% when song progress is enabled

* add watermark border to song progress bar

* always show accurate progress bar

* lower progress bar to 90% alpha

* update changelog
2024-11-20 15:57:00 -06:00
Jason DoveandGitHub fbb7a661fb fix guide group generation for duration yaml instruction (#1943) 2024-11-19 06:26:19 -06:00
Jason DoveandGitHub 37ceac5651 add advance option to epg_group yaml playout instruction (#1942) 2024-11-18 22:07:21 -06:00
Jason DoveandGitHub 0953e258a5 add rewind_on_reset option wait_until yaml playout instruction (#1941) 2024-11-18 19:31:51 -06:00
Jason DoveandGitHub fdbd8a07b6 update macos runner to macos-13; macos-12 is no longer supported (#1940) 2024-11-18 09:59:37 -06:00
Jason DoveandGitHub 5465c45a51 fix plex collection sync (#1939) 2024-11-18 09:41:52 -06:00
Jason DoveandGitHub a0bef3568b fix selecting audio stream with preferred title (#1937)
* update dependencies to fix build errors

* fix selecting audio stream with preferred title
2024-11-16 19:28:30 -06:00
Jason DoveandGitHub f75bb25a1a fix building playouts with certain playlist changes (#1935)
* fix building playouts with certain playlist changes

* update changelog
2024-11-13 14:38:07 -06:00
Jason DoveandGitHub e4ff825ae8 fix displaying playout item durations that are greater than 24 hours (#1932)
* fix displaying playout item durations that are greater than 24 hours

* upgrade refit to make build happy

* upgrade all dependencies
2024-11-08 10:11:57 -06:00
Jason DoveandGitHub 6c4f63ad91 fix scaling behavior crop (#1928) 2024-10-26 08:38:44 -05:00
Jason DoveandGitHub c0a14ba81c add button to reset all playouts (#1927) 2024-10-24 11:23:47 -05:00
Jason DoveandGitHub c063720169 use new channel identifiers in M3U and XMLTV to disambiguate in Plex (#1920) 2024-10-16 09:37:33 -05:00
Jason DoveandGitHub 5d0f40978d fix deleting local libraries with mysql (#1919) 2024-10-16 08:46:45 -05:00
Jason DoveandGitHub 063f463951 update dependencies (#1918) 2024-10-16 08:40:03 -05:00
Jason DoveandGitHub e7817b1460 fix deco default filler hardware acceleration (#1916)
* fix hw accel for deco default filler

* use custom filler kind for deco default filler
2024-10-11 14:33:30 -05:00
Jason DoveandGitHub 7d6faee27b properly destroy channel preview (#1910) 2024-10-04 12:29:40 -05:00
Jason DoveandGitHub 7bba422880 fix emptying trash (#1905) 2024-09-25 10:27:34 -05:00
Jason DoveandGitHub db6ae27384 fix mysql database cleaner (#1902) 2024-09-20 08:42:52 -05:00
2168 changed files with 1330862 additions and 34470 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`
+4 -3
View File
@@ -3,10 +3,11 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2024.1.1",
"version": "2025.3.0.2",
"commands": [
"jb"
]
],
"rollForward": false
}
}
}
}
+37 -5
View File
@@ -1,9 +1,8 @@
[*]
charset=utf-8
end_of_line=lf
trim_trailing_whitespace=true
insert_final_newline=false
insert_final_newline=true
indent_style=space
indent_size=4
@@ -15,7 +14,7 @@ csharp_style_expression_bodied_constructors=true:none
csharp_style_expression_bodied_methods=true:none
csharp_style_expression_bodied_properties=true:suggestion
csharp_style_var_elsewhere=false:suggestion
csharp_style_var_for_built_in_types=false:suggestion
csharp_style_var_for_built_in_types=false:none
csharp_style_var_when_type_is_apparent=true:suggestion
dotnet_naming_rule.local_constants_rule.severity=warning
dotnet_naming_rule.local_constants_rule.style=all_upper_style
@@ -42,6 +41,8 @@ resharper_braces_for_for=required
resharper_braces_for_foreach=required
resharper_braces_for_ifelse=required
resharper_braces_for_while=required
resharper_csharp_arguments_literal=positional
resharper_csharp_arguments_named=positional
resharper_csharp_insert_final_newline=true
resharper_csharp_max_attribute_length_for_same_line=0
resharper_csharp_place_accessorholder_attribute_on_same_line=never
@@ -66,7 +67,7 @@ resharper_built_in_type_reference_style_highlighting=hint
resharper_redundant_base_qualifier_highlighting=warning
resharper_suggest_var_or_type_built_in_types_highlighting=hint
resharper_suggest_var_or_type_elsewhere_highlighting=hint
resharper_suggest_var_or_type_simple_types_highlighting=hint
resharper_suggest_var_or_type_simple_types_highlighting=none
resharper_web_config_module_not_resolved_highlighting=warning
resharper_web_config_type_not_resolved_highlighting=warning
resharper_web_config_wrong_module_highlighting=warning
@@ -84,7 +85,38 @@ tab_width=4
indent_style = space
indent_size = 2
[*.json]
ij_json_array_wrapping = normal
ij_json_keep_blank_lines_in_code = 0
ij_json_keep_indents_on_empty_lines = false
ij_json_keep_line_breaks = true
ij_json_keep_trailing_comma = false
ij_json_object_wrapping = normal
ij_json_property_alignment = do_not_align
ij_json_space_after_colon = true
ij_json_space_after_comma = true
ij_json_space_before_colon = false
ij_json_space_before_comma = false
ij_json_spaces_within_braces = true
ij_json_spaces_within_brackets = true
ij_json_wrap_long_lines = false
[*.cs]
# disable CA1848: Use the LoggerMessage delegates`
dotnet_diagnostic.ca1848.severity = none
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"
+14
View File
@@ -0,0 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: Feature Requests
url: https://features.ersatztv.org
about: Features
- name: Contact
url: https://ersatztv.org/contact
about: Chat Options
- name: Community
url: https://discuss.ersatztv.org
about: Forum
- name: Discussions
url: https://github.com/ErsatzTV/ErsatzTV/discussions
about: Discuss
+77
View File
@@ -0,0 +1,77 @@
name: Issue Report
description: Report an issue
type: Bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this form! Please make sure to fill all fields, including the Title above.
- type: checkboxes
id: before-posting
attributes:
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://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
- label: I'm using an up to date version of ErsatzTV (full release or develop release); We generally do not support previous older versions. If possible, please update to the latest version before opening an issue.
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
required: true
- type: textarea
id: description
attributes:
label: Description
description: |
Description of the problem or issue here.
validations:
required: true
- type: textarea
id: repro-steps
attributes:
label: Steps to reproduce the problem.
description: |
1. Step 1
2. Step 2
3. Step 3
If this is a playback issue, follow these steps and post the resulting zip:
1. Search for the required content using the search bar.
2. Use the overflow/three dots menu on the content and select Troubleshoot Playback.
3. Select the appropriate Playback Settings that trigger the undesired behavior.
4. Click Play to start playback.
5. Repeat steps 3 and 4 until the undesired behavior is reproduced.
6. Click Download Results to have ErsatzTV collect relevant troubleshooting logs (ffmpeg log, ffmpeg profile, hardware capabilities, media info, etc) and compress them in a zip file.
7. Attach the zip to this field.
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: What is the current _bug_ behavior?
description: Write down the incorrect behavior that currently happens after following the reproduction steps.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: What is the expected _correct_ behavior?
description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps.
validations:
required: true
- type: input
id: version
attributes:
label: Specify full version
description: Provide the full version of ErsatzTV, which can be found below the left menu.
placeholder: |
25.5.0-bd695412-docker-amd64
validations:
required: true
- type: textarea
id: additional-information
attributes:
label: Additional information
description: Any additional information that might be useful to this issue.
-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
-239
View File
@@ -1,239 +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
jobs:
build_and_upload_mac:
name: Mac Build & Upload
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- os: macos-12
kind: macOS
target: osx-x64
- os: macos-12
kind: macOS
target: osx-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- 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 net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -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 net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -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: true
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.dmg
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
build_and_upload:
name: Build & Upload
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- os: ubuntu-latest
kind: linux
target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: ubuntu-latest
kind: linux
target: linux-arm64
- os: windows-latest
kind: windows
target: win-x64
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target }}"
- uses: suisei-cn/actions-download-file@v1.3.0
if: ${{ matrix.kind == 'windows' }}
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/6.1-working-cuvid/ffmpeg-6.1-working-cuvid.7z"
target: ffmpeg/
- 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 net8.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -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 net8.0 --runtime "${{ matrix.target }}" -c Release -o "main" -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/"
# Build Windows launcher
if [ "${{ matrix.kind }}" == "windows" ]; then
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
ls -l ErsatzTV-Windows/target/release
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
fi
# Download ffmpeg
if [ "${{ matrix.kind }}" == "windows" ]; then
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
rm -f "$release_name/ffplay.exe"
fi
# Pack files
if [ "${{ matrix.kind }}" == "windows" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*"
else
tar czvf "${release_name}.tar.gz" "$release_name"
fi
# 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 }}.zip
*${{ matrix.target }}.tar.gz
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.zip
${{ env.RELEASE_NAME }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
-58
View File
@@ -1,58 +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/beta/$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/beta/$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 }}
build_and_push:
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 }}
-23
View File
@@ -1,23 +0,0 @@
name: Qodana
on:
workflow_dispatch:
push:
branches:
- main
jobs:
qodana:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
fetch-depth: 0 # a full history is required for pull request analysis
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2024.1
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
-112
View File
@@ -1,112 +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_and_push:
name: Build & Publish
runs-on: ubuntu-latest
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- name: base
path: ''
suffix: ''
qemu: false
- name: nvidia
path: 'nvidia/'
suffix: '-nvidia'
qemu: false
- name: vaapi
path: 'vaapi/'
suffix: '-vaapi'
qemu: false
- name: arm32v7
path: 'arm32v7/'
suffix: '-arm'
qemu: true
- name: arm64
path: 'arm64/'
suffix: '-arm64'
qemu: true
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
if: ${{ matrix.qemu == true }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: docker-buildx
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Build and push
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm64'
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm64' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm/v7'
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm32v7' }}
-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.
-81
View File
@@ -1,81 +0,0 @@
name: Pull Request
on:
pull_request:
jobs:
build_and_test_windows:
runs-on: windows-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- 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
- name: Build Windows
run: |
cd ErsatzTV-Windows
cargo build --release --all-features
build_and_test_linux:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- 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_mac:
runs-on: macos-12
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- 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
-53
View File
@@ -1,53 +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/-beta/}" >> $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 }}
build_and_push:
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 }}
+4
View File
@@ -3,6 +3,9 @@
project.lock.json
.DS_Store
*.pyc
# Claude Code
.mcp/
nupkg/
# Visual Studio Code
@@ -40,6 +43,7 @@ msbuild.wrn
core
scripts/generate-api-sdk/swagger.json
scripts/download-test-content.sh
docker-compose.override.yml
+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"
}
}
}
}
+1086 -3
View File
File diff suppressed because it is too large Load Diff
+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).
+12 -1
View File
@@ -2,5 +2,16 @@
<PropertyGroup>
<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>
</Project>
+41
View File
@@ -0,0 +1,41 @@
<Project>
<PropertyGroup>
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.VisualStudio.Threading.Analyzers"
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>
-2
View File
@@ -1,2 +0,0 @@
target/
-1035
View File
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "ersatztv_windows"
version = "0.1.0"
edition = "2021"
[dependencies]
tray-item = { git = "https://github.com/olback/tray-item-rs" }
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
process_path = "0.1.4"
[dependencies.windows]
version = "0.43.0"
features = [
"Win32_System_Console",
"Win32_Foundation"
]
[build-dependencies]
windres = "*"
static_vcruntime = "2.0"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-6
View File
@@ -1,6 +0,0 @@
use windres::Build;
fn main() {
static_vcruntime::metabuild();
Build::new().compile("ersatztv_windows.rc").unwrap();
}
-2
View File
@@ -1,2 +0,0 @@
id ICON "ersatztv.ico"
ersatztv-icon ICON "ersatztv.ico"
-109
View File
@@ -1,109 +0,0 @@
#![windows_subsystem = "windows"]
use special_folder::SpecialFolder;
use std::fs;
use std::os::windows::process::CommandExt;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use windows::Win32::System::Console;
use {std::sync::mpsc, tray_item::TrayItem};
const CREATE_NO_WINDOW: u32 = 0x08000000;
enum Message {
Exit,
}
fn main() {
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
let (tx, rx) = mpsc::channel();
tray.add_menu_item("Launch Web UI", || {
let _ = Command::new("cmd")
.creation_flags(CREATE_NO_WINDOW)
.arg("/C")
.arg("start")
.arg("http://localhost:8409")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
})
.unwrap();
tray.add_menu_item("Show Logs", || {
let path = SpecialFolder::LocalApplicationData
.get()
.unwrap()
.join("ersatztv")
.join("logs");
match path.to_str() {
None => {}
Some(folder) => {
fs::create_dir_all(folder).unwrap();
let _ = Command::new("explorer.exe")
.arg(folder)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
})
.unwrap();
tray.inner_mut().add_separator().unwrap();
tray.add_menu_item("Exit", move || {
tx.send(Message::Exit).unwrap();
})
.unwrap();
let path = process_path::get_executable_path();
let mut child: Option<Child> = None;
match path {
None => {}
Some(path) => {
let etv = path.parent().unwrap().join("ErsatzTV.exe");
if etv.exists() {
match etv.to_str() {
None => {}
Some(etv) => {
child = Some(
Command::new(etv)
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
}
}
}
}
}
loop {
match rx.recv() {
Ok(Message::Exit) => {
match child {
None => {}
Some(mut child) => {
unsafe {
if Console::AttachConsole(child.id()) == true
{
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
}
}
child.wait().unwrap();
}
}
break;
}
_ => {}
}
}
}
+4 -3
View File
@@ -29,9 +29,10 @@ internal static class Mapper
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Map(lang => allCultures.Filter(ci => string.Equals(
ci.ThreeLetterISOLanguageName,
lang,
StringComparison.OrdinalIgnoreCase)))
.Flatten()
.Distinct()
.ToList();
@@ -1,30 +1,26 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Artists.Mapper;
namespace ErsatzTV.Application.Artists;
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
public class GetArtistByIdHandler(
IArtistRepository artistRepository,
ISearchRepository searchRepository,
ILanguageCodeService languageCodeService)
: IRequestHandler<GetArtistById, Option<ArtistViewModel>>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchRepository _searchRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
{
_artistRepository = artistRepository;
_searchRepository = searchRepository;
}
public async Task<Option<ArtistViewModel>> Handle(
GetArtistById request,
CancellationToken cancellationToken)
{
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
Option<Artist> maybeArtist = await artistRepository.GetArtist(request.ArtistId);
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
async artist =>
{
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
List<string> mediaCodes = await searchRepository.GetLanguagesForArtist(artist);
List<string> languageCodes = languageCodeService.GetAllLanguageCodes(mediaCodes);
return ProjectToViewModel(artist, languageCodes);
},
() => Task.FromResult(Option<ArtistViewModel>.None));
@@ -0,0 +1,17 @@
using System.Net;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record ArtworkContentTypeModel(string Path, string ContentType)
{
public static readonly ArtworkContentTypeModel None = new(string.Empty, string.Empty);
public bool IsExternalUrl => Artwork.IsExternalUrl(Path);
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
? Path
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
}
@@ -1,6 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
@@ -6,24 +6,25 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Artworks;
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) : IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Artwork>> Handle(
GetArtwork request,
GetArtwork request,
CancellationToken cancellationToken)
{
try {
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Artwork> artwork = await dbContext.Artwork
.AsNoTracking()
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
.SelectOneAsync(a => a.Id, a => a.Id == request.Id, cancellationToken)
.MapT(Project);
return artwork.ToEither(BaseError.New("Artwork not found"));
}
catch (Exception ex)
{
@@ -31,12 +32,11 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) :
}
}
private static Artwork Project(Artwork artwork)
{
return new Artwork {
private static Artwork Project(Artwork artwork) =>
new()
{
Id = artwork.Id,
Path = artwork.Path,
ArtworkKind = artwork.ArtworkKind
};
}
}
@@ -0,0 +1,10 @@
namespace ErsatzTV.Application.Channels;
public class ChannelSortViewModel
{
public int Id { get; set; }
public string Number { get; set; }
public string Name { get; set; }
public string OriginalNumber { get; set; }
public bool HasChanged => OriginalNumber != Number;
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
public record ChannelStreamingSpecsViewModel(
int Height,
int Width,
int Bitrate,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
FFmpegProfileAudioFormat AudioFormat);
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using System.Net;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -10,10 +11,16 @@ public record ChannelViewModel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
@@ -21,7 +28,12 @@ public record ChannelViewModel(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate)
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg)
{
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -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;
}
}
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -9,14 +10,25 @@ public record CreateChannel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, CreateChannelResult>>;
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg) : IRequest<Either<BaseError, CreateChannelResult>>;
@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -7,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;
@@ -22,7 +24,7 @@ public class CreateChannelHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(c => PersistChannel(dbContext, c));
}
@@ -35,62 +37,104 @@ public class CreateChannelHandler(
return new CreateChannelResult(channel.Id);
}
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
(ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply(
(
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId) =>
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),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId,
_,
_,
_) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
artwork.Add(
new Artwork
{
Path = request.Logo,
ArtworkKind = ArtworkKind.Logo,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
logo = logo.Replace("iptv/logos/", string.Empty);
}
var channel = new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
ProgressMode = request.ProgressMode,
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate
};
artwork.Add(
new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType)
? request.Logo.ContentType
: null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
}
var channel = new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = request.SlugSeconds,
PlayoutSource = request.PlayoutSource,
PlayoutMode = request.PlayoutMode,
MirrorSourceChannelId = request.MirrorSourceChannelId,
PlayoutOffset = request.PlayoutOffset,
StreamingMode = request.StreamingMode,
Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode,
StreamSelector = request.StreamSelector,
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
SongVideoMode = request.SongVideoMode,
TranscodeMode = request.TranscodeMode,
IdleBehavior = request.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
{
channel.PlayoutMode = ChannelPlayoutMode.Continuous;
}
else
{
channel.MirrorSourceChannelId = null;
channel.PlayoutOffset = null;
}
return channel;
});
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
}
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
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)
@@ -98,10 +142,11 @@ public class CreateChannelHandler(
private static async Task<Validation<BaseError, string>> ValidateNumber(
TvContext dbContext,
CreateChannel createChannel)
CreateChannel createChannel,
CancellationToken cancellationToken)
{
Option<Channel> maybeExistingChannel = await dbContext.Channels
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number);
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number, cancellationToken);
return maybeExistingChannel.Match<Validation<BaseError, string>>(
_ => BaseError.New("Channel number must be unique"),
() =>
@@ -115,50 +160,101 @@ public class CreateChannelHandler(
});
}
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
TvContext dbContext,
CreateChannel createChannel) =>
dbContext.FFmpegProfiles
.CountAsync(p => p.Id == createChannel.FFmpegProfileId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => createChannel.FFmpegProfileId)
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
CreateChannel createChannel,
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,
CreateChannel createChannel)
CreateChannel createChannel,
CancellationToken cancellationToken)
{
if (createChannel.WatermarkId is null)
{
return Option<int>.None;
}
return await dbContext.ChannelWatermarks
.CountAsync(w => w.Id == createChannel.WatermarkId)
.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(
TvContext dbContext,
CreateChannel createChannel)
CreateChannel createChannel,
CancellationToken cancellationToken)
{
if (createChannel.FallbackFillerId is null)
{
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)
.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(
TvContext dbContext,
CreateChannel createChannel,
CancellationToken cancellationToken)
{
if (createChannel.PlayoutSource is not ChannelPlayoutSource.Mirror)
{
return Unit.Default;
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
c => c.Id == createChannel.MirrorSourceChannelId,
c => c.Id == createChannel.MirrorSourceChannelId,
cancellationToken);
if (maybeMirrorSource.IsNone)
{
return BaseError.New("Mirror source channel does not exist.");
}
foreach (var mirrorSource in maybeMirrorSource)
{
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
{
return BaseError.New(
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
}
}
foreach (TimeSpan playoutOffset in Optional(createChannel.PlayoutOffset))
{
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
{
return BaseError.New("Playout offset must not be greater than 12 hours");
}
}
return Unit.Default;
}
}
@@ -1,6 +1,7 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -12,27 +13,36 @@ namespace ErsatzTV.Application.Channels;
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly IFileSystem _fileSystem;
private readonly ISearchTargets _searchTargets;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeleteChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem,
IFileSystem fileSystem,
ISearchTargets searchTargets)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_fileSystem = fileSystem;
_searchTargets = searchTargets;
}
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);
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)
@@ -44,7 +54,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_localFileSystem.FileExists(cacheFile))
if (_fileSystem.File.Exists(cacheFile))
{
File.Delete(cacheFile);
}
@@ -54,13 +64,4 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
return Unit.Default;
}
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext,
DeleteChannel deleteChannel)
{
Option<Channel> maybeChannel = await dbContext.Channels
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
}
}
@@ -1,4 +1,5 @@
using System.Globalization;
using System.IO.Abstractions;
using System.Xml;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core;
@@ -7,9 +8,11 @@ using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
@@ -24,6 +27,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelDataHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@@ -31,12 +35,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public RefreshChannelDataHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository,
ILogger<RefreshChannelDataHandler> logger)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_configElementRepository = configElementRepository;
_logger = logger;
@@ -44,207 +50,278 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
{
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
try
{
return;
}
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
var minifier = new XmlMinifier(
new XmlMinificationSettings
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken);
if (hiddenCount > 0)
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
switch (playout.ProgramSchedulePlayoutType)
{
case ProgramSchedulePlayoutType.Flood:
case ProgramSchedulePlayoutType.Yaml:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WritePlayoutXml(
request,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
case ProgramSchedulePlayoutType.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WriteBlockPlayoutXml(
request,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
case ProgramSchedulePlayoutType.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ExternalJsonFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WritePlayoutXml(
request,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
File.Delete(targetFile);
return;
}
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null ||
remoteStreamTemplateFileName is null)
{
return;
}
var minifier = new XmlMinifier(
new XmlMinificationSettings
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.MirrorSourceChannel)
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
.SelectOneAsync(
c => c.Number == request.ChannelNumber,
c => c.Number == request.ChannelNumber,
cancellationToken);
foreach (Channel channel in maybeChannel)
{
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
}
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.AsSplitQuery()
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
switch (playout.ScheduleKind)
{
case PlayoutScheduleKind.Classic:
case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in floodSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WritePlayoutXml(
request,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in blockSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteBlockPlayoutXml(
request,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in externalJsonSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WritePlayoutXml(
request,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
}
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
File.Move(tempFile, targetFile, true);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
// do nothing
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
File.Move(tempFile, targetFile, true);
}
private async Task WritePlayoutXml(
@@ -256,11 +333,13 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml)
XmlWriter xml,
CancellationToken cancellationToken)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone)
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
// skip all filler that isn't pre-roll
@@ -286,24 +365,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.Tail or FillerKind.Fallback))
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
int customShowId = -1;
if (displayItem.MediaItem is Episode ep)
{
customShowId = ep.Season.ShowId;
}
bool isSameCustomShow = hasCustomTitle;
for (int x = j; x <= finishIndex; x++)
{
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
customShowId == e.Season.ShowId;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
@@ -341,6 +408,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
@@ -348,7 +416,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
}
}
private static async Task WriteBlockPlayoutXml(
private async Task WriteBlockPlayoutXml(
RefreshChannelData request,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
@@ -357,51 +425,110 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml)
XmlWriter xml,
CancellationToken cancellationToken)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await _configElementRepository
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime();
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
switch (xmltvBlockBehavior)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
DateTimeOffset actualFinish = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
currentStart = currentFinish;
currentFinish += perItem;
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
}
}
@@ -418,6 +545,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
@@ -479,6 +607,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
title,
templateContext,
otherVideoTemplate),
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
request,
templateRemoteStream,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
remoteStreamTemplate),
_ => Option<string>.None
};
@@ -514,6 +652,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -564,11 +704,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
showMetadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(showMetadata);
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -582,6 +725,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
EpisodeArtworkUrl = artworkPath,
EpisodeHasThumbnail = !string.IsNullOrWhiteSpace(thumbnailPath),
EpisodeThumbnailUrl = thumbnailPath,
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
metadata.EpisodeNumber,
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
@@ -629,6 +774,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -686,6 +833,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -734,10 +883,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -746,6 +899,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
OtherVideoPlot = metadata.Plot,
OtherVideoHasYear = metadata.Year.HasValue,
OtherVideoYear = metadata.Year,
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
OtherVideoArtworkUrl = artworkPath,
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
OtherVideoContentRating = metadata.ContentRating
@@ -761,18 +916,63 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return Option<string>.None;
}
private string GetMovieTemplateFileName()
private static async Task<Option<string>> ProcessRemoteStreamTemplate(
RefreshChannelData request,
RemoteStream templateRemoteStream,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template remoteStreamTemplate)
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
foreach (RemoteStreamMetadata metadata in templateRemoteStream.RemoteStreamMetadata.HeadOrNone())
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_movie.sbntxt");
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
RemoteStreamTitle = title,
RemoteStreamHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
RemoteStreamPlot = metadata.Plot,
RemoteStreamHasYear = metadata.Year.HasValue,
RemoteStreamYear = metadata.Year,
RemoteStreamHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
RemoteStreamArtworkUrl = artworkPath,
RemoteStreamGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
RemoteStreamHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
RemoteStreamContentRating = metadata.ContentRating
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await remoteStreamTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private string GetMovieTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"movie.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -786,16 +986,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetEpisodeTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "episode.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_episode.sbntxt");
}
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"episode.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -809,16 +1005,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetMusicVideoTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "musicVideo.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_musicVideo.sbntxt");
}
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"musicVideo.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -832,16 +1024,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetSongTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "song.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_song.sbntxt");
}
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"song.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -855,16 +1043,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetOtherVideoTemplateFileName()
{
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "otherVideo.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_otherVideo.sbntxt");
}
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"otherVideo.sbntxt");
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -876,6 +1060,25 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return templateFileName;
}
private string GetRemoteStreamTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"remoteStream.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate remote stream XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
{
string artworkPath = artwork.Path;
@@ -931,6 +1134,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
@@ -979,7 +1184,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
var result = new List<PlayoutItem>();
if (_localFileSystem.FileExists(path))
if (_fileSystem.File.Exists(path))
{
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(path));
@@ -1,9 +1,12 @@
using System.Data.Common;
using System.IO.Abstractions;
using System.Net;
using System.Xml;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -17,6 +20,7 @@ namespace ErsatzTV.Application.Channels;
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelListHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@@ -24,11 +28,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
public RefreshChannelListHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ILogger<RefreshChannelListHandler> logger)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -42,13 +48,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
}
// fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName))
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
@@ -76,12 +82,18 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
{
bool hasLogo = !string.IsNullOrWhiteSpace(channel.ArtworkPath);
bool hasExternalLogo = hasLogo && Artwork.IsExternalUrl(channel.ArtworkPath);
var data = new
{
ChannelId = ChannelIdentifier.FromNumber(channel.Number),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(channel.Number),
ChannelNumber = channel.Number,
ChannelName = channel.Name,
ChannelCategories = GetCategories(channel.Categories),
ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath),
ChannelHasExternalArtwork = hasExternalLogo,
ChannelHasArtwork = hasLogo,
ChannelArtworkPath = channel.ArtworkPath,
ChannelNameEncoded = WebUtility.UrlEncode(channel.Name)
};
@@ -110,7 +122,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
from Channel C
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
where C.Id in (select ChannelId from Playout)
where (C.Id in (select ChannelId from Playout) or C.MirrorSourceChannelId in (select ChannelId from Playout)) and C.IsEnabled = 1 and C.ShowInEPG = 1
order by CAST(C.Number as double)";
// TODO: this needs to be fixed for sqlite/mariadb
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -10,14 +11,25 @@ public record UpdateChannel(
string Group,
string Categories,
int FFmpegProfileId,
string Logo,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelProgressMode ProgressMode,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, ChannelViewModel>>;
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -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,86 +27,277 @@ public class UpdateChannelHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
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(TvContext dbContext, Channel c, UpdateChannel update)
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
UpdateChannel update,
CancellationToken cancellationToken)
{
// don't save mirror when playout exists
if (c.Playouts.Count > 0)
{
update = update with
{
PlayoutSource = ChannelPlayoutSource.Generated,
MirrorSourceChannelId = null
};
}
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name;
c.Number = update.Number;
c.SortNumber = double.Parse(update.Number, CultureInfo.InvariantCulture);
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;
c.PreferredAudioTitle = update.PreferredAudioTitle;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
c.SubtitleMode = update.SubtitleMode;
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
c.Artwork ??= new List<Artwork>();
c.SongVideoMode = update.SongVideoMode;
c.TranscodeMode = update.TranscodeMode;
c.IdleBehavior = update.IdleBehavior;
c.IsEnabled = update.IsEnabled;
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
c.Artwork ??= [];
if (!string.IsNullOrWhiteSpace(update.Logo))
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
{
Option<Artwork> maybeLogo =
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo);
string logo = update.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
maybeLogo.Match(
artwork =>
Option<Artwork> maybeLogo = c.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo).HeadOrNone();
foreach (Artwork artwork in maybeLogo)
{
artwork.Path = logo;
artwork.OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null;
artwork.DateUpdated = DateTime.UtcNow;
}
if (maybeLogo.IsNone)
{
var artwork = new Artwork
{
artwork.Path = update.Logo;
artwork.DateUpdated = DateTime.UtcNow;
},
() =>
{
var artwork = new Artwork
{
Path = update.Logo,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo
};
c.Artwork.Add(artwork);
});
Path = logo,
OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo
};
c.Artwork.Add(artwork);
}
}
else
{
await dbContext.Entry(c)
.Collection(channel => channel.Artwork)
.LoadAsync(cancellationToken);
foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList())
{
c.Artwork.Remove(artwork);
dbContext.Artwork.Remove(artwork);
}
}
c.ProgressMode = update.ProgressMode;
c.PlayoutSource = update.PlayoutSource;
c.PlayoutMode = update.PlayoutMode;
if (c.PlayoutSource is ChannelPlayoutSource.Mirror)
{
c.PlayoutMode = ChannelPlayoutMode.Continuous;
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
}
else
{
c.MirrorSourceChannelId = null;
c.PlayoutOffset = null;
}
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync();
await dbContext.SaveChangesAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
if (c.SubtitleMode != ChannelSubtitleMode.None)
{
Option<Playout> maybePlayout = await dbContext.Playouts
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id);
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
foreach (Playout playout in maybePlayout)
{
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
}
}
await workerChannel.WriteAsync(new RefreshChannelList());
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
if (hasEpgChange)
{
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
}
return ProjectToViewModel(c);
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
}
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
(await ChannelMustExist(dbContext, request), ValidateName(request),
await ValidateNumber(dbContext, request))
.Apply((channelToUpdate, _, _) => channelToUpdate);
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
private static async Task<Validation<BaseError, Channel>> Validate(
TvContext dbContext,
UpdateChannel updateChannel) =>
dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId)
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
UpdateChannel request,
Channel channel,
CancellationToken cancellationToken)
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((_, _, _, _, _) => channel);
// 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 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,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
{
return Unit.Default;
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
c => c.Id == request.MirrorSourceChannelId,
c => c.Id == request.MirrorSourceChannelId,
cancellationToken);
if (maybeMirrorSource.IsNone)
{
return BaseError.New("Mirror source channel does not exist.");
}
foreach (var mirrorSource in maybeMirrorSource)
{
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
{
return BaseError.New(
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
}
}
foreach (TimeSpan playoutOffset in Optional(request.PlayoutOffset))
{
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
{
return BaseError.New("Playout offset must not be greater than 12 hours");
}
}
return Unit.Default;
}
private static Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
updateChannel.NotEmpty(c => c.Name)
@@ -111,10 +305,11 @@ public class UpdateChannelHandler(
private static async Task<Validation<BaseError, string>> ValidateNumber(
TvContext dbContext,
UpdateChannel updateChannel)
UpdateChannel updateChannel,
CancellationToken cancellationToken)
{
int matchId = await dbContext.Channels
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number)
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number, cancellationToken)
.Match(c => c.Id, () => updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId)
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record UpdateChannelNumbers(List<ChannelSortViewModel> Channels) : IRequest<Option<BaseError>>;
@@ -0,0 +1,72 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class UpdateChannelNumbersHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<UpdateChannelNumbers, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
var channelIds = numberUpdates.Keys;
List<Channel> channelsToUpdate = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
// give every channel a non-conflicting number
foreach (var channel in channelsToUpdate)
{
channel.Number = $"-{channel.Id}";
}
// save those changes
await dbContext.SaveChangesAsync(cancellationToken);
// give every channel the proper new number
foreach (var channel in channelsToUpdate)
{
channel.Number = numberUpdates[channel.Id];
if (double.TryParse(channel.Number, CultureInfo.InvariantCulture, out double sortNumber))
{
channel.SortNumber = sortNumber;
}
else
{
return BaseError.New($"Failed to parse channel number {channel.Number}");
}
}
// save those changes
await dbContext.SaveChangesAsync(cancellationToken);
// commit the transaction
await transaction.CommitAsync(cancellationToken);
// update channel list and xmltv
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
foreach (var channel in channelsToUpdate)
{
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
}
return Option<BaseError>.None;
}
catch (Exception ex)
{
return BaseError.New("Failed to update channel numbers: " + ex.Message);
}
}
}
+41 -9
View File
@@ -1,11 +1,12 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
internal static class Mapper
{
internal static ChannelViewModel ProjectToViewModel(Channel channel) =>
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
new(
channel.Id,
channel.Number,
@@ -13,18 +14,29 @@ internal static class Mapper
channel.Group,
channel.Categories,
channel.FFmpegProfileId,
channel.SlugSeconds,
GetLogo(channel),
channel.StreamSelectorMode,
channel.StreamSelector,
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.ProgressMode,
channel.PlayoutSource,
channel.PlayoutMode,
channel.MirrorSourceChannelId,
channel.PlayoutOffset,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0,
playoutCount,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate);
channel.MusicVideoCreditsTemplate,
channel.SongVideoMode,
channel.TranscodeMode,
channel.IdleBehavior,
channel.IsEnabled,
channel.ShowInEpg);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
@@ -38,9 +50,30 @@ internal static class Mapper
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty);
internal static ChannelStreamingSpecsViewModel ProjectToSpecsViewModel(Channel channel) =>
new(
channel.FFmpegProfile.Resolution.Height,
channel.FFmpegProfile.Resolution.Width,
(int)((channel.FFmpegProfile.VideoBitrate * 1000 + channel.FFmpegProfile.AudioBitrate * 1000) * 1.2),
channel.FFmpegProfile.VideoFormat,
channel.FFmpegProfile.VideoProfile,
channel.FFmpegProfile.AudioFormat);
private static ArtworkContentTypeModel GetLogo(Channel channel)
{
Option<Artwork> maybeArtwork = channel.Artwork
.Where(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone();
foreach (Artwork artwork in maybeArtwork)
{
return artwork.IsExternalUrl()
? new ArtworkContentTypeModel(artwork.Path, string.Empty)
: new ArtworkContentTypeModel($"iptv/logos/{artwork.Path}", artwork.OriginalContentType);
}
return ArtworkContentTypeModel.None;
}
private static string GetStreamingMode(Channel channel) =>
channel.StreamingMode switch
@@ -49,7 +82,6 @@ internal static class Mapper
StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};
}
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetAllChannels : IRequest<List<ChannelViewModel>>;
public record GetAllChannels(bool ShowDisabled = true) : IRequest<List<ChannelViewModel>>;
@@ -5,17 +5,14 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelResponseModel>> Handle(
GetAllChannelsForApi request,
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll()).Flatten();
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
return channels.Map(ProjectToResponseModel).ToList();
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetAllChannelsForSort : IRequest<List<ChannelSortViewModel>>;
@@ -0,0 +1,31 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForSortHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllChannelsForSort, List<ChannelSortViewModel>>
{
public async Task<List<ChannelSortViewModel>> Handle(
GetAllChannelsForSort request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Channels
.AsNoTracking()
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToSortViewModel)
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)).ToList());
}
private static ChannelSortViewModel ProjectToSortViewModel(Channel channel)
=> new()
{
Id = channel.Id,
Number = channel.Number,
Name = channel.Name,
OriginalNumber = channel.Number
};
}
@@ -1,14 +1,31 @@
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsHandler : IRequestHandler<GetAllChannels, List<ChannelViewModel>>
public class GetAllChannelsHandler(IChannelRepository channelRepository)
: IRequestHandler<GetAllChannels, List<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
await channelRepository.GetAll(cancellationToken)
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
private static int GetPlayoutsCount(Channel channel)
{
var result = 0;
if (channel.Playouts != null)
{
result += channel.Playouts.Count;
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
{
result += channel.MirrorSourceChannel.Playouts.Count;
}
return result;
}
}
@@ -3,13 +3,10 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByIdHandler : IRequestHandler<GetChannelById, Option<ChannelViewModel>>
public class GetChannelByIdHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelById, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
_channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel);
channelRepository.GetChannel(request.Id)
.MapT(c => ProjectToViewModel(c, 0));
}
@@ -3,12 +3,9 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByNumberHandler : IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
public class GetChannelByNumberHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByNumberHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel);
channelRepository.GetByNumber(request.ChannelNumber).MapT(c => ProjectToViewModel(c, 0));
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelByPlayoutId(int PlayoutId) : IRequest<Option<ChannelViewModel>>;
@@ -0,0 +1,21 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelByPlayoutId, Option<ChannelViewModel>>
{
public async Task<Option<ChannelViewModel>> Handle(
GetChannelByPlayoutId request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Include(p => p.Channel)
.ThenInclude(c => c.Artwork)
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken)
.Map(p => ProjectToViewModel(p.Channel, 1));
}
}
@@ -1,3 +1,5 @@
namespace ErsatzTV.Application.Channels;
using ErsatzTV.FFmpeg;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<int>>;
namespace ErsatzTV.Application.Channels;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<FrameRate>>;
@@ -1,123 +1,118 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Channels;
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>>
public class GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILogger<GetChannelFramerateHandler> _logger;
public GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
{
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
FFmpegProfile ffmpegProfile = await dbContext.Channels
.Filter(c => c.Number == request.ChannelNumber)
.Include(c => c.FFmpegProfile)
.Map(c => c.FFmpegProfile)
.SingleAsync(cancellationToken);
if (!ffmpegProfile.NormalizeFramerate)
try
{
return Option<int>.None;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// TODO: expand to check everything in collection rather than what's scheduled?
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
FFmpegProfile ffmpegProfile = await dbContext.Channels
.AsNoTracking()
.Filter(c => c.Number == request.ChannelNumber)
.Include(c => c.FFmpegProfile)
.Map(c => c.FFmpegProfile)
.SingleAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Filter(p => p.Channel.Number == request.ChannelNumber)
.ToListAsync(cancellationToken);
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
.Flatten()
.Map(mv => mv.RFrameRate)
.ToList();
var distinct = frameRates.Distinct().ToList();
if (distinct.Count > 1)
{
// TODO: something more intelligent than minimum framerate?
int result = frameRates.Map(ParseFrameRate).Min();
if (result < 24)
if (!ffmpegProfile.NormalizeFramerate)
{
_logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
request.ChannelNumber,
distinct,
24,
result);
return 24;
return Option<FrameRate>.None;
}
_logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
request.ChannelNumber,
distinct,
result);
return result;
}
// TODO: expand to check everything in collection rather than what's scheduled?
logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
if (distinct.Count != 0)
{
_logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber,
distinct[0]);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
.Filter(p => p.Channel.Number == request.ChannelNumber)
.ToListAsync(cancellationToken);
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
.Flatten()
.Map(mv => new FrameRate(mv.RFrameRate))
.ToList();
var distinct = frameRates.Distinct().ToList();
if (distinct.Count > 1)
{
// TODO: something more intelligent than minimum framerate?
var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
if (validFrameRates.Count > 0)
{
FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
request.ChannelNumber,
distinct.Map(fr => fr.RFrameRate),
result.RFrameRate);
return result;
}
FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
request.ChannelNumber,
distinct.Map(fr => fr.RFrameRate),
FrameRate.DefaultFrameRate.RFrameRate,
minFrameRate.RFrameRate);
return FrameRate.DefaultFrameRate;
}
if (distinct.Count != 0)
{
logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber,
distinct[0].RFrameRate);
}
else
{
logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
request.ChannelNumber);
}
}
else
catch (Exception ex)
{
_logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
logger.LogWarning(
ex,
"Unexpected error checking frame rates on channel {ChannelNumber}",
request.ChannelNumber);
}
return None;
}
private int ParseFrameRate(string frameRate)
{
if (!int.TryParse(frameRate, out int fr))
{
string[] split = (frameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
fr = (int)Math.Round(left / (double)right);
}
else
{
fr = 24;
}
}
return fr;
}
}
@@ -1,4 +1,7 @@
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
@@ -8,35 +11,32 @@ using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
public partial class GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
ILocalFileSystem localFileSystem)
{
_dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_localFileSystem = localFileSystem;
}
public async Task<Either<BaseError, ChannelGuide>> Handle(
GetChannelGuide request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var hiddenChannelNumbers = dbContext.Channels
.Where(c => c.ShowInEpg == false)
.Select(c => c.Number)
.AsEnumerable()
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile))
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!fileSystem.File.Exists(channelsFile))
{
return BaseError.New($"Required file {channelsFile} is missing");
}
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(request.AccessToken))
@@ -44,7 +44,7 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
accessTokenUri += $"&amp;access_token={request.AccessToken}";
}
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
// TODO: is regex faster?
channelsFragment = channelsFragment
@@ -53,22 +53,54 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<Ba
var channelDataFragments = new Dictionary<string, string>();
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
{
if (fileName.Contains("channels"))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
{
continue;
}
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
try
{
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
catch (FileNotFoundException)
{
// ignore this channel fragment
}
catch (IOException)
{
// ignore this channel fragment
}
}
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
}
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
{
await using var stream = fileSystem.FileStream.New(
fileName,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
return await reader.ReadToEndAsync(cancellationToken);
}
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
private static partial Regex EtvTagRegex();
}
@@ -10,6 +10,7 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
public GetChannelLineupHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
_channelRepository.GetAll(cancellationToken)
.Map(channels => channels.Where(c => c.IsEnabled)
.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
}
@@ -4,19 +4,15 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
public class GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Include(p => p.Channel)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
.MapT(p => p.Channel.Name);
}
}
@@ -4,40 +4,36 @@ using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
{
private readonly IChannelRepository _channelRepository;
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
_channelRepository = channelRepository;
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
channelRepository.GetAll(cancellationToken)
.Map(channels => EnsureMode(channels, request.Mode))
.Map(
channels => new ChannelPlaylist(
request.Scheme,
request.Host,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
.Map(channels => new ChannelPlaylist(
request.Scheme,
request.Host,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
var result = new List<Channel>();
foreach (Channel channel in channels)
{
if (!channel.IsEnabled)
{
continue;
}
switch (mode.ToLowerInvariant())
{
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "segmenter-v2":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
result.Add(channel);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelResolution(string ChannelNumber) : IRequest<Option<ResolutionViewModel>>;
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelStreamSelectors : IRequest<List<string>>;
@@ -0,0 +1,14 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Application.Channels;
public class GetChannelStreamSelectorsHandler(ILocalFileSystem localFileSystem)
: IRequestHandler<GetChannelStreamSelectors, List<string>>
{
public Task<List<string>> Handle(GetChannelStreamSelectors request, CancellationToken cancellationToken) =>
localFileSystem.ListFiles(FileSystemLayout.ChannelStreamSelectorsFolder)
.Map(Path.GetFileName)
.ToList()
.AsTask();
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelStreamingSpecs(string ChannelNumber) : IRequest<Option<ChannelStreamingSpecsViewModel>>;
@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelResolution, Option<ResolutionViewModel>>
public class GetChannelStreamingSpecsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelStreamingSpecs, Option<ChannelStreamingSpecsViewModel>>
{
public async Task<Option<ResolutionViewModel>> Handle(
GetChannelResolution request,
public async Task<Option<ChannelStreamingSpecsViewModel>> Handle(
GetChannelStreamingSpecs request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -18,8 +18,8 @@ public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextF
.AsNoTracking()
.Include(c => c.FFmpegProfile)
.ThenInclude(ff => ff.Resolution)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
return maybeChannel.Map(c => Mapper.ProjectToViewModel(c.FFmpegProfile.Resolution));
return maybeChannel.Map(Mapper.ProjectToSpecsViewModel);
}
}
@@ -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));
}
}
@@ -10,5 +10,5 @@ public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementBy
_configElementRepository = configElementRepository;
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
await _configElementRepository.Upsert(request.Key, request.Value);
await _configElementRepository.Upsert(request.Key, request.Value, cancellationToken);
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateGeneralSettings(GeneralSettingsViewModel GeneralSettings) : IRequest<Either<BaseError, Unit>>;
@@ -16,16 +16,16 @@ public class UpdateLibraryRefreshIntervalHandler :
UpdateLibraryRefreshInterval request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(
_ => _configElementRepository.Upsert(
ConfigElementKey.LibraryRefreshInterval,
request.LibraryRefreshInterval))
.MapT(_ => _configElementRepository.Upsert(
ConfigElementKey.LibraryRefreshInterval,
request.LibraryRefreshInterval,
cancellationToken))
.Bind(v => v.ToEitherAsync());
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
Optional(request.LibraryRefreshInterval)
.Where(lri => lri is >= 0 and < 1_000_000)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Library refresh interval must be zero or greated")
.ToValidation<BaseError>("Library refresh interval must be zero or greater")
.AsTask();
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateLoggingSettings(LoggingSettingsViewModel LoggingSettings) : IRequest<Either<BaseError, Unit>>;
@@ -4,12 +4,12 @@ using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSettings, Either<BaseError, Unit>>
public class UpdateLoggingSettingsHandler : IRequestHandler<UpdateLoggingSettings, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly LoggingLevelSwitches _loggingLevelSwitches;
public UpdateGeneralSettingsHandler(
public UpdateLoggingSettingsHandler(
LoggingLevelSwitches loggingLevelSwitches,
IConfigElementRepository configElementRepository)
{
@@ -18,33 +18,38 @@ public class UpdateGeneralSettingsHandler : IRequestHandler<UpdateGeneralSetting
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateGeneralSettings request,
CancellationToken cancellationToken) => await ApplyUpdate(request.GeneralSettings);
UpdateLoggingSettings request,
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings, cancellationToken);
private async Task<Unit> ApplyUpdate(GeneralSettingsViewModel generalSettings)
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings, CancellationToken cancellationToken)
{
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, generalSettings.DefaultMinimumLogLevel);
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = generalSettings.DefaultMinimumLogLevel;
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = loggingSettings.DefaultMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelScanning,
generalSettings.ScanningMinimumLogLevel);
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = generalSettings.ScanningMinimumLogLevel;
loggingSettings.ScanningMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = loggingSettings.ScanningMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelScheduling,
generalSettings.SchedulingMinimumLogLevel);
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = generalSettings.SchedulingMinimumLogLevel;
loggingSettings.SchedulingMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = loggingSettings.SchedulingMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelSearching,
loggingSettings.SearchingMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.SearchingLevelSwitch.MinimumLevel = loggingSettings.SearchingMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelStreaming,
generalSettings.StreamingMinimumLogLevel);
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = generalSettings.StreamingMinimumLogLevel;
loggingSettings.StreamingMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = loggingSettings.StreamingMinimumLogLevel;
await _configElementRepository.Upsert(
ConfigElementKey.MinimumLogLevelHttp,
generalSettings.HttpMinimumLogLevel);
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = generalSettings.HttpMinimumLogLevel;
loggingSettings.HttpMinimumLogLevel, cancellationToken);
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = loggingSettings.HttpMinimumLogLevel;
return Unit.Default;
}
@@ -32,24 +32,40 @@ public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSetting
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Unit> validation = await Validate(request);
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.PlayoutSettings));
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(
dbContext,
request.PlayoutSettings,
cancellationToken));
}
private async Task<Unit> ApplyUpdate(TvContext dbContext, PlayoutSettingsViewModel playoutSettings)
private async Task<Unit> ApplyUpdate(
TvContext dbContext,
PlayoutSettingsViewModel playoutSettings,
CancellationToken cancellationToken)
{
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, playoutSettings.DaysToBuild);
await _configElementRepository.Upsert(
ConfigElementKey.PlayoutDaysToBuild,
playoutSettings.DaysToBuild,
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.PlayoutSkipMissingItems,
playoutSettings.SkipMissingItems);
playoutSettings.SkipMissingItems,
cancellationToken);
await _configElementRepository.Upsert(
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
playoutSettings.ScriptedScheduleTimeoutSeconds,
cancellationToken);
// continue all playouts to proper number of days
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Channel)
.ToListAsync();
.ToListAsync(cancellationToken);
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number, CultureInfo.InvariantCulture))
.Map(p => p.Id))
{
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue));
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue), cancellationToken);
}
return Unit.Default;
@@ -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;
}
}
@@ -20,7 +20,7 @@ public class UpdateXmltvSettingsHandler(
{
int playoutDaysToBuild =
await configElementRepository
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
.IfNoneAsync(2);
if (playoutDaysToBuild < request.XmltvSettings.DaysToBuild)
@@ -29,19 +29,20 @@ public class UpdateXmltvSettingsHandler(
$"XMLTV days to build ({request.XmltvSettings.DaysToBuild}) cannot be greater than Playout days to build ({playoutDaysToBuild})");
}
return await ApplyUpdate(request.XmltvSettings);
return await ApplyUpdate(request.XmltvSettings, cancellationToken);
}
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings)
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings, CancellationToken cancellationToken)
{
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone);
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild);
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone, cancellationToken);
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild, cancellationToken);
await configElementRepository.Upsert(ConfigElementKey.XmltvBlockBehavior, xmltvSettings.BlockBehavior, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync())
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync(cancellationToken))
{
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber));
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
}
return Unit.Default;
@@ -2,11 +2,12 @@ using Serilog.Events;
namespace ErsatzTV.Application.Configuration;
public class GeneralSettingsViewModel
public class LoggingSettingsViewModel
{
public LogEventLevel DefaultMinimumLogLevel { get; set; }
public LogEventLevel ScanningMinimumLogLevel { get; set; }
public LogEventLevel SchedulingMinimumLogLevel { get; set; }
public LogEventLevel SearchingMinimumLogLevel { get; set; }
public LogEventLevel StreamingMinimumLogLevel { get; set; }
public LogEventLevel HttpMinimumLogLevel { get; set; }
}
@@ -4,4 +4,5 @@ public class PlayoutSettingsViewModel
{
public int DaysToBuild { get; set; }
public bool SkipMissingItems { get; set; }
public int ScriptedScheduleTimeoutSeconds { get; set; }
}
@@ -13,5 +13,5 @@ public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKe
public Task<Option<ConfigElementViewModel>> Handle(
GetConfigElementByKey request,
CancellationToken cancellationToken) =>
_configElementRepository.GetConfigElement(request.Key).MapT(ProjectToViewModel);
_configElementRepository.GetConfigElement(request.Key, cancellationToken).MapT(ProjectToViewModel);
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Configuration;
public record GetGeneralSettings : IRequest<GeneralSettingsViewModel>;
@@ -11,6 +11,6 @@ public class GetLibraryRefreshIntervalHandler : IRequestHandler<GetLibraryRefres
_configElementRepository = configElementRepository;
public Task<int> Handle(GetLibraryRefreshInterval request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
.Map(result => result.IfNone(6));
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetLoggingSettings : IRequest<LoggingSettingsViewModel>;
@@ -4,35 +4,49 @@ using Serilog.Events;
namespace ErsatzTV.Application.Configuration;
public class GetGeneralSettingsHandler : IRequestHandler<GetGeneralSettings, GeneralSettingsViewModel>
public class GetLoggingSettingsHandler : IRequestHandler<GetLoggingSettings, LoggingSettingsViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
public GetGeneralSettingsHandler(IConfigElementRepository configElementRepository) =>
public GetLoggingSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<GeneralSettingsViewModel> Handle(GetGeneralSettings request, CancellationToken cancellationToken)
public async Task<LoggingSettingsViewModel> Handle(GetLoggingSettings request, CancellationToken cancellationToken)
{
Option<LogEventLevel> maybeDefaultLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel);
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevel, cancellationToken);
Option<LogEventLevel> maybeScanningLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScanning);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelScanning,
cancellationToken);
Option<LogEventLevel> maybeSchedulingLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelScheduling);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelScheduling,
cancellationToken);
Option<LogEventLevel> maybeSearchingLevel =
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelSearching,
cancellationToken);
Option<LogEventLevel> maybeStreamingLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelStreaming);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelStreaming,
cancellationToken);
Option<LogEventLevel> maybeHttpLevel =
await _configElementRepository.GetValue<LogEventLevel>(ConfigElementKey.MinimumLogLevelHttp);
await _configElementRepository.GetValue<LogEventLevel>(
ConfigElementKey.MinimumLogLevelHttp,
cancellationToken);
return new GeneralSettingsViewModel
return new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = await maybeDefaultLevel.IfNoneAsync(LogEventLevel.Information),
ScanningMinimumLogLevel = await maybeScanningLevel.IfNoneAsync(LogEventLevel.Information),
SchedulingMinimumLogLevel = await maybeSchedulingLevel.IfNoneAsync(LogEventLevel.Information),
SearchingMinimumLogLevel = await maybeSearchingLevel.IfNoneAsync(LogEventLevel.Information),
StreamingMinimumLogLevel = await maybeStreamingLevel.IfNoneAsync(LogEventLevel.Information),
HttpMinimumLogLevel = await maybeHttpLevel.IfNoneAsync(LogEventLevel.Information)
};
@@ -0,0 +1,5 @@
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.Configuration;
public record GetMpegTsScripts : IRequest<List<MpegTsScript>>;
@@ -0,0 +1,14 @@
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Application.Configuration;
public class GetMpegTsScriptsHandler(IMpegTsScriptService mpegTsScriptService)
: IRequestHandler<GetMpegTsScripts, List<MpegTsScript>>
{
public async Task<List<MpegTsScript>> Handle(GetMpegTsScripts request, CancellationToken cancellationToken)
{
await mpegTsScriptService.RefreshScripts();
return mpegTsScriptService.GetScripts().OrderBy(x => x.Name).ToList();
}
}
@@ -12,15 +12,23 @@ public class GetPlayoutSettingsHandler : IRequestHandler<GetPlayoutSettings, Pla
public async Task<PlayoutSettingsViewModel> Handle(GetPlayoutSettings request, CancellationToken cancellationToken)
{
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(
ConfigElementKey.PlayoutDaysToBuild,
cancellationToken);
Option<bool> skipMissingItems =
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems);
await _configElementRepository.GetValue<bool>(ConfigElementKey.PlayoutSkipMissingItems, cancellationToken);
Option<int> scriptedScheduleTimeoutSeconds =
await _configElementRepository.GetValue<int>(
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
cancellationToken);
return new PlayoutSettingsViewModel
{
DaysToBuild = await daysToBuild.IfNoneAsync(2),
SkipMissingItems = await skipMissingItems.IfNoneAsync(false)
SkipMissingItems = await skipMissingItems.IfNoneAsync(false),
ScriptedScheduleTimeoutSeconds = await scriptedScheduleTimeoutSeconds.IfNoneAsync(30)
};
}
}
@@ -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")
};
}
}
@@ -8,15 +8,23 @@ public class GetXmltvSettingsHandler(IConfigElementRepository configElementRepos
{
public async Task<XmltvSettingsViewModel> Handle(GetXmltvSettings request, CancellationToken cancellationToken)
{
Option<int> daysToBuild = await configElementRepository.GetValue<int>(ConfigElementKey.XmltvDaysToBuild);
Option<int> daysToBuild = await configElementRepository.GetValue<int>(
ConfigElementKey.XmltvDaysToBuild,
cancellationToken);
Option<XmltvTimeZone> maybeTimeZone =
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone);
await configElementRepository.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken);
Option<XmltvBlockBehavior> maybeBlockBehavior =
await configElementRepository.GetValue<XmltvBlockBehavior>(
ConfigElementKey.XmltvBlockBehavior,
cancellationToken);
return new XmltvSettingsViewModel
{
DaysToBuild = await daysToBuild.IfNoneAsync(2),
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local)
TimeZone = await maybeTimeZone.IfNoneAsync(XmltvTimeZone.Local),
BlockBehavior = await maybeBlockBehavior.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly)
};
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Application.Configuration;
public class UiSettingsViewModel
{
public bool IsDarkMode { get; set; }
public string Language { get; set; }
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Application.Configuration;
public enum XmltvBlockBehavior
{
SplitTimeEvenly = 0,
UseActualTimes = 1
}
@@ -4,4 +4,5 @@ public class XmltvSettingsViewModel
{
public int DaysToBuild { get; set; }
public XmltvTimeZone TimeZone { get; set; }
public XmltvBlockBehavior BlockBehavior { get; set; }
}
@@ -1,34 +1,43 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Emby;
public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyCollections>,
IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
{
private readonly IScannerProxyService _scannerProxyService;
public CallEmbyCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
IScannerProxyService scannerProxyService,
IRuntimeInfo runtimeInfo,
ILogger<CallEmbyCollectionScannerHandler> logger) : base(
dbContextFactory,
configElementRepository,
runtimeInfo,
logger)
{
_scannerProxyService = scannerProxyService;
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeEmbyCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
Validation<BaseError, ScanParameters> validation = await Validate(request, cancellationToken);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
parameters => PerformScan(parameters, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
@@ -40,13 +49,16 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeEmbyCollections request)
protected override async Task<Tuple<string, DateTimeOffset>> GetLastScan(
TvContext dbContext,
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
{
DateTime minDateTime = await dbContext.EmbyMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId)
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyMediaSourceId, cancellationToken)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
return new Tuple<string, DateTimeOffset>(string.Empty, new DateTimeOffset(minDateTime, TimeSpan.Zero));
}
protected override bool ScanIsRequired(
@@ -64,20 +76,40 @@ public class CallEmbyCollectionScannerHandler : CallLibraryScannerHandler<Synchr
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
ScanParameters parameters,
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
Option<Guid> maybeScanId = _scannerProxyService.StartScan(FakeLibraryId.EmbyCollections);
foreach (var scanId in maybeScanId)
{
"scan-emby-collections", request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
try
{
var arguments = new List<string>
{
"scan-emby-collections",
request.EmbyMediaSourceId.ToString(CultureInfo.InvariantCulture),
GetBaseUrl(scanId)
};
if (request.ForceScan)
{
arguments.Add("--force");
if (request.ForceScan)
{
arguments.Add("--force");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(parameters, arguments, cancellationToken).MapT(_ => Unit.Default);
}
finally
{
_scannerProxyService.EndScan(scanId);
}
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
return BaseError.New("Emby collections are already scanning");
}
}

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