diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 5bd0580..68120ec 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -111,7 +111,7 @@ "permissions": "rw" } ], - "customOptions": "--user root --label m3undle.run_mode=existing -p 5004:5004 -p 8080:8080 -p 1900:1900/udp -p 65001:65001/udp" + "customOptions": "--user root --label m3undle.run_mode=existing -p 5004:5004 -p 8080:8080 -p 1900:1900/udp" }, "netCore": { "appProject": "${workspaceFolder}/src/M3Undle.Web/M3Undle.Web.csproj", @@ -157,7 +157,7 @@ "permissions": "rw" } ], - "customOptions": "--user root --label m3undle.run_mode=force -p 5004:5004 -p 8080:8080 -p 1900:1900/udp -p 65001:65001/udp" + "customOptions": "--user root --label m3undle.run_mode=force -p 5004:5004 -p 8080:8080 -p 1900:1900/udp" }, "netCore": { "appProject": "${workspaceFolder}/src/M3Undle.Web/M3Undle.Web.csproj", diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fa72b9b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,153 @@ +# Changelog + +All notable changes to M3Undle are documented here. Newest release at the top. + +--- + +## [v1.0.0-alpha.7] — 2026-06-05 + +Alpha 7 is the final alpha milestone. It delivers adaptive live stream recovery for noisy provider channels, a first-class relay policy per provider, significant interface polish across nearly every page, and the consolidated schema migration that closes out all alpha database changes. Beta testing begins after this release. + +### Adaptive stream recovery + +M3Undle now tracks per-channel stream health across sessions and uses that history to make smarter relay decisions. + +- Channels are classified as **Stable**, **Cautious**, or **Unstable** based on observed disconnect and reconnect events +- Health classification persists in the database — it survives restarts and informs the next session startup +- Clean-watch duration is tracked after the last adverse event; a channel relaxes one health level after 30 minutes of clean playback +- At session startup, channel health is loaded before the first upstream connection so the relay decision is already informed before a single byte arrives +- When clean recovery from an MPEG-TS/H.264 boundary is not safe, M3Undle issues a hard controlled retune instead of continuing with corrupt data +- Cooldown policy improved to be less aggressive and avoid thrash after a single bad event + +### Provider relay policy + +Replaced the hidden per-provider "clean remux" toggle with an explicit, user-facing **Relay policy** setting. + +- **Auto** — M3Undle decides: direct relay for Stable and Cautious channels, clean relay when channel health is Unstable +- **On** — always use clean relay regardless of health +- **Off** — always use direct relay regardless of health + +Existing providers migrate automatically: prior `off` rows become `Auto`, prior legacy remux rows become `On`. + +The relay policy, startup health classification, and relay decision reason are now visible on the stream monitor for every active session. + +### Stream monitor improvements + +- Transfer rates (bytes/sec) displayed per session and per connected client +- Startup health and relay decision reason shown for each active session +- Sessions display more detailed subscriber information + +### Retune compatibility fix + +A controlled downstream retune is now suppressed when an internal or Generated HLS relay subscriber is attached to the shared session. A Jellyfin or NextPVR retune no longer kills a concurrent IPTVnator or browser HLS stream that is still active. + +### HDHomeRun page + +New dedicated HDHomeRun page showing device identity, discovery endpoint, tuning endpoint, and configured tuner count. Makes manual client setup straightforward without hunting through settings. + +### About page + +New About page with application version, build date, and project links. Fixes #97. + +### Dashboard and navigation + +- Dashboard reorganized: active profiles are the primary focus, all published endpoint URLs grouped in one place +- Always-on side drawer for fast navigation between pages without reopening a menu +- Navigation highlights the currently active page +- Setup guidance built into the navigation flow to help new users move through provider add, group mapping, and publish in the right order +- Settings moved to the navigation bar for direct access + +### Channel mapping UX + +- New providers no longer mark all channels as needing review — only genuinely new channels are flagged +- Channel tracking progress shown clearly during the mapping workflow +- Group filter chips normalized with consistent colors and shapes for Pending, Include, Exclude, and New states +- Group counts and mapped/unmapped states clearer at a glance +- Fixed a case where adding a provider marked all existing channels and groups as needing review + +### Profiles + +- Fixed inactive provider issues incorrectly marking active providers as degraded on the Profiles page + +### Settings + +- Settings page reorganized to make each section easier to understand +- Authentication and Xtream endpoint settings fixed + +### Logs + +- Log search and type-based filtering added +- Actual error text now shown instead of the .NET array type name + +### Database and performance + +- Slow database load mitigations — the UI detects and displays when the database is not responding +- UI performance improvements between page transitions +- Fixed a bug where saving a snapshot could fail under certain conditions +- All alpha migrations (Alpha 1 through Alpha 7) consolidated into a single baseline migration, removing the startup migration repair path + +**Container images** + +```text +ghcr.io/sydney-elvis/m3undle:v1.0.0-alpha.7 +ghcr.io/sydney-elvis/m3undle:alpha +``` + +--- + +## [v1.0.0-alpha.6] — 2026-04-26 + +Alpha 6 focuses on practical reliability: stronger stream handling for unstable providers, better browser/HLS behavior, first-class observability, Xtream provider detection, and documentation that better matches how people are actually running M3Undle. + +### Streaming and HLS reliability + +- Hardened shared live stream handling so regular MPEG-TS clients and Generated HLS clients can coexist more predictably +- Improved HLS session accounting, cleanup, retune behavior, and stream monitor visibility +- Added stronger internal relay handling for Generated HLS playback, including MPEG-TS relay paths and safer fallback behavior +- Improved handling for shaky providers with upstream cooldowns, content-stall detection, clean relay support, and MPEG-TS startup boundary handling +- Fixed cases where HLS or relay sessions could hold stale client counts or make a parent shared stream look idle while playback was still active + +### Provider workflow and Xtream compatibility + +- Added Xtream-capable endpoint detection when adding providers, with clearer mode guidance in the UI +- Added provider account and playlist-expiration visibility where the upstream exposes it +- Improved support for Xtream/Roku-style endpoint behavior and stream URL handling +- Added per-provider refresh scheduling so provider cadence can be tuned without forcing every profile to follow the same timing + +### Observability and diagnostics + +- Added Prometheus-compatible metrics with configurable access modes (Disabled, LocalOnly, Token, Public), local CIDR controls, and token-based scraping +- Added liveness, readiness, and JSON health endpoints for containers, reverse proxies, and uptime checks +- Added authenticated diagnostics APIs for provider refreshes, streams, lineup state, and EPG behavior +- Added system event tracking and UI badging so stream/provider problems are easier to see without digging through logs first + +### Lineup, guide, and data reliability + +- Added the consolidated alpha.6 schema migration with new observability, system event, provider, and scheduling data +- Improved EPG matching, coverage analysis, and guide handling around refresh/build workflows +- Fixed guide carry-forward behavior so build-only refreshes do not keep serving an empty guide when cached EPG data is available +- Added database indexes and service-side cleanup in areas expected to matter more as catalogs grow + +### Documentation, UI, and project hygiene + +- Reworked the README with current alpha status, Docker guidance, screenshots, endpoint examples, and troubleshooting notes +- Added dedicated observability documentation covering metrics, probes, diagnostics APIs, labels, and Prometheus examples +- Updated Docker and GUI documentation around generated HLS storage, endpoint security, refresh schedules, stream proxy settings, and HDHomeRun behavior +- Continued the CLI/Core split so shared parsing, filtering, provider, EPG, and stream utilities live in the core project instead of the web layer + +### Testing + +- Expanded focused coverage around stream sharing, Generated HLS, upstream connector behavior, MPEG-TS boundary scanning, observability, refresh scheduling, system events, Xtream/provider handling, and EPG matching +- Fixed a timing-sensitive CI failure in the HDHR-only subscriber keepalive test + +**Container images** + +```text +ghcr.io/sydney-elvis/m3undle:v1.0.0-alpha.6 +ghcr.io/sydney-elvis/m3undle:alpha +``` + +--- + +[v1.0.0-alpha.7]: https://github.com/Sydney-Elvis/M3Undle/compare/v1.0.0-alpha.6...v1.0.0-alpha.7 +[v1.0.0-alpha.6]: https://github.com/Sydney-Elvis/M3Undle/compare/v1.0.0-alpha.5...v1.0.0-alpha.6 diff --git a/README.md b/README.md index 416ae4c..502c3d9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Release](https://img.shields.io/github/v/release/Sydney-Elvis/M3Undle?include_prereleases&style=flat-square)](https://github.com/Sydney-Elvis/M3Undle/releases/latest) [![License](https://img.shields.io/github/license/Sydney-Elvis/M3Undle?style=flat-square)](LICENSE) -[**Sponsor**](https://github.com/sponsors/Sydney-Elvis) | [**Buy Me a Coffee**](https://buymeacoffee.com/jake1164s) | [**Changelog**](https://github.com/Sydney-Elvis/M3Undle/releases) | [**Docker**](https://github.com/Sydney-Elvis/M3Undle/pkgs/container/m3undle) +[**Sponsor**](https://github.com/sponsors/Sydney-Elvis) | [**Buy Me a Coffee**](https://buymeacoffee.com/jake1164s) | [**Changelog**](CHANGELOG.md) | [**Docker**](https://github.com/Sydney-Elvis/M3Undle/pkgs/container/m3undle) @@ -26,19 +26,19 @@ Works with clients such as NextPVR, Jellyfin, IPTVnator, IPTV Smarters, and othe ![M3Undle dashboard showing system status, active profile, published channel counts, and output URLs](docs/images/readme-dashboard.png) > [!IMPORTANT] -> **Alpha Status** +> **Beta Status** > -> M3Undle has completed Alpha 6. The core workflow is implemented, streaming has been hardened with shared-stream support and relay fallback for unstable providers, and observability endpoints are in place. Alpha 7 is focused on interface polish before moving into beta. +> M3Undle has completed all alpha milestones and is entering beta. The core workflow is fully implemented: streaming is hardened with shared-stream support, adaptive stream health tracking, and relay policy for unstable providers. Observability, Xtream detection, HDHomeRun integration, and interface polish are all in place. > -> It is stable enough for real LAN testing and personal use, but it is still alpha software. Expect rough edges and possible provider or client-specific issues before beta. +> Beta focuses on broader DVR client validation, documentation, and final hardening before a stable release. It is suitable for real LAN use, but expect provider- and client-specific edge cases to surface during testing. ## Run it M3Undle is published to GitHub Container Registry. -Pull the current alpha image: +Pull the current beta image: - docker pull ghcr.io/sydney-elvis/m3undle:alpha + docker pull ghcr.io/sydney-elvis/m3undle:beta Create a working directory: @@ -50,7 +50,7 @@ Create `compose.yaml`: services: m3undle: - image: ghcr.io/sydney-elvis/m3undle:alpha + image: ghcr.io/sydney-elvis/m3undle:beta container_name: m3undle ports: - "5004:5004" @@ -82,7 +82,7 @@ Port `8080` serves the web UI, M3U, XMLTV, Xtream, and general compatibility end The `config` folder is bind-mounted so configuration files stay easy to inspect and back up. Runtime state, logs, snapshots, and browser playback working files use the Docker-managed `m3undle_data` volume. -Use `alpha` for the latest alpha build. Specific release tags are listed on the [M3Undle container registry page](https://github.com/Sydney-Elvis/M3Undle/pkgs/container/m3undle). +Use `beta` for the latest beta build. Specific release tags are listed on the [M3Undle container registry page](https://github.com/Sydney-Elvis/M3Undle/pkgs/container/m3undle). `latest` is not used during alpha or beta. It will be introduced no earlier than the release candidate track. @@ -127,7 +127,7 @@ Publish the same managed lineup through M3U, XMLTV, HDHomeRun-compatible, and Xt ### Streaming -Proxy live streams through M3Undle, hide provider credentials from clients, share live streams across multiple downstream clients, and monitor active stream sessions. +Proxy live streams through M3Undle, hide provider credentials from clients, share live streams across multiple downstream clients, and monitor active stream sessions. M3Undle tracks per-channel stream health (Stable, Cautious, Unstable) and uses configurable relay policy to handle noisy provider channels without disrupting connected clients. ![Stream Monitor showing two active sessions with buffer usage and three connected clients sharing streams](docs/images/readme-streams.png) @@ -239,13 +239,13 @@ When reporting an issue, include the M3Undle version tag, Docker compose file wi ## Roadmap -Alpha 6 is released. Current work is Alpha 7 interface polish before moving into beta. +All alpha milestones are complete. M3Undle is now in beta. -Planned release path: +Release path: 1. Alpha 6 — released. -2. Alpha 7 — interface polish and UX improvements. -3. Beta — broader testing, documentation cleanup, and client compatibility work. +2. Alpha 7 — released. Adaptive stream recovery, relay policy, stream health tracking, interface polish, and HDHomeRun improvements. +3. **Beta — current.** Broader DVR client validation, documentation, and final hardening. 4. Release candidate — final validation and packaging. 5. v1.0.0 — stable release. diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 4ec60df..85cf2b8 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -22,7 +22,7 @@ docker compose up -d ```yaml services: m3undle: - image: ghcr.io/sydney-elvis/m3undle:alpha + image: ghcr.io/sydney-elvis/m3undle:beta container_name: m3undle user: "${PUID}:${PGID}" ports: @@ -92,7 +92,7 @@ docker run -d \ -v ./config:/config \ -v ./data:/data \ --restart unless-stopped \ - ghcr.io/sydney-elvis/m3undle:alpha + ghcr.io/sydney-elvis/m3undle:beta ``` --- @@ -463,7 +463,7 @@ For auto-discovery to work identically to a real HDHomeRun (including from other ```yaml services: m3undle: - image: ghcr.io/sydney-elvis/m3undle:alpha + image: ghcr.io/sydney-elvis/m3undle:beta container_name: m3undle network_mode: host environment: @@ -538,12 +538,11 @@ Database migrations run automatically on startup. | Tag | Tracks | Notes | |---|---|---| -| `v1.0.0-alpha.1` | Exact version — immutable | Pin to this if you want full control over updates | -| `alpha` | Latest alpha release | Moves forward as new alpha builds are published | -| `beta` | Latest beta release | Available from v1.0.0-beta.1 | +| `v1.0.0-beta.1` | Exact version — immutable | Pin to this if you want full control over updates | +| `beta` | Latest beta release | Moves forward as new beta builds are published | | `latest` | Latest **stable** release | Not published until v1.0.0 — do not use during pre-release | -**Current phase:** alpha — use the `alpha` tag or pin to a specific version like `v1.0.0-alpha.1`. +**Current phase:** beta — use the `beta` tag or pin to a specific version like `v1.0.0-beta.1`. `latest` does not exist yet. Pulling it will return "image not found". diff --git a/docs/GUI.md b/docs/GUI.md index 20333ec..7fa5f98 100644 --- a/docs/GUI.md +++ b/docs/GUI.md @@ -11,6 +11,8 @@ The interface emphasizes: - Clear visibility - No hidden logic +UI consistency rules used by contributors and AI agents are documented in `docs/dev/GUI_CONSISTENCY_CONTRACT.md`. + --- ## UI Sections @@ -27,6 +29,7 @@ Configuration includes: - Timeout settings - Enabled/disabled toggle - Optional per-provider max concurrent stream limit +- Relay policy — controls how M3Undle handles the stream from this provider: **Auto** (applies clean relay for channels classified as Unstable), **On** (always clean relay regardless of health), or **Off** (direct relay only) The UI shows: @@ -153,6 +156,8 @@ When the simultaneous-stream cap is full, zero-viewer streams waiting in their d MPEG-TS CDN-gap handling is automatic. M3Undle can send null-packet keepalives for external non-HDHomeRun players during short upstream gaps, while HDHomeRun-only sessions are left as plain upstream MPEG-TS for DVR compatibility. The advanced content-stall timeout for that behavior is configured through appsettings/environment variables rather than the UI. +M3Undle tracks per-channel stream health across sessions. Each channel is classified as **Stable**, **Cautious**, or **Unstable** based on observed disconnect and recovery events, and that classification persists in the database. The stream monitor shows the startup health and relay policy decision for each active session so you can see which channels are being handled with additional relay protection. + For HDHomeRun-style access, tuner ownership is tracked by the configured `Virtual Tuner ID`, not by remote IP. Re-tuning from the same virtual tuner replaces the prior playback session instead of consuming another tuner slot. **Browser Playback** @@ -272,6 +277,27 @@ The **Manage Numbers** button in the page header switches the page into Number M --- +### 9. HDHomeRun + +The HDHomeRun page shows the M3Undle device identity and endpoint URLs for clients that use HDHomeRun-compatible device discovery. + +It displays: + +- Device name, device ID, and firmware version (as presented to HDHomeRun clients) +- Discovery endpoint URL (`http://:5004/discover.json`) +- Tuning endpoint URL (`http://:5004/lineup.json`) +- Configured tuner count + +Use this page to confirm your HDHomeRun setup and copy the tuner URL for manual client configuration. Auto-discovery depends on Docker networking and multicast; manual setup using the URL shown here is generally more reliable. + +--- + +### 10. About + +The About page shows product information: application version, build date, and links to the project home and license. + +--- + ## UI Design Goals The Web UI is built around the following principles: diff --git a/docs/design/DB_SCHEMA.md b/docs/design/DB_SCHEMA.md index 93521e1..fcaa405 100644 --- a/docs/design/DB_SCHEMA.md +++ b/docs/design/DB_SCHEMA.md @@ -31,7 +31,7 @@ - created_utc (TEXT) - updated_utc (TEXT) -Note: `providers.is_active` was removed in migration `Alpha6_ActiveProfile`. Active state now lives on `profiles.is_active`. +Note: active state lives on `profiles.is_active`. Indexes: - idx_providers_enabled(enabled) diff --git a/docs/dev/ALPHA5_VALIDATION_CHECKLIST.md b/docs/dev/ALPHA5_VALIDATION_CHECKLIST.md index a446e38..602b3f3 100644 --- a/docs/dev/ALPHA5_VALIDATION_CHECKLIST.md +++ b/docs/dev/ALPHA5_VALIDATION_CHECKLIST.md @@ -45,7 +45,7 @@ Legend: `[ ]` not started | `[x]` passed | `[!]` failed / investigate - [x] Make `ChannelSessionManager.RemoveIfClosedAsync()` atomic - [x] Add a transaction around `ProfilesPageService.DeleteProfileAsync()` - [x] Block disabled profiles from being activated in `ProfilesPageService.SetProfileActiveAsync()` -- [x] Harden `20260404000000_Alpha6_ActiveProfile` migration table rebuild behavior +- [x] Harden active-profile schema handling ## Post-Review Medium Coverage (2026-04-10) diff --git a/docs/dev/GUI_CONSISTENCY_CONTRACT.md b/docs/dev/GUI_CONSISTENCY_CONTRACT.md new file mode 100644 index 0000000..25d86ff --- /dev/null +++ b/docs/dev/GUI_CONSISTENCY_CONTRACT.md @@ -0,0 +1,91 @@ +# GUI Consistency Contract + +This contract defines stable UI semantics for labels, status indicators, and compact controls in the M3Undle web app. + +## Scope + +Applies to all Razor components under `src/M3Undle.Web/Components`. + +## Chip Contract + +### Intent types + +- `status`: current state of a resource or workflow. +- `severity`: issue seriousness. +- `filter`: toggle that changes current view. +- `navigation`: chip that navigates to another page/section. +- `count`: passive numeric summary. +- `metadata`: taxonomy or informational tag. + +### Color semantics + +- `Color.Success`: healthy, active, published, successful. +- `Color.Warning`: pending, degraded, caution-required. +- `Color.Error`: failed, blocked, or critical issue. +- `Color.Info`: informational, newly discovered, neutral context. +- `Color.Default`: inactive, disabled, unknown, or neutral baseline. +- `Color.Primary`: app-primary taxonomy or selected object identity. + +Do not reuse `Color.Warning` or `Color.Success` for purely decorative emphasis. + +### Variant semantics + +- `Variant.Filled`: active selection or user-applied filter state. +- `Variant.Outlined`: passive display, inactive filter, or neutral status. +- `Variant.Text`: lightweight inline hints only. + +### Tooltip requirements + +A tooltip is required for: + +- every clickable chip (`OnClick` or `Href`), +- every status/severity chip whose meaning is not obvious from plain text, +- every count chip where the counted entity may be ambiguous. + +Tooltip text should answer: + +- what this represents, +- what click does (if clickable). + +### Click affordance rules + +- Clickable chips must use `Style="cursor:pointer;"`. +- Non-clickable chips must not imply interaction. +- In a related chip group, avoid mixing clickable and non-clickable chips unless each clickable chip has a clear tooltip. + +### Accessibility rules + +- Icon-only actions near chips should have a tooltip. +- Prefer explicit text over color-only communication. +- If a state is critical, pair color with icon and/or text label. + +## Icon Button Contract + +- Icon-only buttons with actions must have a tooltip. +- Destructive actions must use `Color.Error`. +- Data refresh/reload actions should use refresh icon plus tooltip text beginning with "Reload". + +## Alert Contract + +- `Severity.Error`: operation failed or data is unusable. +- `Severity.Warning`: risky or degraded but still usable. +- `Severity.Info`: contextual or setup guidance. +- `Severity.Success`: operation completed successfully. + +Keep alert copy actionable and concise. + +## Inline Style Contract + +- Prefer shared component classes and MudBlazor props over ad-hoc inline `Style`. +- Inline styles are acceptable for one-off layout constraints, but repeated styles should move to CSS. + +## Enforcement Notes For AI Agents + +Before finalizing UI changes: + +1. Verify chip intent category and semantic color/variant. +2. Verify tooltip and click affordance requirements. +3. Check for mixed interactive vs passive chips in the same row. +4. Run solution build and tests. + +If this contract conflicts with an existing screen pattern, preserve behavior and add a follow-up note in the PR description for contract alignment work. diff --git a/docs/dev/PROJECT_PLAN.md b/docs/dev/PROJECT_PLAN.md index c7a36f9..2e73af1 100644 --- a/docs/dev/PROJECT_PLAN.md +++ b/docs/dev/PROJECT_PLAN.md @@ -23,9 +23,10 @@ Primary published endpoints: - Alpha 2: complete - Alpha 3: complete - Alpha 4: complete — stream proxy, HDHomeRun tuner-slot enforcement, and EPG sources implemented; all checklist items passed; DVR client validation (Plex/Emby/Jellyfin) moved to Beta -- Alpha 5: in progress — most items complete; active profile switching and lineup status in final polish -- Alpha 6: planned — per-provider gateway/VPN support -- Beta: hardening and release prep +- Alpha 5: complete — active profile switching, lineup status, channel review queue, dynamic groups, downstream integrations, HLS browser playback +- Alpha 6: complete — per-provider gateway/VPN routing, Xtream auto-detection, system event badge, observability endpoints, provider expiry +- Alpha 7: complete — adaptive stream recovery, channel health tracking (Stable/Cautious/Unstable), relay policy (auto/on/off), stream monitor improvements, HDHR page, About page, interface polish +- Beta: in progress — DVR client validation, documentation, hardening ## Release Milestones @@ -194,21 +195,47 @@ Status: In progress. (Related issue seeds: #3, #4, #5, #6, #7, #8, #9) ### Alpha 6 — Per-Provider Gateway Support, Xtream Auto-Detection & System Events Goal: Per-provider gateway/VPN routing with Block and Fallback modes. Xtream Codes auto-detection at provider add time with explicit user mode selection. System event infrastructure with nav bar badge for diagnostic visibility. Gateway documentation and companion gateway project remain insiders features. -Status: Planned. +Status: Complete. -- [ ] Per-provider gateway/VPN routing (Block and Fallback modes) -- [ ] Xtream Codes auto-detection at provider add time -- [ ] System event badge (nav bar, in-memory, diagnostic) +- [x] Per-provider gateway/VPN routing (Block and Fallback modes) +- [x] Xtream Codes auto-detection at provider add time +- [x] System event badge (nav bar, in-memory, diagnostic) +- [x] Prometheus-compatible metrics (LocalOnly, Token, Public modes) +- [x] Liveness/readiness/health probes +- [x] Authenticated diagnostics APIs +- [x] Provider account and playlist expiration visibility +- [x] Per-provider refresh scheduling See implementation plans: - [AUTOMATION_LAB_INTEGRATION_PLAN.md](../../.ai_docs/AUTOMATION_LAB_INTEGRATION_PLAN.md) — automation lab integration (provider seed, readiness endpoints, streaming test scenarios) - [XTREM_PROVIDER_DETECTION.md](../../.ai_docs/XTREM_PROVIDER_DETECTION.md) — Xtream auto-detection - [EVENT_BADGE_SYSTEM.md](../../.ai_docs/EVENT_BADGE_SYSTEM.md) — system event badge +### Alpha 7 — Adaptive Stream Recovery & Interface Polish +Goal: Make live stream handling robust for noisy/unstable providers. Polish interfaces and navigation before beta. + +Status: Complete. + +- [x] Adaptive stream recovery — detect stalls, recover from safe MPEG-TS boundaries, force hard controlled retune when needed +- [x] Channel health tracking — per-channel Stable/Cautious/Unstable classification persisted to DB +- [x] Relay policy — explicit per-provider Auto/On/Off setting replacing hidden clean-remux toggle +- [x] Stream health events — durable clean-watch evidence and health promotion/demotion logic +- [x] Stream monitor improvements — transfer rates, relay decision reason, startup health visible +- [x] HDHR discovery page — dedicated HDHomeRun page showing device info and endpoint URLs +- [x] About page — product info, version, build details +- [x] Dashboard reorganization — endpoints, stream limiting, and active-profile visibility improvements +- [x] Channel mapping UX — new channels not tracked by default; clearer group/chip display +- [x] Navigation improvements — guided setup flow, highlighted active page +- [x] Database performance — slow-DB mitigations, UI shows DB response status +- [x] Controlled-retune fix — retune suppressed when internal HLS relay subscriber is attached +- [x] IPTVnator compatibility fixes + +--- + ### Beta — Hardening & Release Prep Goal: No major feature additions. Stabilize, validate, and document. -Status: Planned. +Status: In progress. - [ ] Security review - [ ] Performance validation for large providers diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..ce0aab7 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.7", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/release-notes-v1.0.0-alpha.6.md b/release-notes-v1.0.0-alpha.6.md deleted file mode 100644 index d10363a..0000000 --- a/release-notes-v1.0.0-alpha.6.md +++ /dev/null @@ -1,70 +0,0 @@ -## M3Undle v1.0.0-alpha.6 (Alpha) - -M3Undle is a self-hosted lineup manager for large streaming provider catalogs, focused on explicit control, stable output, and DVR-friendly publishing. - -This alpha milestone focuses on practical reliability: stronger stream handling for unstable providers, better browser/HLS behavior, first-class observability, Xtream provider detection, and documentation that better matches how people are actually running M3Undle. - ---- - -## What's in this release - -### Streaming and HLS reliability -- Hardened shared live stream handling so regular MPEG-TS clients and Generated HLS clients can coexist more predictably -- Improved HLS session accounting, cleanup, retune behavior, and stream monitor visibility -- Added stronger internal relay handling for Generated HLS playback, including MPEG-TS relay paths and safer fallback behavior -- Improved handling for shaky providers with upstream cooldowns, content-stall detection, clean relay support, and MPEG-TS startup boundary handling -- Fixed cases where HLS or relay sessions could hold stale client counts or make a parent shared stream look idle while playback was still active - -### Provider workflow and Xtream compatibility -- Added Xtream-capable endpoint detection when adding providers, with clearer mode guidance in the UI -- Added provider account and playlist-expiration visibility where the upstream exposes it -- Improved support for Xtream/Roku-style endpoint behavior and stream URL handling -- Added per-provider refresh scheduling so provider cadence can be tuned without forcing every profile to follow the same timing - -### Observability and diagnostics -- Added Prometheus-compatible metrics with configurable access modes, local CIDR controls, and token-based scraping -- Added liveness, readiness, and JSON health endpoints for containers, reverse proxies, and uptime checks -- Added authenticated diagnostics APIs for provider refreshes, streams, lineup state, and EPG behavior -- Added system event tracking and UI badging so stream/provider problems are easier to see without digging through logs first - -### Lineup, guide, and data reliability -- Added the consolidated alpha.6 schema migration with new observability, system event, provider, and scheduling data -- Improved EPG matching, coverage analysis, and guide handling around refresh/build workflows -- Fixed guide carry-forward behavior so build-only refreshes do not keep serving an empty guide when cached EPG data is available -- Added database indexes and service-side cleanup in areas expected to matter more as catalogs grow - -### Documentation, UI, and project hygiene -- Reworked the README with current alpha status, Docker guidance, screenshots, endpoint examples, and troubleshooting notes -- Added dedicated observability documentation covering metrics, probes, diagnostics APIs, labels, and Prometheus examples -- Updated Docker and GUI documentation around generated HLS storage, endpoint security, refresh schedules, stream proxy settings, and HDHomeRun behavior -- Continued the CLI/Core split so shared parsing, filtering, provider, EPG, and stream utilities live in the core project instead of the web layer - -### Testing -- Expanded focused coverage around stream sharing, Generated HLS, upstream connector behavior, MPEG-TS boundary scanning, observability, refresh scheduling, system events, Xtream/provider handling, and EPG matching -- Added regression coverage for several alpha.6 stream lifecycle and guide refresh fixes -- Fixed a timing-sensitive CI failure in the HDHR-only subscriber keepalive test caused by a shared cancellation token being used for both subscriber lifetime and an intentional observation delay - ---- - -## Container Images - -```text -ghcr.io/sydney-elvis/m3undle:v1.0.0-alpha.6 -ghcr.io/sydney-elvis/m3undle:alpha -``` - -> `alpha` is a rolling tag and points to the latest alpha release. - ---- - -## Known alpha limitations - -Still alpha: streaming, HLS, Xtream, HDHomeRun, and observability behavior are much stronger in this release, but broader beta validation across DVR and player clients is still ongoing. Plex and Emby Live TV/DVR testing remain limited by their paid subscription requirements, and provider-specific stream quirks may still need follow-up tuning before beta. - ---- - -## Contributor - -@jake1164 - -**Full Changelog:** https://github.com/Sydney-Elvis/M3Undle/compare/v1.0.0-alpha.5...v1.0.0-alpha.6 diff --git a/src/CoreVersion.props b/src/CoreVersion.props index eb46053..920f1a5 100644 --- a/src/CoreVersion.props +++ b/src/CoreVersion.props @@ -1,6 +1,6 @@ - 1.0.0-alpha.6 + 1.0.0-alpha.7 $(Version) 1.0.0.0 1.0.0.0 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index eee73b9..9c6ba52 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -25,8 +25,23 @@ + + + + + + + + <_GitShortHash>$(_GitShortHash.Trim()) + + + + + + obj/ diff --git a/src/M3Undle.Core/AppBuildInfo.cs b/src/M3Undle.Core/AppBuildInfo.cs index 14c90f3..5ebe984 100644 --- a/src/M3Undle.Core/AppBuildInfo.cs +++ b/src/M3Undle.Core/AppBuildInfo.cs @@ -2,7 +2,7 @@ namespace M3Undle.Core; -public sealed record AppBuildInfo(string Version, string? BuildDateUtc, string? BuildNumber) +public sealed record AppBuildInfo(string Version, string? BuildDateUtc, string? BuildNumber, string? GitHash) { public static AppBuildInfo ForEntryAssembly() { @@ -20,6 +20,7 @@ public static AppBuildInfo FromAssembly(Assembly assembly) string? buildDateUtc = null; string? buildNumber = null; + string? gitHash = null; foreach (var attribute in assembly.GetCustomAttributes()) { @@ -32,10 +33,25 @@ public static AppBuildInfo FromAssembly(Assembly assembly) if (buildNumber is null && string.Equals(attribute.Key, "BuildNumber", StringComparison.OrdinalIgnoreCase)) { buildNumber = attribute.Value; + continue; + } + + if (gitHash is null && string.Equals(attribute.Key, "GitHash", StringComparison.OrdinalIgnoreCase)) + { + gitHash = NormalizeGitHash(attribute.Value); } } - return new AppBuildInfo(version, buildDateUtc, buildNumber); + return new AppBuildInfo(version, buildDateUtc, buildNumber, gitHash); + } + + private static string? NormalizeGitHash(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return value; + + var trimmed = value.Trim(); + return trimmed.Length > 7 ? trimmed[..7] : trimmed; } public string ToDisplayString() diff --git a/src/M3Undle.Core/MpegTs/MpegTsBoundaryScanner.cs b/src/M3Undle.Core/MpegTs/MpegTsBoundaryScanner.cs index ea20a48..ab4f554 100644 --- a/src/M3Undle.Core/MpegTs/MpegTsBoundaryScanner.cs +++ b/src/M3Undle.Core/MpegTs/MpegTsBoundaryScanner.cs @@ -67,13 +67,13 @@ public sealed class MpegTsBoundaryScanner if (output.Count == 0) return dropped > 0 || syncLost - ? new MpegTsPacketBatch([], MpegTsStartupKind.None, dropped, syncLost) + ? new MpegTsPacketBatch([], MpegTsStartupKind.None, dropped, syncLost, _videoPid is not null) : null; if (startupKind == MpegTsStartupKind.None) startupKind = MpegTsStartupKind.PacketBoundary; - return new MpegTsPacketBatch([.. output], startupKind, dropped, syncLost); + return new MpegTsPacketBatch([.. output], startupKind, dropped, syncLost, _videoPid is not null); } public void Reset() diff --git a/src/M3Undle.Core/MpegTs/MpegTsPacketBatch.cs b/src/M3Undle.Core/MpegTs/MpegTsPacketBatch.cs index 759b3e6..4370df7 100644 --- a/src/M3Undle.Core/MpegTs/MpegTsPacketBatch.cs +++ b/src/M3Undle.Core/MpegTs/MpegTsPacketBatch.cs @@ -4,4 +4,5 @@ public sealed record MpegTsPacketBatch( byte[] Data, MpegTsStartupKind StartupKind, int DroppedByteCount, - bool SyncLost); + bool SyncLost, + bool HasKnownH264VideoStream = false); diff --git a/src/M3Undle.Web/Api/CompatibilityEndpoints.cs b/src/M3Undle.Web/Api/CompatibilityEndpoints.cs index dda703a..a01caa6 100644 --- a/src/M3Undle.Web/Api/CompatibilityEndpoints.cs +++ b/src/M3Undle.Web/Api/CompatibilityEndpoints.cs @@ -97,6 +97,9 @@ public static IEndpointRouteBuilder MapCompatibilityEndpoints(this IEndpointRout debug.MapGet("/streams/strikes", ServeDebugStrikesAsync); debug.MapGet("/streams/rca", ServeDebugStreamRcaAsync); debug.MapGet("/snapshots/idle", ServeDebugSnapshotIdleAsync); + debug.MapPost("/stream-health/events/seed", ServeDebugStreamHealthSeedAsync); + debug.MapDelete("/stream-health/events/{providerId}/{providerChannelId}", ServeDebugStreamHealthDeleteAsync); + debug.MapGet("/stream-health/{providerId}/{providerChannelId}", ServeDebugStreamHealthAsync); } return app; @@ -1158,12 +1161,14 @@ private static async Task ServeReadinessAsync( reasons.Add("no active snapshot for active profile"); } - if (refreshTrigger.IsRefreshing) + if (refreshTrigger.IsRefreshing && reasons.Count > 0) reasons.Add("refresh in progress"); + var reason = string.Join("; ", reasons); + return reasons.Count == 0 ? Results.Ok(new { ready = true }) - : Results.Json(new { ready = false, reasons }, statusCode: StatusCodes.Status503ServiceUnavailable); + : Results.Json(new { ready = false, reason, reasons }, statusCode: StatusCodes.Status503ServiceUnavailable); } private static IResult ServeDebugStrikesAsync(UpstreamFailureStrikeStore strikeStore) @@ -1217,6 +1222,102 @@ private static async Task ServeDebugSnapshotIdleAsync( }, JsonOptions); } + private static async Task ServeDebugStreamHealthSeedAsync( + StreamHealthSeedRequest request, + ApplicationDbContext db, + IStreamChannelHealthProfileService healthProfileService, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.ProviderId) + || string.IsNullOrWhiteSpace(request.ProviderChannelId) + || string.IsNullOrWhiteSpace(request.DisplayName) + || request.Events is null + || request.Events.Count == 0) + { + return Results.BadRequest(new + { + error = "providerId, providerChannelId, displayName, and at least one event are required.", + }); + } + + var rows = new List(request.Events.Count); + foreach (var seededEvent in request.Events) + { + if (!Enum.TryParse(seededEvent.EventKind, ignoreCase: true, out var eventKind)) + { + return Results.BadRequest(new + { + error = $"Unknown stream health event kind '{seededEvent.EventKind}'.", + }); + } + + rows.Add(new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = request.ProviderId, + ProviderChannelId = request.ProviderChannelId, + DisplayName = request.DisplayName, + EventKind = eventKind.ToString(), + EventUtc = (seededEvent.EventUtc ?? DateTimeOffset.UtcNow).UtcDateTime, + SessionId = seededEvent.SessionId, + RelayMode = seededEvent.RelayMode, + RouteClassification = "debug_seed", + UpstreamFailureKind = seededEvent.UpstreamFailureKind, + ReconnectAttempt = seededEvent.ReconnectAttempt, + SafeStartKind = seededEvent.SafeStartKind, + ClientAbortAfterRecovery = seededEvent.ClientAbortAfterRecovery + || eventKind == StreamDiagnosticEventKind.ClientAbortAfterRecovery, + ForcedRetune = seededEvent.ForcedRetune + || eventKind is StreamDiagnosticEventKind.RecoveryForcedRetune + or StreamDiagnosticEventKind.ControlledDownstreamRetune, + TsSyncLoss = seededEvent.TsSyncLoss + || eventKind == StreamDiagnosticEventKind.MpegTsSyncLost, + CleanWatchDurationMs = seededEvent.CleanWatchDurationSeconds is { } cleanWatchSeconds + ? Math.Max(0, cleanWatchSeconds) * 1000 + : null, + }); + } + + db.StreamChannelHealthEvents.AddRange(rows); + await db.SaveChangesAsync(cancellationToken); + healthProfileService.Invalidate(request.ProviderId, request.ProviderChannelId); + return Results.Ok(new + { + seeded = rows.Count, + request.ProviderId, + request.ProviderChannelId, + }); + } + + private static async Task ServeDebugStreamHealthDeleteAsync( + string providerId, + string providerChannelId, + ApplicationDbContext db, + IStreamChannelHealthProfileService healthProfileService, + CancellationToken cancellationToken) + { + var deleted = await db.StreamChannelHealthEvents + .Where(e => e.ProviderId == providerId && e.ProviderChannelId == providerChannelId) + .ExecuteDeleteAsync(cancellationToken); + healthProfileService.Invalidate(providerId, providerChannelId); + return Results.Ok(new { cleared = true, deleted, providerId, providerChannelId }); + } + + private static async Task ServeDebugStreamHealthAsync( + string providerId, + string providerChannelId, + IStreamChannelHealthProfileService healthProfileService, + IOptions reconnectOptions, + CancellationToken cancellationToken) + { + var evidence = await healthProfileService.GetEvidenceAsync( + providerId, + providerChannelId, + reconnectOptions.Value, + cancellationToken); + return Results.Json(evidence, JsonOptions); + } + private static async Task ServeInternalRelayAsync( string providerId, string channelId, @@ -1472,4 +1573,23 @@ private sealed record StreamRcaBundle( IReadOnlyList ActiveProviderStreams, IReadOnlyList ActiveCooldowns, IReadOnlyList RecentEvents); + + private sealed record StreamHealthSeedRequest( + string ProviderId, + string ProviderChannelId, + string DisplayName, + IReadOnlyList Events); + + private sealed record StreamHealthSeedEvent( + string EventKind, + DateTimeOffset? EventUtc = null, + string? SessionId = null, + string? RelayMode = null, + string? UpstreamFailureKind = null, + int? ReconnectAttempt = null, + string? SafeStartKind = null, + bool ClientAbortAfterRecovery = false, + bool ForcedRetune = false, + bool TsSyncLoss = false, + double? CleanWatchDurationSeconds = null); } diff --git a/src/M3Undle.Web/Api/DiagnosticsApiEndpoints.cs b/src/M3Undle.Web/Api/DiagnosticsApiEndpoints.cs index 405e7ac..4e8fbcc 100644 --- a/src/M3Undle.Web/Api/DiagnosticsApiEndpoints.cs +++ b/src/M3Undle.Web/Api/DiagnosticsApiEndpoints.cs @@ -2,8 +2,10 @@ using M3Undle.Web.Application.Epg; using M3Undle.Web.Data; using M3Undle.Web.Security; +using M3Undle.Web.Streaming.Configuration; using M3Undle.Web.Streaming.Observability; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; namespace M3Undle.Web.Api; @@ -17,12 +19,34 @@ public static IEndpointRouteBuilder MapDiagnosticsApiEndpoints(this IEndpointRou group.MapGet("/providers", GetProvidersAsync).WithSummary("Get provider diagnostics"); group.MapGet("/streams", GetStreams).WithSummary("Get stream diagnostics"); + group.MapGet("/streams/{sessionId}/health-evidence", GetStreamHealthEvidenceAsync) + .WithSummary("Get stream channel health evidence for a session"); group.MapGet("/lineup", GetLineupAsync).WithSummary("Get lineup diagnostics"); group.MapGet("/epg", GetEpgAsync).WithSummary("Get EPG diagnostics"); return app; } + private static async Task GetStreamHealthEvidenceAsync( + string sessionId, + StreamingRegistry registry, + IStreamChannelHealthProfileService healthProfileService, + IOptions reconnectOptions, + CancellationToken cancellationToken) + { + var session = registry.TryGetSession(sessionId); + if (session is null) + return TypedResults.NotFound(); + + var evidence = await healthProfileService.GetEvidenceAsync( + session.ProviderId, + session.ProviderChannelId, + reconnectOptions.Value, + cancellationToken); + + return Results.Json(evidence); + } + private static async Task GetProvidersAsync( ApplicationDbContext db, TimeProvider timeProvider, diff --git a/src/M3Undle.Web/Api/HdHomeRunEndpoints.cs b/src/M3Undle.Web/Api/HdHomeRunEndpoints.cs index 5d53f0d..71117e1 100644 --- a/src/M3Undle.Web/Api/HdHomeRunEndpoints.cs +++ b/src/M3Undle.Web/Api/HdHomeRunEndpoints.cs @@ -26,6 +26,7 @@ public static IEndpointRouteBuilder MapHdHomeRunEndpoints(this IEndpointRouteBui { var client = app.MapClientSurface(); var hdhr = client.MapGroup("hdhr"); + hdhr.AddEndpointFilter(); hdhr.MapGet("discover.json", ServeDiscoverAsync); hdhr.MapGet("lineup.json", ServeLineupJsonAsync); @@ -37,14 +38,17 @@ public static IEndpointRouteBuilder MapHdHomeRunEndpoints(this IEndpointRouteBui hdhr.MapGet("device.xml", ServeDeviceXmlAsync); // Legacy aliases kept for HDHR client compatibility. - client.MapGet("discover.json", ServeDiscoverAsync); - client.MapGet("lineup.json", ServeLineupJsonAsync); - client.MapGet("lineup.xml", ServeLineupXmlAsync); - client.MapGet("lineup.m3u", ServeLineupM3uAsync); - client.MapGet("lineup_status.json", ServeLineupStatusAsync); - client.MapGet("lineup.post", ServeLineupPost); - client.MapPost("lineup.post", ServeLineupPost); - client.MapGet("device.xml", ServeDeviceXmlAsync); + var legacyHdhr = client.MapGroup(string.Empty); + legacyHdhr.AddEndpointFilter(); + + legacyHdhr.MapGet("discover.json", ServeDiscoverAsync); + legacyHdhr.MapGet("lineup.json", ServeLineupJsonAsync); + legacyHdhr.MapGet("lineup.xml", ServeLineupXmlAsync); + legacyHdhr.MapGet("lineup.m3u", ServeLineupM3uAsync); + legacyHdhr.MapGet("lineup_status.json", ServeLineupStatusAsync); + legacyHdhr.MapGet("lineup.post", ServeLineupPost); + legacyHdhr.MapPost("lineup.post", ServeLineupPost); + legacyHdhr.MapGet("device.xml", ServeDeviceXmlAsync); return app; } diff --git a/src/M3Undle.Web/Api/SiteSettingsApiEndpoints.cs b/src/M3Undle.Web/Api/SiteSettingsApiEndpoints.cs index 00f6bf7..067a3d2 100644 --- a/src/M3Undle.Web/Api/SiteSettingsApiEndpoints.cs +++ b/src/M3Undle.Web/Api/SiteSettingsApiEndpoints.cs @@ -15,6 +15,8 @@ public static IEndpointRouteBuilder MapSiteSettingsApiEndpoints(this IEndpointRo group.MapGet("/endpoint-security", GetEndpointSecurityAsync).WithSummary("Get endpoint security settings"); group.MapPut("/endpoint-security", UpdateEndpointSecurityAsync).WithSummary("Update endpoint security settings"); + group.MapGet("/streaming", GetStreamingSettingsAsync).WithSummary("Get streaming settings"); + group.MapPut("/streaming", UpdateStreamingSettingsAsync).WithSummary("Update streaming settings"); group.MapGet("/generated-hls", GetGeneratedHlsSettingsAsync).WithSummary("Get generated HLS settings"); group.MapPut("/generated-hls", UpdateGeneratedHlsSettingsAsync).WithSummary("Update generated HLS settings"); group.MapGet("/refresh-schedule", GetRefreshScheduleAsync).WithSummary("Get refresh schedule settings"); @@ -55,7 +57,8 @@ private static async Task, ValidationProble Username: request.Username, Password: request.Password, ActiveProfileId: request.ActiveProfileId, - VirtualTunerId: request.VirtualTunerId), cancellationToken); + VirtualTunerId: request.VirtualTunerId, + XtreamCompatibilityEnabled: request.XtreamCompatibilityEnabled), cancellationToken); if (!result.Succeeded) { @@ -78,7 +81,8 @@ private sealed record EndpointSecurityUpdateRequest( string? Username, string? Password, string? ActiveProfileId, - string? VirtualTunerId); + string? VirtualTunerId, + bool XtreamCompatibilityEnabled = true); private sealed record EndpointSecurityResponse( bool Enabled, @@ -87,6 +91,51 @@ private sealed record EndpointSecurityResponse( string? ActiveProfileId, string? VirtualTunerId); + private static async Task> GetStreamingSettingsAsync( + IStreamingSettingsService streamingSettingsService, + CancellationToken cancellationToken) + => TypedResults.Ok(await streamingSettingsService.GetSettingsAsync(cancellationToken)); + + private static async Task, ValidationProblem>> UpdateStreamingSettingsAsync( + StreamingSettingsUpdateRequest request, + IStreamingSettingsService streamingSettingsService, + CancellationToken cancellationToken) + { + var result = await streamingSettingsService.UpdateAsync(new UpdateStreamingSettingsCommand( + request.StreamingEnabled, + request.MaxConcurrentSessions, + request.IdleGraceSeconds, + request.IdleGraceHardCapSeconds, + request.BufferMaxBytesPerSession, + request.BufferMaxBytesHardCap, + request.BufferReadChunkSizeBytes, + request.ReconnectReadStallTimeoutSeconds, + request.ReconnectOutageWindowSeconds, + request.ReconnectConnectTimeoutSeconds), cancellationToken); + + if (!result.Succeeded) + { + return TypedResults.ValidationProblem(new Dictionary + { + ["streaming"] = [result.Error ?? "Streaming settings update failed."], + }); + } + + return TypedResults.Ok(await streamingSettingsService.GetSettingsAsync(cancellationToken)); + } + + private sealed record StreamingSettingsUpdateRequest( + bool StreamingEnabled, + int MaxConcurrentSessions, + int IdleGraceSeconds, + int IdleGraceHardCapSeconds, + int BufferMaxBytesPerSession, + int BufferMaxBytesHardCap, + int BufferReadChunkSizeBytes, + int ReconnectReadStallTimeoutSeconds, + int ReconnectOutageWindowSeconds, + int ReconnectConnectTimeoutSeconds); + private static async Task> GetObservabilitySettingsAsync( IObservabilitySettingsService settingsService, CancellationToken cancellationToken) @@ -294,7 +343,8 @@ private static async Task, ValidationProblem>> AdvertisedBaseUrl: request.AdvertisedBaseUrl, DiscoveryEnabled: request.DiscoveryEnabled, SsdpEnabled: request.SsdpEnabled, - SiliconDustDiscoveryEnabled: request.SiliconDustDiscoveryEnabled), cancellationToken); + SiliconDustDiscoveryEnabled: request.SiliconDustDiscoveryEnabled, + AllowedNetworks: request.AllowedNetworks), cancellationToken); if (!result.Succeeded) { @@ -323,7 +373,8 @@ private static HdhrSettingsResponse MapHdhrResponse(HdhrSettingsState state) => SsdpEnabled: state.Saved.SsdpEnabled, SiliconDustDiscoveryEnabled: state.Saved.SiliconDustDiscoveryEnabled, RestartRequired: state.RestartRequired, - DisabledByEnvironment: state.DisabledByEnvironment); + DisabledByEnvironment: state.DisabledByEnvironment, + AllowedNetworks: state.Saved.AllowedNetworks); private sealed record HdhrSettingsUpdateRequest( bool Enabled, @@ -332,7 +383,8 @@ private sealed record HdhrSettingsUpdateRequest( string? AdvertisedBaseUrl, bool DiscoveryEnabled, bool SsdpEnabled, - bool SiliconDustDiscoveryEnabled); + bool SiliconDustDiscoveryEnabled, + string? AllowedNetworks = null); private static async Task> GetEventSettingsAsync( IEventService eventService, @@ -371,5 +423,6 @@ private sealed record HdhrSettingsResponse( bool SsdpEnabled, bool SiliconDustDiscoveryEnabled, bool RestartRequired, - bool DisabledByEnvironment); + bool DisabledByEnvironment, + string? AllowedNetworks); } diff --git a/src/M3Undle.Web/Api/XtreamEndpoints.cs b/src/M3Undle.Web/Api/XtreamEndpoints.cs index 42df66a..fcbc685 100644 --- a/src/M3Undle.Web/Api/XtreamEndpoints.cs +++ b/src/M3Undle.Web/Api/XtreamEndpoints.cs @@ -594,12 +594,9 @@ await StreamErrorResponse.WriteHtmlErrorAsync( return; } - var pathBase = context.Request.PathBase.HasValue - ? context.Request.PathBase.Value!.TrimEnd('/') - : string.Empty; - var generatedManifestUrl = - $"{pathBase}/hls/generated/{Uri.EscapeDataString(generatedSession.SessionId)}/index.m3u8"; - generatedManifestUrl = generatedManifestUrl.ApplyClientAccessQuery(context); + var generatedManifestUrl = BuildGeneratedXtreamHlsManifestRedirectUrl( + context, + generatedSession.SessionId); context.Response.Redirect(generatedManifestUrl, permanent: false); return; } @@ -783,10 +780,8 @@ private static async Task ServeGeneratedXtreamHlsAssetAsync( return; } - var xtreamUser = Uri.EscapeDataString(context.Request.RouteValues["xtreamUser"]?.ToString() ?? string.Empty); - var xtreamPass = Uri.EscapeDataString(context.Request.RouteValues["xtreamPass"]?.ToString() ?? string.Empty); var manifestUrl = new Uri($"{GetBaseUrl(context)}{context.Request.Path}"); - var generatedAssetBase = $"{GetBaseUrl(context)}/hls/generated/{xtreamUser}/{xtreamPass}/{Uri.EscapeDataString(sessionId)}"; + var generatedAssetBase = BuildGeneratedXtreamHlsAssetBaseUrl(context, sessionId); var rewritten = hlsManifestRewriter.Rewrite( manifest, @@ -810,6 +805,49 @@ private static async Task ServeGeneratedXtreamHlsAssetAsync( await context.Response.SendFileAsync(filePath, cancellationToken); } + internal static string BuildGeneratedXtreamHlsManifestRedirectUrl(HttpContext context, string sessionId) + { + var pathBase = context.Request.PathBase.HasValue + ? context.Request.PathBase.Value!.TrimEnd('/') + : string.Empty; + var escapedSessionId = Uri.EscapeDataString(sessionId); + + if (TryGetEscapedXtreamPathCredentials(context, out var xtreamUser, out var xtreamPass)) + return $"{pathBase}/hls/generated/{xtreamUser}/{xtreamPass}/{escapedSessionId}/index.m3u8"; + + var fallback = $"{pathBase}/hls/generated/{escapedSessionId}/index.m3u8"; + return fallback.ApplyClientAccessQuery(context); + } + + internal static string BuildGeneratedXtreamHlsAssetBaseUrl(HttpContext context, string sessionId) + { + var escapedSessionId = Uri.EscapeDataString(sessionId); + if (TryGetEscapedXtreamPathCredentials(context, out var xtreamUser, out var xtreamPass)) + return $"{GetBaseUrl(context)}/hls/generated/{xtreamUser}/{xtreamPass}/{escapedSessionId}"; + + return $"{GetBaseUrl(context)}/hls/generated/{escapedSessionId}"; + } + + private static bool TryGetEscapedXtreamPathCredentials( + HttpContext context, + out string xtreamUser, + out string xtreamPass) + { + var rawUser = context.Request.RouteValues["xtreamUser"]?.ToString(); + var rawPass = context.Request.RouteValues["xtreamPass"]?.ToString(); + + if (string.IsNullOrWhiteSpace(rawUser) || string.IsNullOrEmpty(rawPass)) + { + xtreamUser = string.Empty; + xtreamPass = string.Empty; + return false; + } + + xtreamUser = Uri.EscapeDataString(rawUser); + xtreamPass = Uri.EscapeDataString(rawPass); + return true; + } + private static async Task ServeDirectRelayAsync( HttpContext context, ApplicationDbContext db, diff --git a/src/M3Undle.Web/Application/ChannelMappingPageService.cs b/src/M3Undle.Web/Application/ChannelMappingPageService.cs index 375fc18..5a138ab 100644 --- a/src/M3Undle.Web/Application/ChannelMappingPageService.cs +++ b/src/M3Undle.Web/Application/ChannelMappingPageService.cs @@ -37,14 +37,21 @@ public async Task> ListGroupFiltersAsync(string profileId, var filters = await db.ProfileGroupFilters .AsNoTracking() .Include(x => x.ProviderGroup).ThenInclude(g => g.Provider) - .Include(x => x.ChannelFilters) .Where(x => x.ProfileId == profileId) .ToListAsync(cancellationToken); + var selectedCounts = await db.ProfileGroupChannelFilters + .AsNoTracking() + .Where(x => x.ProfileGroupFilter.ProfileId == profileId + && x.State == LineupReviewSemantics.ChannelStateIncluded) + .GroupBy(x => x.ProfileGroupFilterId) + .Select(g => new { g.Key, Count = g.Count() }) + .ToDictionaryAsync(x => x.Key, x => x.Count, cancellationToken); + return filters .OrderByDescending(f => f.IsNew) .ThenBy(f => f.ProviderGroup.RawName, StringComparer.OrdinalIgnoreCase) - .Select(ToDto) + .Select(f => ToDto(f, selectedCounts.GetValueOrDefault(f.ProfileGroupFilterId, 0))) .ToList(); } @@ -382,11 +389,8 @@ private static void ApplySelectionItem(ProfileGroupChannelFilter row, ChannelSel row.UpdatedUtc = updatedUtc; } - private static GroupFilterDto ToDto(ProfileGroupFilter f) + private static GroupFilterDto ToDto(ProfileGroupFilter f, int selectedCount) { - var selectedCount = f.ChannelFilters? - .Count(cf => string.Equals(cf.State, LineupReviewSemantics.ChannelStateIncluded, StringComparison.Ordinal)) ?? 0; - return new GroupFilterDto { ProfileGroupFilterId = f.ProfileGroupFilterId, @@ -412,6 +416,48 @@ private static GroupFilterDto ToDto(ProfileGroupFilter f) }; } + public async Task> GetMappedChannelsPanelAsync( + string profileId, + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var snapshotCreatedUtc = await db.Snapshots + .AsNoTracking() + .Where(x => x.ProfileId == profileId && x.Status == "active") + .OrderByDescending(x => x.CreatedUtc) + .Select(x => (DateTime?)x.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + + var rows = await db.ProfileGroupChannelFilters + .AsNoTracking() + .Where(x => x.ProfileGroupFilter.ProfileId == profileId + && x.ProfileGroupFilter.Decision == LineupReviewSemantics.GroupDecisionInclude + && x.State == LineupReviewSemantics.ChannelStateIncluded + && x.ChannelNumber != null) + .Select(x => new + { + x.ChannelNumber, + DisplayName = x.DisplayNameOverride ?? x.ProviderChannel.DisplayName, + x.UpdatedUtc, + FilterId = x.ProfileGroupFilterId, + OutputName = x.ProfileGroupFilter.OutputName, + }) + .OrderBy(x => x.ChannelNumber) + .ToListAsync(cancellationToken); + + var cutoff = snapshotCreatedUtc; + return rows.Select(r => new MappedChannelPanelItem + { + ChannelNumber = r.ChannelNumber, + DisplayName = r.DisplayName, + IsLive = cutoff.HasValue && r.UpdatedUtc <= cutoff.Value, + FilterId = r.FilterId, + OutputName = r.OutputName, + }).ToList(); + } + private static string? NormalizeRequestedDecision(string decision) { if (string.IsNullOrWhiteSpace(decision)) @@ -451,7 +497,8 @@ public async Task ListReviewQueueAsync( bool notifyOnly, int page, int pageSize, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool includePendingSummary = true) { await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -461,8 +508,6 @@ public async Task ListReviewQueueAsync( var pendingBase = db.ProfileGroupChannelFilters .AsNoTracking() - .Include(x => x.ProfileGroupFilter).ThenInclude(f => f.ProviderGroup) - .Include(x => x.ProviderChannel) .Where(x => x.ProfileGroupFilter.ProfileId == profileId && x.ProfileGroupFilter.TrackingPolicy == LineupReviewSemantics.TrackingPolicyReview && x.ProfileGroupFilter.ProviderGroup.ContentType == "live" @@ -471,9 +516,23 @@ public async Task ListReviewQueueAsync( && !x.ProviderChannel.IsPlaceholder && x.State == LineupReviewSemantics.ChannelStatePending); - var pendingTotal = await pendingBase.CountAsync(cancellationToken); - var pendingNotified = await pendingBase - .CountAsync(x => x.ProfileGroupFilter.TrackNewChannels, cancellationToken); + int pendingTotal = 0; + int pendingNotified = 0; + + if (includePendingSummary) + { + var pendingCounts = await pendingBase + .GroupBy(_ => 1) + .Select(g => new + { + PendingTotal = g.Count(), + PendingNotified = g.Count(x => x.ProfileGroupFilter.TrackNewChannels), + }) + .FirstOrDefaultAsync(cancellationToken); + + pendingTotal = pendingCounts?.PendingTotal ?? 0; + pendingNotified = pendingCounts?.PendingNotified ?? 0; + } var query = pendingBase; @@ -489,7 +548,9 @@ public async Task ListReviewQueueAsync( query = query.Where(x => EF.Functions.Like(x.ProviderChannel.DisplayName.ToUpper(), $"%{term}%")); } - var total = await query.CountAsync(cancellationToken); + var total = includePendingSummary + ? await query.CountAsync(cancellationToken) + : 0; var items = await query .OrderByDescending(x => x.ProfileGroupFilter.TrackNewChannels) @@ -520,7 +581,7 @@ public async Task ListReviewQueueAsync( return new ReviewQueueListResponse { - Total = total, + Total = includePendingSummary ? total : items.Count, PendingTotal = pendingTotal, PendingNotified = pendingNotified, Items = items, diff --git a/src/M3Undle.Web/Application/ChannelStatsService.cs b/src/M3Undle.Web/Application/ChannelStatsService.cs index cf7861b..b371996 100644 --- a/src/M3Undle.Web/Application/ChannelStatsService.cs +++ b/src/M3Undle.Web/Application/ChannelStatsService.cs @@ -13,66 +13,54 @@ public async Task GetStatsAsync(CancellationToken ct) var activeProfile = await db.Profiles .AsNoTracking() - .Include(x => x.ProfileProviders) - .ThenInclude(pp => pp.Provider) - .FirstOrDefaultAsync(x => x.IsActive, ct); + .Where(x => x.IsActive) + .Select(x => new { x.ProfileId }) + .FirstOrDefaultAsync(ct); if (activeProfile is null) return new ChannelMappingStatsDto(); var profileId = activeProfile.ProfileId; - var provider = activeProfile.ProfileProviders - .Where(pp => pp.Enabled) - .OrderBy(pp => pp.Priority) - .Select(pp => pp.Provider) - .FirstOrDefault(p => p.Enabled); - - var groupsIncluded = await db.ProfileGroupFilters - .AsNoTracking() - .Include(x => x.ProviderGroup) - .CountAsync(x => x.ProfileId == profileId - && x.ProviderGroup.ContentType == "live" - && x.Decision != LineupReviewSemantics.GroupDecisionExclude, ct); - - var groupsHold = await db.ProfileGroupFilters - .AsNoTracking() - .Include(x => x.ProviderGroup) - .CountAsync(x => x.ProfileId == profileId - && x.ProviderGroup.ContentType == "live" - && x.IsNew, ct); - - var groupsNew = await db.ProfileGroupFilters + var provider = await db.ProfileProviders .AsNoTracking() - .Include(x => x.ProviderGroup) - .CountAsync(x => x.ProfileId == profileId - && x.ProviderGroup.ContentType == "live" - && x.IsNew - && x.TrackNewChannels, ct); + .Where(pp => pp.ProfileId == profileId && pp.Enabled && pp.Provider.Enabled) + .OrderBy(pp => pp.Priority) + .Select(pp => new + { + pp.ProviderId, + pp.Provider.IncludeVod, + pp.Provider.IncludeSeries, + }) + .FirstOrDefaultAsync(ct); - var pendingChannelsTotal = await db.ProfileGroupChannelFilters + var groupAggregates = await db.ProfileGroupFilters .AsNoTracking() - .Include(x => x.ProfileGroupFilter).ThenInclude(f => f.ProviderGroup) - .Include(x => x.ProviderChannel) - .CountAsync(x => x.ProfileGroupFilter.ProfileId == profileId - && x.ProfileGroupFilter.TrackingPolicy == LineupReviewSemantics.TrackingPolicyReview - && x.State == LineupReviewSemantics.ChannelStatePending - && x.ProviderChannel.Active - && x.ProviderChannel.ContentType == "live" - && !x.ProviderChannel.IsPlaceholder - && x.ProfileGroupFilter.ProviderGroup.ContentType == "live", ct); + .Where(x => x.ProfileId == profileId && x.ProviderGroup.ContentType == "live") + .GroupBy(_ => 1) + .Select(g => new + { + Included = g.Count(x => x.Decision != LineupReviewSemantics.GroupDecisionExclude), + Hold = g.Count(x => x.IsNew), + New = g.Count(x => x.IsNew && x.TrackNewChannels), + }) + .FirstOrDefaultAsync(ct); - var pendingChannelsNotified = await db.ProfileGroupChannelFilters + var pendingAggregates = await db.ProfileGroupChannelFilters .AsNoTracking() - .Include(x => x.ProfileGroupFilter).ThenInclude(f => f.ProviderGroup) - .Include(x => x.ProviderChannel) - .CountAsync(x => x.ProfileGroupFilter.ProfileId == profileId - && x.ProfileGroupFilter.TrackingPolicy == LineupReviewSemantics.TrackingPolicyReview - && x.State == LineupReviewSemantics.ChannelStatePending - && x.ProviderChannel.Active - && x.ProviderChannel.ContentType == "live" - && !x.ProviderChannel.IsPlaceholder - && x.ProfileGroupFilter.ProviderGroup.ContentType == "live" - && x.ProfileGroupFilter.TrackNewChannels, ct); + .Where(x => x.ProfileGroupFilter.ProfileId == profileId + && x.ProfileGroupFilter.TrackingPolicy == LineupReviewSemantics.TrackingPolicyReview + && x.State == LineupReviewSemantics.ChannelStatePending + && x.ProviderChannel.Active + && x.ProviderChannel.ContentType == "live" + && !x.ProviderChannel.IsPlaceholder + && x.ProfileGroupFilter.ProviderGroup.ContentType == "live") + .GroupBy(_ => 1) + .Select(g => new + { + Total = g.Count(), + Notified = g.Count(x => x.ProfileGroupFilter.TrackNewChannels), + }) + .FirstOrDefaultAsync(ct); var activeSnapshot = await db.Snapshots .AsNoTracking() @@ -94,15 +82,24 @@ public async Task GetStatsAsync(CancellationToken ct) channelsInProvider = lastFetchRun?.ChannelCountSeen; - vodGroups = await db.ProviderGroups + var groupTypeCounts = await db.ProviderGroups .AsNoTracking() - .CountAsync(x => x.ProviderId == provider.ProviderId && x.Active && x.ContentType == "vod", ct); - - seriesGroups = await db.ProviderGroups - .AsNoTracking() - .CountAsync(x => x.ProviderId == provider.ProviderId && x.Active && x.ContentType == "series", ct); + .Where(x => x.ProviderId == provider.ProviderId && x.Active + && (x.ContentType == "vod" || x.ContentType == "series")) + .GroupBy(x => x.ContentType) + .Select(g => new { ContentType = g.Key, Count = g.Count() }) + .ToListAsync(ct); + + vodGroups = groupTypeCounts.FirstOrDefault(x => x.ContentType == "vod")?.Count ?? 0; + seriesGroups = groupTypeCounts.FirstOrDefault(x => x.ContentType == "series")?.Count ?? 0; } + var groupsIncluded = groupAggregates?.Included ?? 0; + var groupsHold = groupAggregates?.Hold ?? 0; + var groupsNew = groupAggregates?.New ?? 0; + var pendingChannelsTotal = pendingAggregates?.Total ?? 0; + var pendingChannelsNotified = pendingAggregates?.Notified ?? 0; + return new ChannelMappingStatsDto { ProfileId = profileId, diff --git a/src/M3Undle.Web/Application/CustomGroupPageService.cs b/src/M3Undle.Web/Application/CustomGroupPageService.cs index fe84cba..91766bd 100644 --- a/src/M3Undle.Web/Application/CustomGroupPageService.cs +++ b/src/M3Undle.Web/Application/CustomGroupPageService.cs @@ -20,6 +20,7 @@ public async Task> ListAsync(string profileId, Cancellation .OrderBy(x => x.SortOverride == null ? 1 : 0) .ThenBy(x => x.SortOverride) .ThenBy(x => x.Name) + .AsSplitQuery() .ToListAsync(cancellationToken); return groups.Select(ToDto).ToList(); @@ -543,6 +544,7 @@ private static string EvaluateTrackingPolicy(ProfileCustomGroup group, ProviderC ChannelCount = g.Channels.Count, SelectedChannelCount = g.Channels.Count(c => c.State == LineupReviewSemantics.ChannelStateIncluded), ProviderLinkCount = g.ProviderLinks.Count, + LinkedProviderGroupIds = g.ProviderLinks.Select(l => l.ProviderGroupId).ToHashSet(StringComparer.Ordinal), CreatedUtc = g.CreatedUtc, UpdatedUtc = g.UpdatedUtc, }; diff --git a/src/M3Undle.Web/Application/DashboardStatsService.cs b/src/M3Undle.Web/Application/DashboardStatsService.cs index c01e9c7..859f28c 100644 --- a/src/M3Undle.Web/Application/DashboardStatsService.cs +++ b/src/M3Undle.Web/Application/DashboardStatsService.cs @@ -21,22 +21,52 @@ public async Task GetStatsAsync(CancellationToken ct) .OrderByDescending(s => s.CreatedUtc) .ToListAsync(ct); - var latestFetchRun = await db.FetchRuns + var profileProviders = await db.ProfileProviders .AsNoTracking() - .OrderByDescending(f => f.StartedUtc) - .FirstOrDefaultAsync(ct); + .Select(pp => new { pp.ProfileId, pp.ProviderId, pp.Enabled }) + .ToListAsync(ct); + + var relevantProviderIds = profileProviders.Select(pp => pp.ProviderId).Distinct().ToList(); + + // Latest fetch run status per provider, keyed by ProviderId. + // Uses a NOT EXISTS correlated subquery so only the latest row per provider is fetched + // rather than loading the entire fetch_runs history and deduplicating in memory. + var latestRunsByProvider = new Dictionary(StringComparer.Ordinal); + if (relevantProviderIds.Count > 0) + { + var latestRuns = await db.FetchRuns + .AsNoTracking() + .Where(f => relevantProviderIds.Contains(f.ProviderId) + && !db.FetchRuns.Any(f2 => f2.ProviderId == f.ProviderId && f2.StartedUtc > f.StartedUtc)) + .Select(f => new { f.ProviderId, f.Status }) + .ToListAsync(ct); + + foreach (var run in latestRuns) + latestRunsByProvider.TryAdd(run.ProviderId, run.Status); + } + + var profileProviderMap = profileProviders + .GroupBy(pp => pp.ProfileId) + .ToDictionary(g => g.Key, g => g.Select(pp => pp.ProviderId).ToList()); + + var providerDetailsById = relevantProviderIds.Count > 0 + ? await db.Providers + .AsNoTracking() + .Where(p => relevantProviderIds.Contains(p.ProviderId)) + .Select(p => new { p.ProviderId, p.Name, p.MaxConcurrentStreams, p.PlaylistExpiresUtc, p.Enabled }) + .ToListAsync(ct) + : []; + + var providerDetailMap = providerDetailsById.ToDictionary(p => p.ProviderId); var groupsPendingReview = await db.ProfileGroupFilters .AsNoTracking() - .Include(x => x.ProviderGroup) .CountAsync(x => x.ProviderGroup.ContentType == "live" && x.IsNew && x.TrackNewChannels, ct); var channelsPendingReview = await db.ProfileGroupChannelFilters .AsNoTracking() - .Include(x => x.ProfileGroupFilter).ThenInclude(f => f.ProviderGroup) - .Include(x => x.ProviderChannel) .CountAsync(x => x.ProfileGroupFilter.ProviderGroup.ContentType == "live" && x.ProfileGroupFilter.TrackingPolicy == LineupReviewSemantics.TrackingPolicyReview && x.ProviderChannel.ContentType == "live" @@ -46,11 +76,7 @@ public async Task GetStatsAsync(CancellationToken ct) && x.ProfileGroupFilter.TrackNewChannels, ct); // Counts shown next to the Output URLs reflect only the active profile's snapshot. - var activeProfileId = await db.Profiles - .AsNoTracking() - .Where(x => x.IsActive) - .Select(x => (string?)x.ProfileId) - .FirstOrDefaultAsync(ct); + var activeProfileId = profiles.FirstOrDefault(x => x.IsActive)?.ProfileId; DateTime? activeProfileProviderExpiresUtc = null; if (activeProfileId is not null) @@ -96,9 +122,27 @@ public async Task GetStatsAsync(CancellationToken ct) } } - if (snapshot is not null && latestFetchRun?.Status == "fail") + var profileProviderIds = profileProviderMap.GetValueOrDefault(profile.ProfileId, []); + var profileHasFailed = profileProviderIds.Any(pid => + latestRunsByProvider.TryGetValue(pid, out var s) && s == "fail"); + + if (snapshot is not null && profileHasFailed) health = ProfileHealthStatus.Degraded; + var enabledProfileProviderIds = profileProviders + .Where(pp => pp.ProfileId == profile.ProfileId && pp.Enabled) + .Select(pp => pp.ProviderId) + .ToList(); + + var profileProviderSummaries = enabledProfileProviderIds + .Where(pid => providerDetailMap.ContainsKey(pid) && providerDetailMap[pid].Enabled) + .Select(pid => + { + var p = providerDetailMap[pid]; + return new DashboardProviderSummary(p.ProviderId, p.Name, p.MaxConcurrentStreams, p.PlaylistExpiresUtc); + }) + .ToList(); + summaries.Add(new DashboardProfileSummary { ProfileId = profile.ProfileId, @@ -106,13 +150,21 @@ public async Task GetStatsAsync(CancellationToken ct) OutputName = profile.OutputName, IsEnabled = profile.Enabled, IsActive = profile.IsActive, + IsPublished = snapshot is not null, LastPublishedUtc = snapshot?.CreatedUtc, LiveCount = snapshot?.LiveChannelCount ?? 0, + MovieCount = snapshot?.VodChannelCount ?? 0, + SeriesCount = snapshot?.SeriesChannelCount ?? 0, HealthStatus = health, + Providers = profileProviderSummaries, }); } - var refreshFailed = latestFetchRun is not null && latestFetchRun.Status == "fail"; + var activeProviderIds = activeProfileId is not null + ? profileProviderMap.GetValueOrDefault(activeProfileId, []) + : []; + var refreshFailed = activeProviderIds.Any(pid => + latestRunsByProvider.TryGetValue(pid, out var s) && s == "fail"); var now = DateTime.UtcNow; var expiryThreshold = now.AddDays(30); diff --git a/src/M3Undle.Web/Application/EndpointSecurityService.cs b/src/M3Undle.Web/Application/EndpointSecurityService.cs index 7af6d70..2c2bdef 100644 --- a/src/M3Undle.Web/Application/EndpointSecurityService.cs +++ b/src/M3Undle.Web/Application/EndpointSecurityService.cs @@ -10,7 +10,8 @@ public sealed record EndpointSecuritySettings( string? Username, bool HasCredential, string? ActiveProfileId, - string? VirtualTunerId); + string? VirtualTunerId, + bool XtreamCompatibilityEnabled); public sealed record EndpointBindingState(string? ActiveProfileId, string? VirtualTunerId); @@ -19,7 +20,8 @@ public sealed record UpdateEndpointSecurityCommand( string? Username, string? Password, string? ActiveProfileId, - string? VirtualTunerId); + string? VirtualTunerId, + bool XtreamCompatibilityEnabled); public sealed record EndpointSecurityUpdateResult( bool Succeeded, @@ -29,6 +31,7 @@ public sealed record EndpointSecurityUpdateResult( public interface IEndpointSecurityService { ValueTask IsEnabledAsync(CancellationToken cancellationToken); + ValueTask IsXtreamEnabledAsync(CancellationToken cancellationToken); Task GetSettingsAsync(CancellationToken cancellationToken); Task GetBindingAsync(string credentialId, CancellationToken cancellationToken); Task UpdateAsync(UpdateEndpointSecurityCommand command, CancellationToken cancellationToken); @@ -44,6 +47,12 @@ public async ValueTask IsEnabledAsync(CancellationToken cancellationToken) return site.EndpointSecurityEnabled; } + public async ValueTask IsXtreamEnabledAsync(CancellationToken cancellationToken) + { + var site = await EnsureSiteSettingsAsync(cancellationToken); + return site.XtreamCompatibilityEnabled; + } + public async Task GetSettingsAsync(CancellationToken cancellationToken) { var site = await EnsureSiteSettingsAsync(cancellationToken); @@ -65,7 +74,8 @@ public async Task GetSettingsAsync(CancellationToken c Username: credential?.Username, HasCredential: credential is not null, ActiveProfileId: binding?.ActiveProfileId, - VirtualTunerId: binding?.VirtualTunerId); + VirtualTunerId: binding?.VirtualTunerId, + XtreamCompatibilityEnabled: site.XtreamCompatibilityEnabled); } public async Task GetBindingAsync(string credentialId, CancellationToken cancellationToken) @@ -207,6 +217,7 @@ public async Task UpdateAsync(UpdateEndpointSecuri } site.EndpointSecurityEnabled = command.Enabled; + site.XtreamCompatibilityEnabled = command.XtreamCompatibilityEnabled; await db.SaveChangesAsync(cancellationToken); var settings = await GetSettingsAsync(cancellationToken); diff --git a/src/M3Undle.Web/Application/EndpointUrlService.cs b/src/M3Undle.Web/Application/EndpointUrlService.cs new file mode 100644 index 0000000..b381f9e --- /dev/null +++ b/src/M3Undle.Web/Application/EndpointUrlService.cs @@ -0,0 +1,50 @@ +namespace M3Undle.Web.Application; + +public sealed class EndpointUrlService(EnvironmentVariableService env) +{ + private static readonly Lazy _containerInfo + = new(DetectContainerInfo, LazyThreadSafetyMode.ExecutionAndPublication); + + public string? GetPublicBaseUrl() => NormalizeUrl(env.GetValue("M3UNDLE_PUBLIC_BASE_URL")); + public string? GetDockerBaseUrl() => NormalizeUrl(env.GetValue("M3UNDLE_DOCKER_BASE_URL")); + public string? GetExternalBaseUrl() => NormalizeUrl(env.GetValue("M3UNDLE_EXTERNAL_BASE_URL")); + + public bool IsPublicBaseUrlFromEnv => !string.IsNullOrWhiteSpace(env.GetValue("M3UNDLE_PUBLIC_BASE_URL")); + public bool IsDockerBaseUrlFromEnv => !string.IsNullOrWhiteSpace(env.GetValue("M3UNDLE_DOCKER_BASE_URL")); + public bool IsExternalBaseUrlFromEnv => !string.IsNullOrWhiteSpace(env.GetValue("M3UNDLE_EXTERNAL_BASE_URL")); + + public bool IsContainerDetected => _containerInfo.Value.IsContainer; + public string DetectedHostname => _containerInfo.Value.Hostname; + public bool IsHostnameLikelyContainerId => IsShortHexId(DetectedHostname); + + private static ContainerInfo DetectContainerInfo() + { + var hostname = TryGetHostname(); + var isContainer = + string.Equals(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true", StringComparison.OrdinalIgnoreCase) + || File.Exists("/.dockerenv"); + return new ContainerInfo(isContainer, hostname); + } + + private static string TryGetHostname() + { + try { return System.Net.Dns.GetHostName(); } + catch { return Environment.MachineName; } + } + + private static bool IsShortHexId(string hostname) + => (hostname.Length == 12 || hostname.Length == 64) && hostname.All(c => char.IsAsciiHexDigit(c)); + + public static string? NormalizeUrl(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var trimmed = value.Trim().TrimEnd('/'); + return Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) + && (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) + || string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + ? trimmed + : null; + } + + private sealed record ContainerInfo(bool IsContainer, string Hostname); +} diff --git a/src/M3Undle.Web/Application/HdHomeRunSettingsService.cs b/src/M3Undle.Web/Application/HdHomeRunSettingsService.cs index ad2c26e..70fc15b 100644 --- a/src/M3Undle.Web/Application/HdHomeRunSettingsService.cs +++ b/src/M3Undle.Web/Application/HdHomeRunSettingsService.cs @@ -1,6 +1,7 @@ using M3Undle.Web.Data; using M3Undle.Web.Data.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; namespace M3Undle.Web.Application; @@ -9,15 +10,18 @@ public interface IHdHomeRunSettingsService Task GetSettingsAsync(CancellationToken ct = default); Task UpdateAsync(UpdateHdhrSettingsCommand command, CancellationToken ct = default); Task ClearRestartRequiredAsync(CancellationToken ct = default); + Task GetAllowedNetworksAsync(CancellationToken ct = default); } public sealed class HdHomeRunSettingsService( - ApplicationDbContext db, + IServiceScopeFactory scopeFactory, HdHomeRunDeviceService deviceService, HdHomeRunTunerCountResolver tunerCountResolver) : IHdHomeRunSettingsService { public async Task GetSettingsAsync(CancellationToken ct = default) { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); var settings = await db.SiteSettings .AsNoTracking() .OrderBy(x => x.Id) @@ -37,7 +41,9 @@ public async Task GetSettingsAsync(CancellationToken ct = def public async Task UpdateAsync(UpdateHdhrSettingsCommand command, CancellationToken ct = default) { - var settings = await db.SiteSettings + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var settings = await db.SiteSettings .OrderBy(x => x.Id) .FirstOrDefaultAsync(ct); if (settings is null) @@ -73,6 +79,7 @@ public async Task UpdateAsync(UpdateHdhrSettingsComman settings.HdhrSsdpEnabled = command.SsdpEnabled; settings.HdhrSiliconDustDiscoveryEnabled = command.SiliconDustDiscoveryEnabled; settings.HdhrFriendlyName = string.IsNullOrWhiteSpace(command.FriendlyName) ? null : command.FriendlyName.Trim(); + settings.HdhrAllowedNetworks = string.IsNullOrWhiteSpace(command.AllowedNetworks) ? null : command.AllowedNetworks.Trim(); if (changed) settings.HdhrSettingsRestartRequired = true; @@ -88,9 +95,22 @@ public async Task UpdateAsync(UpdateHdhrSettingsComman Changed: changed); } - public async Task ClearRestartRequiredAsync(CancellationToken ct = default) + public async Task GetAllowedNetworksAsync(CancellationToken ct = default) { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); var settings = await db.SiteSettings + .AsNoTracking() + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(ct); + return settings?.HdhrAllowedNetworks; + } + + public async Task ClearRestartRequiredAsync(CancellationToken ct = default) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var settings = await db.SiteSettings .OrderBy(x => x.Id) .FirstOrDefaultAsync(ct); if (settings is null || !settings.HdhrSettingsRestartRequired) @@ -117,7 +137,8 @@ private HdhrSettingsSnapshot MapSnapshot(SiteSettings settings, int? providerLim ResolvedBaseUrl: deviceService.ResolveBaseUrl(), DiscoveryEnabled: settings.HdhrDiscoveryEnabled, SsdpEnabled: settings.HdhrSsdpEnabled, - SiliconDustDiscoveryEnabled: settings.HdhrSiliconDustDiscoveryEnabled); + SiliconDustDiscoveryEnabled: settings.HdhrSiliconDustDiscoveryEnabled, + AllowedNetworks: settings.HdhrAllowedNetworks); } private HdhrSettingsSnapshot BuildAppliedSnapshot(int? providerLimit) @@ -138,7 +159,8 @@ private HdhrSettingsSnapshot BuildAppliedSnapshot(int? providerLimit) ResolvedBaseUrl: runtime.ResolvedBaseUrl, DiscoveryEnabled: runtime.DiscoveryEnabled, SsdpEnabled: runtime.SsdpEnabled, - SiliconDustDiscoveryEnabled: runtime.SiliconDustDiscoveryEnabled); + SiliconDustDiscoveryEnabled: runtime.SiliconDustDiscoveryEnabled, + AllowedNetworks: null); } private static IEnumerable Validate(UpdateHdhrSettingsCommand command) @@ -181,7 +203,8 @@ public sealed record HdhrSettingsSnapshot( string ResolvedBaseUrl, bool DiscoveryEnabled, bool SsdpEnabled, - bool SiliconDustDiscoveryEnabled); + bool SiliconDustDiscoveryEnabled, + string? AllowedNetworks); public sealed record HdhrSettingsState( HdhrSettingsSnapshot Saved, @@ -196,7 +219,8 @@ public sealed record UpdateHdhrSettingsCommand( string? AdvertisedBaseUrl, bool DiscoveryEnabled, bool SsdpEnabled, - bool SiliconDustDiscoveryEnabled); + bool SiliconDustDiscoveryEnabled, + string? AllowedNetworks); public sealed record HdhrSettingsUpdateResult( bool Succeeded, diff --git a/src/M3Undle.Web/Application/IRefreshTrigger.cs b/src/M3Undle.Web/Application/IRefreshTrigger.cs index f5e319d..f42099b 100644 --- a/src/M3Undle.Web/Application/IRefreshTrigger.cs +++ b/src/M3Undle.Web/Application/IRefreshTrigger.cs @@ -5,6 +5,9 @@ public interface IRefreshTrigger /// Whether a refresh run is currently executing. bool IsRefreshing { get; } + /// UTC time the current refresh run started, or null if no refresh is active. + DateTime? RefreshStartedAt { get; } + /// /// Request an immediate full refresh (fetch from provider + rebuild snapshot). /// Returns true when the request was queued; false when a refresh is already diff --git a/src/M3Undle.Web/Application/LineupStatusService.cs b/src/M3Undle.Web/Application/LineupStatusService.cs index 8a2779a..c85418b 100644 --- a/src/M3Undle.Web/Application/LineupStatusService.cs +++ b/src/M3Undle.Web/Application/LineupStatusService.cs @@ -36,7 +36,6 @@ public async Task GetStatusAsync(CancellationToken cancell var activeProfile = await db.Profiles .AsNoTracking() .Where(x => x.IsActive && x.Enabled) - .OrderByDescending(x => x.UpdatedUtc) .Select(x => new ActiveProfileInfo(x.ProfileId, x.Name, x.UpdatedUtc)) .FirstOrDefaultAsync(cancellationToken); @@ -45,20 +44,12 @@ public async Task GetStatusAsync(CancellationToken cancell Provider? activeProvider = null; if (activeProfileId is not null) { - var profileProvider = await db.ProfileProviders + activeProvider = await db.ProfileProviders .AsNoTracking() - .Where(x => x.ProfileId == activeProfileId && x.Enabled) + .Where(x => x.ProfileId == activeProfileId && x.Enabled && x.Provider.Enabled) .OrderBy(x => x.Priority) + .Select(x => x.Provider) .FirstOrDefaultAsync(cancellationToken); - - if (profileProvider is not null) - { - activeProvider = await db.Providers - .AsNoTracking() - .FirstOrDefaultAsync( - x => x.ProviderId == profileProvider.ProviderId && x.Enabled, - cancellationToken); - } } var activeSnapshot = activeProfileId is null @@ -91,7 +82,7 @@ public async Task GetStatusAsync(CancellationToken cancell var activeProviderInfo = activeProvider is null ? null - : new ActiveProviderInfo(activeProvider.ProviderId, activeProvider.Name); + : new ActiveProviderInfo(activeProvider.ProviderId, activeProvider.Name, activeProvider.MaxConcurrentStreams); var switchState = ComputeSwitchState(activeProfile, activeProviderInfo, activeSnapshot, lastRefresh, isRefreshing); var lineupStatus = ComputeLineupStatus(activeProfile, activeSnapshot, lastRefresh, isRefreshing); @@ -128,7 +119,7 @@ private static string ComputeLineupStatus( if (isRefreshing) return LineupStatusCodes.Refreshing; - if (activeSnapshot is null) + if (activeSnapshot is null || activeSnapshot.LiveChannelCount <= 0) return LineupStatusCodes.NoActiveSnapshot; if (lastRefresh?.Status == "fail") @@ -186,7 +177,7 @@ internal sealed record LineupStatusInfo( internal sealed record ActiveProfileInfo(string ProfileId, string Name, DateTime UpdatedUtc); -internal sealed record ActiveProviderInfo(string ProviderId, string Name); +internal sealed record ActiveProviderInfo(string ProviderId, string Name, int? MaxConcurrentStreams); internal sealed record ActiveSnapshotInfo( string SnapshotId, diff --git a/src/M3Undle.Web/Application/ProfilesPageService.cs b/src/M3Undle.Web/Application/ProfilesPageService.cs index 7dd17ce..8c1b3f5 100644 --- a/src/M3Undle.Web/Application/ProfilesPageService.cs +++ b/src/M3Undle.Web/Application/ProfilesPageService.cs @@ -190,11 +190,76 @@ await db.Profiles refreshTrigger.TriggerRefresh(); } - public async Task> GetProfilesAsync(CancellationToken ct) + public async Task<(bool XtreamEnabled, bool HdhrEnabled)> GetEndpointFlagsAsync(CancellationToken ct) { await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - return await BuildProfileListAsync(db, profileId: null, ct); + var settings = await db.SiteSettings + .AsNoTracking() + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(ct) + ?? new Data.Entities.SiteSettings { Id = 1 }; + return (settings.XtreamCompatibilityEnabled, settings.HdhrEnabled); + } + + public async Task> GetProfilesAsync(CancellationToken ct, bool includePendingCounts = true) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await BuildProfileListAsync(db, profileId: null, ct, includePendingCounts); + } + + public async Task> GetPendingReviewCountsAsync(CancellationToken ct) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var groupsPendingByProfile = await db.ProfileGroupFilters + .AsNoTracking() + .Where(x => x.ProviderGroup.ContentType == "live" && x.IsNew) + .GroupBy(x => x.ProfileId) + .Select(g => new { ProfileId = g.Key, Count = g.Count() }) + .ToListAsync(ct); + + var channelsPendingByProfile = await db.ProfileGroupChannelFilters + .AsNoTracking() + .Where(x => x.ProfileGroupFilter.ProviderGroup.ContentType == "live" + && x.ProfileGroupFilter.TrackingPolicy == LineupReviewSemantics.TrackingPolicyReview + && x.ProviderChannel.ContentType == "live" + && x.ProviderChannel.Active + && !x.ProviderChannel.IsPlaceholder + && x.State == LineupReviewSemantics.ChannelStatePending) + .GroupBy(x => x.ProfileGroupFilter.ProfileId) + .Select(g => new { ProfileId = g.Key, Count = g.Count() }) + .ToListAsync(ct); + + var groupsRemovedByProfile = await db.ProfileGroupFilters + .AsNoTracking() + .Where(x => x.ProviderGroup.ContentType == "live" + && x.Decision != LineupReviewSemantics.GroupDecisionExclude + && !x.ProviderGroup.Active) + .GroupBy(x => x.ProfileId) + .Select(g => new { ProfileId = g.Key, Count = g.Count() }) + .ToListAsync(ct); + + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var group in groupsPendingByProfile) + result[group.ProfileId] = (group.Count, 0, 0); + + foreach (var channel in channelsPendingByProfile) + { + result.TryGetValue(channel.ProfileId, out var existing); + result[channel.ProfileId] = (existing.Groups, channel.Count, existing.Removed); + } + + foreach (var removed in groupsRemovedByProfile) + { + result.TryGetValue(removed.ProfileId, out var existing); + result[removed.ProfileId] = (existing.Groups, existing.Channels, removed.Count); + } + + return result; } public async Task GetProfileDetailAsync(string profileId, CancellationToken ct) @@ -202,7 +267,7 @@ public async Task> GetProfilesAsync(CancellationToken c await using var scope = scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - var list = await BuildProfileListAsync(db, profileId, ct); + var list = await BuildProfileListAsync(db, profileId, ct, includePendingCounts: true); if (list.Count == 0) return null; var history = await db.Snapshots @@ -231,7 +296,7 @@ public async Task> GetProfilesAsync(CancellationToken c } private static async Task> BuildProfileListAsync( - ApplicationDbContext db, string? profileId, CancellationToken ct) + ApplicationDbContext db, string? profileId, CancellationToken ct, bool includePendingCounts) { var profileQuery = db.Profiles.AsNoTracking(); if (profileId is not null) @@ -247,78 +312,135 @@ private static async Task> BuildProfileListAsync( var profileProviders = await db.ProfileProviders .AsNoTracking() - .Include(pp => pp.Provider) .Where(pp => profileIds.Contains(pp.ProfileId)) .OrderBy(pp => pp.Priority) + .Select(pp => new + { + pp.ProfileId, + pp.ProviderId, + pp.Priority, + pp.Enabled, + ProviderName = pp.Provider.Name, + pp.Provider.PlaylistExpiresUtc, + }) .ToListAsync(ct); var activeSnapshots = await db.Snapshots .AsNoTracking() .Where(s => profileIds.Contains(s.ProfileId) && s.Status == "active") .OrderByDescending(s => s.CreatedUtc) + .Select(s => new + { + s.ProfileId, + s.CreatedUtc, + s.LiveChannelCount, + s.VodChannelCount, + s.SeriesChannelCount, + }) .ToListAsync(ct); - var groupsPendingByProfile = await db.ProfileGroupFilters - .AsNoTracking() - .Include(x => x.ProviderGroup) - .Where(x => profileIds.Contains(x.ProfileId) - && x.ProviderGroup.ContentType == "live" - && x.IsNew) - .GroupBy(x => x.ProfileId) - .Select(g => new { ProfileId = g.Key, Count = g.Count() }) - .ToListAsync(ct); - - var channelsPendingByProfile = await db.ProfileGroupChannelFilters - .AsNoTracking() - .Include(x => x.ProfileGroupFilter).ThenInclude(f => f.ProviderGroup) - .Include(x => x.ProviderChannel) - .Where(x => profileIds.Contains(x.ProfileGroupFilter.ProfileId) - && x.ProfileGroupFilter.ProviderGroup.ContentType == "live" - && x.ProfileGroupFilter.TrackingPolicy == LineupReviewSemantics.TrackingPolicyReview - && x.ProviderChannel.ContentType == "live" - && x.ProviderChannel.Active - && !x.ProviderChannel.IsPlaceholder - && x.State == LineupReviewSemantics.ChannelStatePending) - .GroupBy(x => x.ProfileGroupFilter.ProfileId) - .Select(g => new { ProfileId = g.Key, Count = g.Count() }) - .ToListAsync(ct); + var groupsPendingByProfile = includePendingCounts + ? await db.ProfileGroupFilters + .AsNoTracking() + .Where(x => profileIds.Contains(x.ProfileId) + && x.ProviderGroup.ContentType == "live" + && x.IsNew) + .GroupBy(x => x.ProfileId) + .Select(g => new { ProfileId = g.Key, Count = g.Count() }) + .ToListAsync(ct) + : []; + + var channelsPendingByProfile = includePendingCounts + ? await db.ProfileGroupChannelFilters + .AsNoTracking() + .Where(x => profileIds.Contains(x.ProfileGroupFilter.ProfileId) + && x.ProfileGroupFilter.ProviderGroup.ContentType == "live" + && x.ProfileGroupFilter.TrackingPolicy == LineupReviewSemantics.TrackingPolicyReview + && x.ProviderChannel.ContentType == "live" + && x.ProviderChannel.Active + && !x.ProviderChannel.IsPlaceholder + && x.State == LineupReviewSemantics.ChannelStatePending) + .GroupBy(x => x.ProfileGroupFilter.ProfileId) + .Select(g => new { ProfileId = g.Key, Count = g.Count() }) + .ToListAsync(ct) + : []; + + var groupsRemovedByProfile = includePendingCounts + ? await db.ProfileGroupFilters + .AsNoTracking() + .Where(x => profileIds.Contains(x.ProfileId) + && x.ProviderGroup.ContentType == "live" + && x.Decision != LineupReviewSemantics.GroupDecisionExclude + && !x.ProviderGroup.Active) + .GroupBy(x => x.ProfileId) + .Select(g => new { ProfileId = g.Key, Count = g.Count() }) + .ToListAsync(ct) + : []; + + + // Latest fetch run status per provider — used to scope health to each profile's own providers. + var relevantProviderIds = profileProviders.Select(pp => pp.ProviderId).Distinct().ToList(); + var latestRunsByProvider = new Dictionary(StringComparer.Ordinal); + var latestRunErrorsByProvider = new Dictionary(StringComparer.Ordinal); + if (relevantProviderIds.Count > 0) + { + var recentRuns = await db.FetchRuns + .AsNoTracking() + .Where(f => relevantProviderIds.Contains(f.ProviderId)) + .Select(f => new { f.ProviderId, f.StartedUtc, f.Status, f.ErrorSummary }) + .OrderByDescending(f => f.StartedUtc) + .ToListAsync(ct); + + foreach (var run in recentRuns) + { + latestRunsByProvider.TryAdd(run.ProviderId, run.Status); + latestRunErrorsByProvider.TryAdd(run.ProviderId, run.ErrorSummary); + } + } - var lastFailedRun = await db.FetchRuns - .AsNoTracking() - .OrderByDescending(f => f.StartedUtc) - .Select(f => new { f.Status }) - .FirstOrDefaultAsync(ct); + var profileProviderMap = profileProviders + .GroupBy(pp => pp.ProfileId) + .ToDictionary(g => g.Key, g => g.Select(pp => pp.ProviderId).ToList()); var pendingByProfile = groupsPendingByProfile.ToDictionary(g => g.ProfileId, g => g.Count); var pendingChannelsByProfileMap = channelsPendingByProfile.ToDictionary(g => g.ProfileId, g => g.Count); + var removedGroupsByProfileMap = groupsRemovedByProfile.ToDictionary(g => g.ProfileId, g => g.Count); - var result = new List(); - - foreach (var profile in profiles) - { - var snapshot = activeSnapshots - .FirstOrDefault(s => s.ProfileId == profile.ProfileId); - - var providers = profileProviders - .Where(pp => pp.ProfileId == profile.ProfileId) - .Select(pp => new ProfileProviderInfoDto + var providersByProfile = profileProviders + .GroupBy(x => x.ProfileId) + .ToDictionary( + g => g.Key, + g => g.Select(pp => new ProfileProviderInfoDto { ProviderId = pp.ProviderId, - Name = pp.Provider.Name, + Name = pp.ProviderName, Priority = pp.Priority, Enabled = pp.Enabled, - PlaylistExpiresUtc = pp.Provider.PlaylistExpiresUtc, - }) - .ToList(); + PlaylistExpiresUtc = pp.PlaylistExpiresUtc, + LastFetchStatus = latestRunsByProvider.GetValueOrDefault(pp.ProviderId), + LastFetchErrorSummary = latestRunErrorsByProvider.GetValueOrDefault(pp.ProviderId), + }).ToList()); + + var latestSnapshotByProfile = activeSnapshots + .GroupBy(s => s.ProfileId) + .ToDictionary(g => g.Key, g => g.First()); + + var result = new List(); + + foreach (var profile in profiles) + { + var providers = providersByProfile.GetValueOrDefault(profile.ProfileId, []); + var snapshot = latestSnapshotByProfile.GetValueOrDefault(profile.ProfileId); var hasProviders = providers.Count > 0; var health = ProfileHealthStatus.NoOutput; if (hasProviders && snapshot is not null) { - health = lastFailedRun?.Status == "fail" - ? ProfileHealthStatus.Degraded - : ProfileHealthStatus.Ok; + var profileProviderIds = profileProviderMap.GetValueOrDefault(profile.ProfileId, []); + var profileHasFailed = profileProviderIds.Any(pid => + latestRunsByProvider.TryGetValue(pid, out var s) && s == "fail"); + health = profileHasFailed ? ProfileHealthStatus.Degraded : ProfileHealthStatus.Ok; } result.Add(new ProfilePageItemDto @@ -338,6 +460,7 @@ private static async Task> BuildProfileListAsync( HealthStatus = health, GroupsPendingReview = pendingByProfile.GetValueOrDefault(profile.ProfileId, 0), ChannelsPendingReview = pendingChannelsByProfileMap.GetValueOrDefault(profile.ProfileId, 0), + GroupsRemovedFromProvider = removedGroupsByProfileMap.GetValueOrDefault(profile.ProfileId, 0), }); } diff --git a/src/M3Undle.Web/Application/SnapshotBuilder.cs b/src/M3Undle.Web/Application/SnapshotBuilder.cs index 3f434a2..317c3f7 100644 --- a/src/M3Undle.Web/Application/SnapshotBuilder.cs +++ b/src/M3Undle.Web/Application/SnapshotBuilder.cs @@ -120,15 +120,17 @@ public ChannelOverride(string? OutputGroupName, int? ChannelNumber, string? TvgI private sealed record LineupMetricDeltas(int Added, int Removed, int Renamed, int MappingChanges); + private sealed record ProviderRefreshLink( + string ProviderId, + int Priority, + bool IsActiveProfile); + /// Full refresh: fetch all enabled providers, sync to DB, then build snapshots. public async Task<(bool Succeeded, string? ErrorSummary, IReadOnlyDictionary> ChannelsByProvider, string? ChangeClass, IReadOnlySet AffectedProfileIds)> RunAsync(CancellationToken cancellationToken) { using var scope = logger.BeginScope(new Dictionary { ["EventType"] = "Refresh" }); - var providers = await db.Providers - .AsNoTracking() - .Where(x => x.Enabled) - .ToListAsync(cancellationToken); + var providers = await GetOrderedEnabledProvidersAsync(cancellationToken); if (providers.Count == 0) { @@ -169,10 +171,7 @@ private sealed record LineupMetricDeltas(int Added, int Removed, int Renamed, in { using var scope = logger.BeginScope(new Dictionary { ["EventType"] = "Refresh" }); - var providers = await db.Providers - .AsNoTracking() - .Where(x => x.Enabled) - .ToListAsync(cancellationToken); + var providers = await GetOrderedEnabledProvidersAsync(cancellationToken); if (providers.Count == 0) { @@ -198,6 +197,48 @@ private sealed record LineupMetricDeltas(int Added, int Removed, int Renamed, in return (anySucceeded, lastErrorSummary, aggregateChangeClass, aggregateProfileIds); } + private async Task> GetOrderedEnabledProvidersAsync(CancellationToken cancellationToken) + { + var providers = await db.Providers + .AsNoTracking() + .Where(x => x.Enabled) + .ToListAsync(cancellationToken); + + if (providers.Count <= 1) + return providers; + + var links = await db.ProfileProviders + .AsNoTracking() + .Where(x => x.Enabled && x.Provider.Enabled && x.Profile.Enabled) + .Select(x => new ProviderRefreshLink(x.ProviderId, x.Priority, x.Profile.IsActive)) + .ToListAsync(cancellationToken); + + var linkLookup = links.ToLookup(x => x.ProviderId, StringComparer.Ordinal); + + return providers + .OrderBy(provider => GetProviderRefreshRank(linkLookup[provider.ProviderId])) + .ThenBy(provider => GetProviderRefreshPriority(linkLookup[provider.ProviderId])) + .ThenBy(provider => provider.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(provider => provider.ProviderId, StringComparer.Ordinal) + .ToList(); + } + + private static int GetProviderRefreshRank(IEnumerable links) + { + var materialized = links as IReadOnlyCollection ?? links.ToArray(); + if (materialized.Any(x => x.IsActiveProfile)) + return 0; + if (materialized.Count > 0) + return 1; + return 2; + } + + private static int GetProviderRefreshPriority(IEnumerable links) + { + var priorities = links.Select(x => x.Priority); + return priorities.Any() ? priorities.Min() : int.MaxValue; + } + private async Task<(bool Succeeded, string? ErrorSummary, IReadOnlyList Channels, string? ChangeClass, IReadOnlySet AffectedProfileIds)> RunForProviderAsync( Provider provider, int? globalIntervalHours, CancellationToken cancellationToken) { @@ -227,6 +268,11 @@ private sealed record LineupMetricDeltas(int Added, int Removed, int Renamed, in return (false, null, [], null, new HashSet()); } + var initialProfileProviderSyncProfileIds = await GetInitialProfileProviderSyncProfileIdsAsync( + provider.ProviderId, + activeLinks.Select(l => l.ProfileId).ToList(), + cancellationToken); + var profileId = activeLinks[0].ProfileId; // 2. Create FetchRun pre-saved as "running" (crash leaves it as "running", not "fail") @@ -253,7 +299,7 @@ private sealed record LineupMetricDeltas(int Added, int Removed, int Renamed, in } catch (Exception ex) when (ex is ProviderFetchException or ProviderParseException or OperationCanceledException) { - logger.LogWarning(ex, "Playlist fetch/parse failed for provider {ProviderId} after {Elapsed}ms.", provider.ProviderId, sw.ElapsedMilliseconds); + logger.LogWarning(ex, "Playlist fetch/parse failed for provider \"{ProviderName}\" after {Elapsed}ms.", provider.Name, sw.ElapsedMilliseconds); metrics?.RecordProviderRefresh(provider.ProviderId, success: false, sw.Elapsed); await FailFetchRunAsync(fetchRun, ex.Message); await PublishSystemEventBestEffortAsync( @@ -294,7 +340,10 @@ await PublishSystemEventBestEffortAsync( stage = "group-filters"; foreach (var link in activeLinks) - await SyncGroupFiltersAsync(link.ProfileId, provider.ProviderId, cancellationToken); + { + var isInitialProfileProviderSync = initialProfileProviderSyncProfileIds.Contains(link.ProfileId); + await SyncGroupFiltersAsync(link.ProfileId, provider.ProviderId, !isInitialProfileProviderSync, cancellationToken); + } logger.LogInformation("Group filters synced in {Elapsed}ms for {Count} profile(s), provider {ProviderId}.", sw.ElapsedMilliseconds, activeLinks.Count, provider.ProviderId); sw.Restart(); @@ -305,7 +354,10 @@ await PublishSystemEventBestEffortAsync( stage = "pending-review"; foreach (var link in activeLinks) - await SyncPendingChannelReviewsAsync(link.ProfileId, provider.ProviderId, now, cancellationToken); + { + var isInitialProfileProviderSync = initialProfileProviderSyncProfileIds.Contains(link.ProfileId); + await SyncPendingChannelReviewsAsync(link.ProfileId, provider.ProviderId, now, !isInitialProfileProviderSync, cancellationToken); + } logger.LogInformation("Pending channel review sync completed in {Elapsed}ms for {Count} profile(s), provider {ProviderId}.", sw.ElapsedMilliseconds, activeLinks.Count, provider.ProviderId); sw.Restart(); @@ -447,6 +499,27 @@ await db.Providers } } + private async Task> GetInitialProfileProviderSyncProfileIdsAsync( + string providerId, + IReadOnlyCollection profileIds, + CancellationToken cancellationToken) + { + if (profileIds.Count == 0) + return []; + + var profileIdsWithExistingFilters = await db.ProfileGroupFilters + .AsNoTracking() + .Where(x => profileIds.Contains(x.ProfileId) + && x.ProviderGroup.ProviderId == providerId) + .Select(x => x.ProfileId) + .Distinct() + .ToListAsync(cancellationToken); + + var result = profileIds.ToHashSet(StringComparer.Ordinal); + result.ExceptWith(profileIdsWithExistingFilters); + return result; + } + private async Task UpdateXtreamExpiryAsync(Provider provider, CancellationToken cancellationToken) { var info = await fetcher.TryProbeXtreamAsync(provider, cancellationToken); @@ -1508,6 +1581,7 @@ private async Task> SyncProviderGroupsAsync( private async Task SyncGroupFiltersAsync( string profileId, string providerId, + bool markNewGroups, CancellationToken cancellationToken) { var allGroupIds = await db.ProviderGroups @@ -1531,7 +1605,7 @@ private async Task SyncGroupFiltersAsync( ProfileId = profileId, ProviderGroupId = id, Decision = LineupReviewSemantics.GroupDecisionInclude, - IsNew = true, + IsNew = markNewGroups, ChannelMode = LineupReviewSemantics.GroupModeManualReview, TrackingPolicy = LineupReviewSemantics.TrackingPolicyReview, TrackNewChannels = false, @@ -1552,6 +1626,7 @@ private async Task SyncPendingChannelReviewsAsync( string profileId, string providerId, DateTime now, + bool queueNewRowsForReview, CancellationToken cancellationToken) { var manualReviewFilters = await db.ProfileGroupFilters @@ -1566,7 +1641,8 @@ private async Task SyncPendingChannelReviewsAsync( .Where(f => LineupReviewSemantics.IsGroupIncluded(f.Decision) && LineupReviewSemantics.NormalizeGroupMode(f.ChannelMode) == LineupReviewSemantics.GroupModeManualReview - && LineupReviewSemantics.ShouldQueuePending(f.TrackingPolicy)) + && LineupReviewSemantics.ShouldQueuePending(f.TrackingPolicy) + && f.TrackNewChannels) .ToList(); if (candidateFilters.Count == 0) @@ -1609,6 +1685,10 @@ private async Task SyncPendingChannelReviewsAsync( .Select(x => $"{x.ProfileGroupFilterId}:{x.ProviderChannelId}") .ToHashSet(StringComparer.Ordinal); + var newRowState = queueNewRowsForReview + ? LineupReviewSemantics.ChannelStatePending + : LineupReviewSemantics.ChannelStateExcluded; + var newRows = new List(); foreach (var channel in activeChannels) { @@ -1624,7 +1704,7 @@ private async Task SyncPendingChannelReviewsAsync( ProfileGroupChannelFilterId = Guid.NewGuid().ToString(), ProfileGroupFilterId = filter.ProfileGroupFilterId, ProviderChannelId = channel.ProviderChannelId, - State = LineupReviewSemantics.ChannelStatePending, + State = newRowState, CreatedUtc = now, UpdatedUtc = now, }); @@ -1636,8 +1716,9 @@ private async Task SyncPendingChannelReviewsAsync( db.ProfileGroupChannelFilters.AddRange(newRows); await db.SaveChangesAsync(cancellationToken); logger.LogInformation( - "Created {Count} pending channel review row(s) for profile {ProfileId}, provider {ProviderId}.", + "Created {Count} {State} channel review row(s) for profile {ProfileId}, provider {ProviderId}.", newRows.Count, + newRowState, profileId, providerId); } diff --git a/src/M3Undle.Web/Application/SnapshotRefreshService.cs b/src/M3Undle.Web/Application/SnapshotRefreshService.cs index fc17acd..93c2b74 100644 --- a/src/M3Undle.Web/Application/SnapshotRefreshService.cs +++ b/src/M3Undle.Web/Application/SnapshotRefreshService.cs @@ -36,6 +36,7 @@ private enum RefreshMode { FetchAndBuild, BuildOnly } // Current run CTS — cancelled by CancelRefresh(); null when no run is active private volatile CancellationTokenSource? _currentRunCts; private volatile bool _cancelledByUser; + private DateTime? _refreshStartedAt; // Schedule loop wait CTS — cancelled when the user updates the refresh schedule private volatile CancellationTokenSource? _scheduleWaitCts; @@ -46,6 +47,8 @@ private enum RefreshMode { FetchAndBuild, BuildOnly } public bool IsRefreshing => _executionGate.CurrentCount == 0; + public DateTime? RefreshStartedAt => _refreshStartedAt; + public bool TriggerRefresh() { if (_executionGate.CurrentCount == 0) @@ -373,6 +376,7 @@ private async Task RunRefreshAsync(CancellationToken stoppingToken) catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogWarning(ex, "Event cleanup failed."); } logger.LogInformation("Snapshot refresh started."); + _refreshStartedAt = timeProvider.GetUtcNow().UtcDateTime; eventBus.Publish(AppEventKind.RefreshStarted); bool succeeded = false; string? errorSummary = null; @@ -385,7 +389,10 @@ private async Task RunRefreshAsync(CancellationToken stoppingToken) var (s, e, channelsByProvider, cc, profileIds) = await builder.RunAsync(runCts.Token); (succeeded, errorSummary, changeClass, affectedProfileIds) = (s, e, cc, profileIds); if (channelsByProvider.Count > 0) + { _cachedChannels = channelsByProvider; + await PurgeStaleProviderDataAsync(channelsByProvider.Keys, stoppingToken); + } logger.LogInformation("Snapshot refresh completed (published={Succeeded}, change={ChangeClass}).", succeeded, changeClass ?? "none"); if (cc == ChangeClasses.Breaking) { @@ -414,12 +421,96 @@ await eventService.PublishAsync(SystemEventSeverity.Warning, SystemEventTypes.Br } finally { + _refreshStartedAt = null; _currentRunCts = null; eventBus.Publish(AppEventKind.RefreshCompleted, succeeded, errorSummary, changeClass, affectedProfileIds.Count > 0 ? affectedProfileIds : null); } } + // ------------------------------------------------------------------------- + // Data retention — keep last 2 fetch generations per provider + // ------------------------------------------------------------------------- + + private async Task PurgeStaleProviderDataAsync(IEnumerable fetchedProviderIds, CancellationToken ct) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var totalChannels = 0; + var totalRuns = 0; + + foreach (var providerId in fetchedProviderIds) + { + var (channels, runs) = await PurgeProviderGenerationsAsync(db, providerId, ct); + totalChannels += channels; + totalRuns += runs; + } + + if (totalChannels > 0 || totalRuns > 0) + logger.LogInformation( + "Data retention: purged {ChannelCount} stale channel(s) and {RunCount} old fetch run(s).", + totalChannels, totalRuns); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Provider data retention purge failed — will retry on next refresh."); + } + } + + private static async Task<(int Channels, int Runs)> PurgeProviderGenerationsAsync( + ApplicationDbContext db, string providerId, CancellationToken ct) + { + // Identify the 2 most recent fetch runs — these are the "live" generations to keep. + var recentRunIds = await db.FetchRuns + .AsNoTracking() + .Where(x => x.ProviderId == providerId) + .OrderByDescending(x => x.StartedUtc) + .Take(2) + .Select(x => x.FetchRunId) + .ToListAsync(ct); + + // Fewer than 2 runs means there is no older generation to purge. + if (recentRunIds.Count < 2) + return (0, 0); + + // Use a subquery instead of loading IDs into memory — avoids SQLite's 999-parameter + // limit when a provider has a large channel list with multiple old fetch runs. + var staleChannelQuery = db.ProviderChannels + .Where(x => x.ProviderId == providerId && !recentRunIds.Contains(x.LastFetchRunId)) + .Select(x => x.ProviderChannelId); + + var staleCount = await staleChannelQuery.CountAsync(ct); + if (staleCount > 0) + { + // Delete child rows explicitly — SQLite FK cascade requires PRAGMA foreign_keys = ON + // which is not enabled; follow the same explicit-delete pattern as DeleteProfileAsync. + await db.ProfileGroupChannelFilters + .Where(x => staleChannelQuery.Contains(x.ProviderChannelId)) + .ExecuteDeleteAsync(ct); + + await db.ChannelSources + .Where(x => staleChannelQuery.Contains(x.ProviderChannelId)) + .ExecuteDeleteAsync(ct); + + await db.ProfileCustomGroupChannels + .Where(x => staleChannelQuery.Contains(x.ProviderChannelId)) + .ExecuteDeleteAsync(ct); + + await db.ProviderChannels + .Where(x => x.ProviderId == providerId && !recentRunIds.Contains(x.LastFetchRunId)) + .ExecuteDeleteAsync(ct); + } + + // FetchRun → ProviderChannel FK is Restrict, so delete runs only after channels are gone. + var deletedRuns = await db.FetchRuns + .Where(x => x.ProviderId == providerId && !recentRunIds.Contains(x.FetchRunId)) + .ExecuteDeleteAsync(ct); + + return (staleCount, deletedRuns); + } + private async Task RunBuildOnlyAsync(CancellationToken stoppingToken) { _cancelledByUser = false; @@ -429,6 +520,7 @@ private async Task RunBuildOnlyAsync(CancellationToken stoppingToken) runCts.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes)); logger.LogInformation("Snapshot build-only started."); + _refreshStartedAt = timeProvider.GetUtcNow().UtcDateTime; eventBus.Publish(AppEventKind.RefreshStarted); bool succeeded = false; string? errorSummary = null; @@ -458,6 +550,7 @@ private async Task RunBuildOnlyAsync(CancellationToken stoppingToken) } finally { + _refreshStartedAt = null; _currentRunCts = null; eventBus.Publish(AppEventKind.RefreshCompleted, succeeded, errorSummary, changeClass, affectedProfileIds.Count > 0 ? affectedProfileIds : null); diff --git a/src/M3Undle.Web/Components/App.razor b/src/M3Undle.Web/Components/App.razor index 3eef3b4..c6ffd91 100644 --- a/src/M3Undle.Web/Components/App.razor +++ b/src/M3Undle.Web/Components/App.razor @@ -20,9 +20,9 @@ } + - diff --git a/src/M3Undle.Web/Components/EndpointCopyRow.razor b/src/M3Undle.Web/Components/EndpointCopyRow.razor new file mode 100644 index 0000000..9ed03c4 --- /dev/null +++ b/src/M3Undle.Web/Components/EndpointCopyRow.razor @@ -0,0 +1,47 @@ +@inject IJSRuntime JS +@inject ISnackbar Snackbar + + + + + + + @if (ExtraVariants is { Count: > 0 }) + { + + @foreach (var v in ExtraVariants) + { + var item = v; + @item.Label + } + + } + + +@code { + [Parameter, EditorRequired] + public string DefaultUrl { get; set; } = string.Empty; + + [Parameter] + public IReadOnlyList ExtraVariants { get; set; } = []; + + private Task CopyDefaultAsync() => CopyAsync(DefaultUrl); + + private async Task CopyAsync(string text) + { + try + { + var copied = await JS.InvokeAsync("m3undleCopyText", text); + Snackbar.Add( + copied ? "Copied to clipboard" : "Copy failed. Please copy manually.", + copied ? Severity.Success : Severity.Warning); + } + catch (JSException) + { + Snackbar.Add("Copy failed. Please copy manually.", Severity.Warning); + } + } +} diff --git a/src/M3Undle.Web/Components/EndpointUrlVariant.cs b/src/M3Undle.Web/Components/EndpointUrlVariant.cs new file mode 100644 index 0000000..3e06238 --- /dev/null +++ b/src/M3Undle.Web/Components/EndpointUrlVariant.cs @@ -0,0 +1,3 @@ +namespace M3Undle.Web.Components; + +public sealed record EndpointUrlVariant(string Label, string Url); diff --git a/src/M3Undle.Web/Components/Layout/AboutDialog.razor b/src/M3Undle.Web/Components/Layout/AboutDialog.razor new file mode 100644 index 0000000..9defe74 --- /dev/null +++ b/src/M3Undle.Web/Components/Layout/AboutDialog.razor @@ -0,0 +1,93 @@ +@using M3Undle.Core +@using System.Globalization + + + + + + + M3Undle + M3Undle + IPTV Proxy & Management + + + + @VersionDisplay + @if (!string.IsNullOrWhiteSpace(BuildInfo.BuildDateUtc)) + { + @($" {FormatBuildDate(BuildInfo.BuildDateUtc)}") + } + + + + M3Undle is a modern self-hosted IPTV proxy and playlist management platform focused on reliability, compatibility, and transparency. + + + + Open Source + Self Hosted + Docker Ready + Apache 2.0 + + + + + + + + + GitHub + + + · + Issues + · + + + + Buy Me a Coffee + + + · + + + + Sponsor + + + + + + + + + + Close + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public AppBuildInfo BuildInfo { get; set; } = null!; + + private void Close() => MudDialog.Close(); + + private string VersionDisplay => + string.IsNullOrWhiteSpace(BuildInfo.GitHash) + ? BuildInfo.Version + : $"{BuildInfo.Version} ({BuildInfo.GitHash})"; + + private static string FormatBuildDate(string? iso) + { + if (string.IsNullOrWhiteSpace(iso)) return iso ?? string.Empty; + return DateTime.TryParse(iso, null, DateTimeStyles.RoundtripKind, out var dt) + ? dt.ToString("MMMM d, yyyy", CultureInfo.InvariantCulture) + : iso; + } +} diff --git a/src/M3Undle.Web/Components/Layout/AboutDialog.razor.css b/src/M3Undle.Web/Components/Layout/AboutDialog.razor.css new file mode 100644 index 0000000..b96e724 --- /dev/null +++ b/src/M3Undle.Web/Components/Layout/AboutDialog.razor.css @@ -0,0 +1,18 @@ +.about-tag { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border: 1px solid rgba(56, 189, 248, 0.2); + border-radius: 20px; + font-size: 0.72rem; + color: rgba(56, 189, 248, 0.5); + letter-spacing: 0.03em; +} + +.about-footer { + font-size: 0.68rem; + color: var(--mud-palette-text-secondary); + opacity: 0.4; + text-align: center; + line-height: 1.5; +} diff --git a/src/M3Undle.Web/Components/Layout/LoginDisplay.razor b/src/M3Undle.Web/Components/Layout/LoginDisplay.razor index eadcae0..fbe510c 100644 --- a/src/M3Undle.Web/Components/Layout/LoginDisplay.razor +++ b/src/M3Undle.Web/Components/Layout/LoginDisplay.razor @@ -5,14 +5,36 @@ { - - -
- - - Logout - -
+ + +
+ + + + +
+ Signed in as + @UserName +
+
+ +
+ + + + Sign out + + +
+
diff --git a/src/M3Undle.Web/Components/Layout/LoginDisplay.razor.css b/src/M3Undle.Web/Components/Layout/LoginDisplay.razor.css new file mode 100644 index 0000000..3b01533 --- /dev/null +++ b/src/M3Undle.Web/Components/Layout/LoginDisplay.razor.css @@ -0,0 +1,18 @@ +.user-menu-card { + width: 240px; + padding: 14px; +} + +.user-menu-header { + min-width: 0; +} + +.user-menu-identity { + min-width: 0; +} + +.user-menu-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/M3Undle.Web/Components/Layout/MainLayout.razor b/src/M3Undle.Web/Components/Layout/MainLayout.razor index 3d6a934..b24c3d8 100644 --- a/src/M3Undle.Web/Components/Layout/MainLayout.razor +++ b/src/M3Undle.Web/Components/Layout/MainLayout.razor @@ -7,8 +7,8 @@ @inject M3Undle.Web.Application.IEventService EventService @inject IDialogService DialogService @inject ISnackbar Snackbar -@inject M3Undle.Web.Application.IHdHomeRunSettingsService HdhrSettingsService -@inject M3Undle.Web.Application.HdHomeRunTunerManager HdhrTunerManager +@inject M3Undle.Web.Streaming.Observability.StreamingRegistry StreamingRegistry +@inject M3Undle.Web.Application.LineupStatusService LineupStatusService @using System.Reflection @using System.Threading.Channels @using Microsoft.AspNetCore.Components.Authorization @@ -16,6 +16,8 @@ @using M3Undle.Web.Contracts @inject M3Undle.Web.Application.ChannelStatsService StatsService @inject M3Undle.Web.Application.ISiteSettingsService SiteSettings +@inject M3Undle.Core.AppBuildInfo BuildInfo +@inject ILogger Logger @@ -23,50 +25,90 @@ - + + + + + + + M3Undle + + + @if (_isRefreshing) + { + + + Updating lineup… + + } + + + + + + + + + + + + + + + - - - M3Undle - + @if (_drawerOpen) + { + + + M3Undle + + } + else + { + + + + } + + - - - - - M3Undle - - - @if (_isRefreshing) + + @if (CurrentSetupStep != SetupStep.None) { - - - Updating lineup… - + + + Setup Required + + @if (CurrentSetupStep == SetupStep.NeedsProvider) + { + No active profile — add a provider on the Providers page to get started. + } + else if (CurrentSetupStep == SetupStep.NeedsEnabledProvider) + { + The active profile has no enabled provider — open Providers and add or enable one. + } + else + { + No channels are published — open Channel Mapping, include the groups you want, then build the lineup. + } + + + } - - - - - - - - - - - - - - @Body @@ -74,15 +116,10 @@ - v@(_version) - @if (_hdhrStatus is not null) - { - - - @HdhrStatusLabel - - - } + v@(BuildInfo.Version) + @StreamsLabel + Clients @_clientCount @if (_channelStats is not null) @@ -108,11 +145,27 @@ } } + @if (HasStatusDelays) + { + + + Status delayed + + + } + + + @HealthLabel + @code { + private const int FooterHeightPx = 28; + private static readonly TimeSpan UiQueryTimeout = TimeSpan.FromSeconds(3); + private bool _drawerOpen; + private bool _drawerWasOpen; private bool _isDarkMode; private ChannelMappingStatsDto? _channelStats; private bool _systemIsDark; @@ -120,8 +173,15 @@ private bool _eventPanelOpen; private SystemEventSummary _eventSummary = new(0, null); private bool _canViewEvents = true; - - private HdhrFooterStatus? _hdhrStatus; + private bool _eventSummaryDelayed; + private bool _channelStatsDelayed; + private bool _lineupStatusDelayed; + + private int _streamCount; + private int _clientCount; + private int? _providerMaxStreams; + private string _lineupStatusCode = string.Empty; + private bool _activeProfileHasProvider; private IDisposable? _eventSubscription; private readonly CancellationTokenSource _cts = new(); @@ -129,14 +189,11 @@ private Task? AuthStateTask { get; set; } private MudThemeProvider _themeProvider = default!; - private static readonly string _version = - typeof(MainLayout).Assembly - .GetCustomAttribute() - ?.InformationalVersion ?? "unknown"; - private enum ThemeMode { Auto, Light, Dark } private ThemeMode _mode = ThemeMode.Auto; + private string DrawerViewportStyle => $"height: calc(100dvh - {FooterHeightPx}px); bottom: {FooterHeightPx}px;"; + private string ThemeModeIcon => _mode switch { ThemeMode.Light => Icons.Material.Filled.LightMode, @@ -151,36 +208,75 @@ _ => "Auto (system) — click for light" }; - private string HdhrStatusLabel => _hdhrStatus switch + private string StreamsLabel => _providerMaxStreams.HasValue + ? $"Streams {_streamCount}/{_providerMaxStreams} max" + : $"Streams {_streamCount}"; + + private string HealthLabel => _lineupStatusCode switch { - null => string.Empty, - { DisabledByEnvironment: true } => "HDHR off (env)", - { Enabled: false } => "HDHR off", - _ => $"HDHR {_hdhrStatus.ActiveLeaseCount}/{_hdhrStatus.EffectiveTunerCount}" + LineupStatusCodes.Ok => "Healthy", + LineupStatusCodes.Refreshing => "Refreshing", + LineupStatusCodes.Switching => "Switching", + LineupStatusCodes.Degraded => "Degraded", + LineupStatusCodes.NoActiveProfile => "Setup Required", + LineupStatusCodes.NoActiveSnapshot => "Setup Required", + _ => "Unknown" }; - private Color HdhrStatusColor => _hdhrStatus switch + private bool HasStatusDelays => _eventSummaryDelayed || _channelStatsDelayed || _lineupStatusDelayed; + + private string StatusDelaySummary { - null => Color.Inherit, - { DisabledByEnvironment: true } => Color.Warning, - { Enabled: false } => Color.Default, - { ActiveLeaseCount: > 0 } => Color.Success, - _ => Color.Info + get + { + var delayed = new List(3); + if (_eventSummaryDelayed) delayed.Add("system events"); + if (_channelStatsDelayed) delayed.Add("channel stats"); + if (_lineupStatusDelayed) delayed.Add("lineup status"); + return delayed.Count == 0 + ? "All status data is up to date." + : $"Delayed status data: {string.Join(", ", delayed)}."; + } + } + + private string HealthIcon => _lineupStatusCode switch + { + LineupStatusCodes.Ok => Icons.Material.Filled.CheckCircle, + LineupStatusCodes.Refreshing or LineupStatusCodes.Switching => Icons.Material.Filled.Sync, + LineupStatusCodes.Degraded or LineupStatusCodes.NoActiveProfile or LineupStatusCodes.NoActiveSnapshot => Icons.Material.Filled.Warning, + _ => Icons.Material.Filled.Help }; - private string HdhrTooltipText => _hdhrStatus is null - ? "HDHomeRun status unavailable." - : string.Join(" | ", - [ - _hdhrStatus.DisabledByEnvironment ? "Disabled by M3UNDLE_HDHR_ENABLED" : _hdhrStatus.Enabled ? "Enabled" : "Disabled in Settings", - $"Tuners {_hdhrStatus.ActiveLeaseCount}/{_hdhrStatus.EffectiveTunerCount}", - _hdhrStatus.DiscoveryEnabled ? "Discovery on" : "Discovery off", - _hdhrStatus.SsdpEnabled ? "SSDP on" : "SSDP off", - _hdhrStatus.SiliconDustDiscoveryEnabled ? "SiliconDust on" : "SiliconDust off", - $"Base {_hdhrStatus.ResolvedBaseUrl}", - _hdhrStatus.RestartRequired ? "Restart required" : "Settings applied", - "Open Settings" - ]); + private Color HealthColor => _lineupStatusCode switch + { + LineupStatusCodes.Ok => Color.Success, + LineupStatusCodes.Refreshing or LineupStatusCodes.Switching => Color.Info, + LineupStatusCodes.Degraded or LineupStatusCodes.NoActiveProfile or LineupStatusCodes.NoActiveSnapshot => Color.Warning, + _ => Color.Default + }; + + private enum SetupStep { None, NeedsProvider, NeedsEnabledProvider, NeedsChannels } + + private SetupStep CurrentSetupStep + { + get + { + if (_lineupStatusCode == LineupStatusCodes.NoActiveProfile) + return SetupStep.NeedsProvider; + if (_lineupStatusCode == LineupStatusCodes.NoActiveSnapshot && !_activeProfileHasProvider) + return SetupStep.NeedsEnabledProvider; + if (_lineupStatusCode == LineupStatusCodes.NoActiveSnapshot) + return SetupStep.NeedsChannels; + return SetupStep.None; + } + } + + private string SetupBannerIcon => CurrentSetupStep switch + { + SetupStep.NeedsProvider => Icons.Material.Filled.AccountCircle, + SetupStep.NeedsEnabledProvider => Icons.Material.Filled.CloudQueue, + _ => Icons.Material.Filled.LiveTv, + }; private static readonly MudTheme _theme = new() { @@ -212,7 +308,8 @@ }, LayoutProperties = new LayoutProperties { - DrawerWidthLeft = "260px", + DrawerWidthLeft = "220px", + DrawerMiniWidthLeft = "56px", }, PaletteDark = new PaletteDark { @@ -243,23 +340,33 @@ } }; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() { _isRefreshing = RefreshTrigger.IsRefreshing; var reader = EventBus.Subscribe(out _eventSubscription); _ = ListenForEventsAsync(reader, _cts.Token); + _ = InitializeAsync(); + return Task.CompletedTask; + } - var canAccess = await CanAccessApiAsync(); - if (canAccess) + private async Task InitializeAsync() + { + try { - _ = LoadChannelStatsAsync(); - _ = LoadHdhrStatusAsync(); - _ = PollHdhrStatusAsync(_cts.Token); - } + var canAccess = await CanAccessApiAsync(); + if (canAccess) + { + _ = LoadChannelStatsAsync(); + _ = LoadLineupStatusAsync(); + _ = PollStreamAndStatusAsync(_cts.Token); + } - var authEnabled = await SiteSettings.GetAuthenticationEnabledAsync(); - _canViewEvents = !authEnabled || canAccess; - await RefreshEventCountAsync(); + var authEnabled = await SiteSettings.GetAuthenticationEnabledAsync(); + _canViewEvents = !authEnabled || canAccess; + await RefreshEventCountAsync(); + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) { } } private async Task CanAccessApiAsync() @@ -276,14 +383,39 @@ private void OpenEventPanel() { - _eventPanelOpen = true; + if (!_eventPanelOpen) + { + _drawerWasOpen = _drawerOpen; + _drawerOpen = false; + } + _eventPanelOpen = !_eventPanelOpen; + } + + private void OnEventPanelChanged(bool value) + { + _eventPanelOpen = value; + if (!value) + { + _drawerOpen = _drawerWasOpen; + _drawerWasOpen = false; + } } private async Task RefreshEventCountAsync() { try { - _eventSummary = await EventService.GetSummaryAsync(_cts.Token); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + timeoutCts.CancelAfter(UiQueryTimeout); + var ct = timeoutCts.Token; + _eventSummary = await Task.Run(() => EventService.GetSummaryAsync(ct)); + _eventSummaryDelayed = false; + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) when (!_cts.IsCancellationRequested) + { + _eventSummaryDelayed = true; + Logger.LogWarning("Timed out loading event summary for layout status."); await InvokeAsync(StateHasChanged); } catch (OperationCanceledException) { } @@ -293,30 +425,62 @@ { try { - _channelStats = await StatsService.GetStatsAsync(_cts.Token); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + timeoutCts.CancelAfter(UiQueryTimeout); + var ct = timeoutCts.Token; + _channelStats = await Task.Run(() => StatsService.GetStatsAsync(ct)); + _channelStatsDelayed = false; + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) when (!_cts.IsCancellationRequested) + { + _channelStatsDelayed = true; + Logger.LogWarning("Timed out loading channel stats for layout status."); await InvokeAsync(StateHasChanged); } catch (OperationCanceledException) { } } - private async Task LoadHdhrStatusAsync() + private void RefreshStreamCounts() + { + _streamCount = StreamingRegistry.GetActiveSessions().Count; + _clientCount = StreamingRegistry.GetActiveClients().Count; + } + + private async Task LoadLineupStatusAsync() { try { - var state = await HdhrSettingsService.GetSettingsAsync(_cts.Token); - var activeLeaseCount = HdhrTunerManager.GetActiveLeases().Count; - _hdhrStatus = new HdhrFooterStatus( - state.Applied.Enabled, - state.DisabledByEnvironment, - state.Applied.EffectiveTunerCount, - activeLeaseCount, - state.Applied.DiscoveryEnabled, - state.Applied.SsdpEnabled, - state.Applied.SiliconDustDiscoveryEnabled, - state.Applied.ResolvedBaseUrl, - state.RestartRequired); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + timeoutCts.CancelAfter(UiQueryTimeout); + var ct = timeoutCts.Token; + var status = await Task.Run(() => LineupStatusService.GetStatusAsync(ct), ct); + _lineupStatusCode = status.Status; + _providerMaxStreams = status.Lineup?.ActiveProvider?.MaxConcurrentStreams; + _activeProfileHasProvider = status.Lineup?.ActiveProvider is not null; + _lineupStatusDelayed = false; await InvokeAsync(StateHasChanged); } + catch (OperationCanceledException) when (!_cts.IsCancellationRequested) + { + _lineupStatusDelayed = true; + Logger.LogWarning("Timed out loading lineup status for layout status."); + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) { } + } + + private async Task PollStreamAndStatusAsync(CancellationToken ct) + { + try + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3)); + while (await timer.WaitForNextTickAsync(ct)) + { + RefreshStreamCounts(); + await InvokeAsync(StateHasChanged); + } + } catch (OperationCanceledException) { } } @@ -347,13 +511,13 @@ else if (evt.Kind == M3Undle.Web.Application.AppEventKind.GroupFiltersChanged) { await LoadChannelStatsAsync(); - await LoadHdhrStatusAsync(); + await LoadLineupStatusAsync(); } else if (evt.Kind == M3Undle.Web.Application.AppEventKind.RefreshCompleted) { _isRefreshing = false; await LoadChannelStatsAsync(); - await LoadHdhrStatusAsync(); + await LoadLineupStatusAsync(); await InvokeAsync(StateHasChanged); if (!evt.Succeeded && evt.ErrorSummary is not null) @@ -366,11 +530,11 @@ } else if (evt.Kind == M3Undle.Web.Application.AppEventKind.ProviderActivated) { - await LoadHdhrStatusAsync(); + await LoadLineupStatusAsync(); } else if (evt.Kind == M3Undle.Web.Application.AppEventKind.ProviderChanged) { - await LoadHdhrStatusAsync(); + await LoadLineupStatusAsync(); } else if (evt.Kind == M3Undle.Web.Application.AppEventKind.SystemEventPublished) { @@ -381,17 +545,6 @@ catch (OperationCanceledException) { } } - private async Task PollHdhrStatusAsync(CancellationToken ct) - { - try - { - using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10)); - while (await timer.WaitForNextTickAsync(ct)) - await LoadHdhrStatusAsync(); - } - catch (OperationCanceledException) { } - } - public void Dispose() { NavigationManager.LocationChanged -= OnLocationChanged; @@ -435,6 +588,15 @@ }; } + private Task OpenAboutDialogAsync() + { + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true }; + return DialogService.ShowAsync("About M3Undle", new DialogParameters + { + { x => x.BuildInfo, BuildInfo } + }, options); + } + private void ToggleDrawer() => _drawerOpen = !_drawerOpen; private Task CloseDrawer() @@ -443,14 +605,4 @@ return Task.CompletedTask; } - private sealed record HdhrFooterStatus( - bool Enabled, - bool DisabledByEnvironment, - int EffectiveTunerCount, - int ActiveLeaseCount, - bool DiscoveryEnabled, - bool SsdpEnabled, - bool SiliconDustDiscoveryEnabled, - string ResolvedBaseUrl, - bool RestartRequired); } diff --git a/src/M3Undle.Web/Components/Layout/NavMenu.razor b/src/M3Undle.Web/Components/Layout/NavMenu.razor index 262709b..871d17f 100644 --- a/src/M3Undle.Web/Components/Layout/NavMenu.razor +++ b/src/M3Undle.Web/Components/Layout/NavMenu.razor @@ -7,90 +7,117 @@ @inject ISnackbar Snackbar @inject ISiteSettingsService SiteSettings @inject AppEventBus EventBus +@inject ILogger Logger @implements IDisposable - - Overview - Providers - Channels - Profiles - Mapping - - - Review Queue - @if (_pendingReviewCount > 0) - { - @_pendingReviewCount - } - - - What's On - EPG - Streams - Settings - Logs + + + Overview + + + Channels + + + Streams + + + + + Review Queue + @if (_pendingReviewCount > 0) + { + + @_pendingReviewCount + + } + + + + + What's On + + + Mapping + + + + + + Providers + + + Profiles + + + EPG + + + HDHomeRun + + + Logs + - - @(_isRefreshing ? "Refreshing…" : "Refresh Lineup") - + + + @(_isRefreshing ? "Refreshing…" : "Refresh Lineup") + + + @if (_statsDelayed) + { + + + Review count delayed + + + } -@if (_authEnabled) -{ - - - - - - @context.User.Identity?.Name - -
- - - - Logout - - -
- - - Login - - -
-} - @code { + private static readonly TimeSpan UiQueryTimeout = TimeSpan.FromSeconds(3); + [Parameter] public EventCallback OnNavigate { get; set; } - [Inject] - private NavigationManager NavigationManager { get; set; } = default!; - - private string _currentPath = string.Empty; private bool _isRefreshing; private bool _authEnabled; private int _pendingReviewCount; + private bool _statsDelayed; private IDisposable? _eventSubscription; private readonly CancellationTokenSource _cts = new(); [CascadingParameter] private Task? AuthStateTask { get; set; } - protected override async Task OnInitializedAsync() - { - _currentPath = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); - _authEnabled = await SiteSettings.GetAuthenticationEnabledAsync(); + private string ReviewQueueTooltip => _pendingReviewCount > 0 + ? $"Review Queue ({_pendingReviewCount} pending)" + : "Review Queue"; + protected override Task OnInitializedAsync() + { var reader = EventBus.Subscribe(out _eventSubscription); _ = ListenForEventsAsync(reader, _cts.Token); - if (await CanAccessApiAsync()) - await LoadStatsAsync(); + _ = InitializeAsync(); + return Task.CompletedTask; + } + + private async Task InitializeAsync() + { + try + { + _authEnabled = await SiteSettings.GetAuthenticationEnabledAsync(); + + if (await CanAccessApiAsync()) + await LoadStatsAsync(); + + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) { } } private async Task CanAccessApiAsync() @@ -120,10 +147,19 @@ { try { - var stats = await StatsService.GetStatsAsync(_cts.Token); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + timeoutCts.CancelAfter(UiQueryTimeout); + var stats = await StatsService.GetStatsAsync(timeoutCts.Token); _pendingReviewCount = (stats?.GroupsNew ?? 0) + (stats?.PendingChannelsNotified ?? 0); + _statsDelayed = false; StateHasChanged(); } + catch (OperationCanceledException) when (!_cts.IsCancellationRequested) + { + _statsDelayed = true; + Logger.LogWarning("Timed out loading nav menu review queue stats."); + await InvokeAsync(StateHasChanged); + } catch (OperationCanceledException) { } } @@ -134,16 +170,6 @@ _eventSubscription?.Dispose(); } - private Task HandleNavClick(MouseEventArgs _) - { - if (OnNavigate.HasDelegate) - { - return OnNavigate.InvokeAsync(); - } - - return Task.CompletedTask; - } - private async Task TriggerRefreshAsync() { if (_isRefreshing) return; @@ -174,4 +200,3 @@ } } - diff --git a/src/M3Undle.Web/Components/Layout/SystemEventPanel.razor b/src/M3Undle.Web/Components/Layout/SystemEventPanel.razor index bbddc96..97f55aa 100644 --- a/src/M3Undle.Web/Components/Layout/SystemEventPanel.razor +++ b/src/M3Undle.Web/Components/Layout/SystemEventPanel.razor @@ -1,22 +1,30 @@ @using M3Undle.Web.Application @using M3Undle.Web.Data.Entities +@using System.Threading.Channels +@implements IDisposable @inject IEventService EventService +@inject AppEventBus EventBus - - + + System Events - @if (_events.Count > 0) - { - Clear all - } + + @if (_events.Count > 0) + { + Clear all + } + + -
+
@if (_loading) {
@@ -25,33 +33,42 @@ } else if (_events.Count == 0) { -
- No recent events +
+ + No system events
} else { - @foreach (var evt in _events) + @foreach (var item in _eventItems) { + var evt = item.Event;
- @evt.Title + + @evt.Title + @if (item.TotalOccurrences > 1) + { + + + @($"{item.TotalOccurrences} times") + + + } + + OnClick="() => DismissAsync(item.EventIds)" /> - @RelativeTime(evt.OccurredAt) - @if (evt.OccurrenceCount > 1) - { - · @evt.OccurrenceCount occurrences - } + @item.RelativeTime @if (!string.IsNullOrWhiteSpace(evt.Detail)) { @@ -73,7 +90,16 @@ [Parameter] public EventCallback OnCountChanged { get; set; } private List _events = []; + private IReadOnlyList _eventItems = []; private bool _loading; + private IDisposable? _eventSubscription; + private readonly CancellationTokenSource _cts = new(); + + protected override void OnInitialized() + { + var reader = EventBus.Subscribe(out _eventSubscription); + _ = ListenForEventsAsync(reader, _cts.Token); + } protected override async Task OnParametersSetAsync() { @@ -81,19 +107,52 @@ await LoadAsync(); } + private async Task ListenForEventsAsync(ChannelReader reader, CancellationToken ct) + { + try + { + await foreach (var evt in reader.ReadAllAsync(ct)) + { + if (evt.Kind == AppEventKind.SystemEventPublished && Open) + await InvokeAsync(LoadAsync); + } + } + catch (OperationCanceledException) { } + } + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + _eventSubscription?.Dispose(); + } + + private async Task HandleOpenChangedAsync(bool value) + { + await OpenChanged.InvokeAsync(value); + } + + private Task CloseAsync() => OpenChanged.InvokeAsync(false); + private async Task LoadAsync() { _loading = true; StateHasChanged(); _events = [.. await EventService.GetAllAsync()]; + RebuildEventItems(); _loading = false; StateHasChanged(); } - private async Task DismissAsync(string id) + private async Task DismissAsync(IReadOnlyList ids) { - await EventService.DismissAsync(id); - _events.RemoveAll(e => e.SystemEventId == id); + foreach (var id in ids) + { + await EventService.DismissAsync(id); + } + + _events.RemoveAll(e => ids.Contains(e.SystemEventId)); + RebuildEventItems(); await OnCountChanged.InvokeAsync(); StateHasChanged(); } @@ -102,18 +161,17 @@ { await EventService.DismissAllAsync(); _events.Clear(); + _eventItems = []; await OnCountChanged.InvokeAsync(); + await CloseAsync(); StateHasChanged(); } - private static string RelativeTime(DateTime utc) - { - var diff = DateTime.UtcNow - utc; - if (diff.TotalSeconds < 60) return "just now"; - if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; - if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; - return $"{(int)diff.TotalDays}d ago"; - } + private void RebuildEventItems() + => _eventItems = SystemEventPanelGrouping.Group( + _events, + DateTime.UtcNow, + SystemEventPanelGrouping.RelativeTime); private static string SeverityIcon(string severity) => severity switch { diff --git a/src/M3Undle.Web/Components/Layout/SystemEventPanelGrouping.cs b/src/M3Undle.Web/Components/Layout/SystemEventPanelGrouping.cs new file mode 100644 index 0000000..bcb0c5c --- /dev/null +++ b/src/M3Undle.Web/Components/Layout/SystemEventPanelGrouping.cs @@ -0,0 +1,88 @@ +using M3Undle.Web.Data.Entities; + +namespace M3Undle.Web.Components.Layout; + +internal sealed record SystemEventPanelItem( + SystemEvent Event, + IReadOnlyList EventIds, + int TotalOccurrences, + string RelativeTime); + +internal static class SystemEventPanelGrouping +{ + public static IReadOnlyList Group( + IEnumerable events, + DateTime nowUtc, + Func relativeTime) + { + var groups = new Dictionary(); + var order = new List(); + + foreach (var evt in events) + { + var key = new GroupKey( + evt.EventType, + evt.Severity, + evt.Title, + evt.Detail, + evt.ProviderId, + evt.IntegrationId); + + if (groups.TryGetValue(key, out var existing)) + { + existing.EventIds.Add(evt.SystemEventId); + existing.TotalOccurrences += Math.Max(1, evt.OccurrenceCount); + } + else + { + var group = new PendingGroup( + key, + evt, + [evt.SystemEventId], + Math.Max(1, evt.OccurrenceCount), + relativeTime(evt.OccurredAt, nowUtc)); + groups[key] = group; + order.Add(key); + } + } + + return order + .Select(k => + { + var g = groups[k]; + return new SystemEventPanelItem(g.Event, g.EventIds, g.TotalOccurrences, g.RelativeTime); + }) + .ToList(); + } + + public static string RelativeTime(DateTime utc, DateTime nowUtc) + { + var diff = nowUtc - utc; + if (diff.TotalSeconds < 60) return "just now"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + return $"{(int)diff.TotalDays}d ago"; + } + + private sealed class PendingGroup( + GroupKey key, + SystemEvent eventItem, + List eventIds, + int totalOccurrences, + string relativeTime) + { + public GroupKey Key { get; } = key; + public SystemEvent Event { get; } = eventItem; + public List EventIds { get; } = eventIds; + public int TotalOccurrences { get; set; } = totalOccurrences; + public string RelativeTime { get; } = relativeTime; + } + + private sealed record GroupKey( + string EventType, + string Severity, + string Title, + string? Detail, + string? ProviderId, + string? IntegrationId); +} diff --git a/src/M3Undle.Web/Components/Pages/AddProviderDialog.razor b/src/M3Undle.Web/Components/Pages/AddProviderDialog.razor index f68ce52..ad54d6c 100644 --- a/src/M3Undle.Web/Components/Pages/AddProviderDialog.razor +++ b/src/M3Undle.Web/Components/Pages/AddProviderDialog.razor @@ -1,297 +1,152 @@ @using M3Undle.Web.Contracts.Providers +@using M3Undle.Web.Components.ProviderForms @inject M3Undle.Web.Application.ProviderPageService ProviderPageService @inject M3Undle.Web.Application.ProfilesPageService ProfilesPageService +@inject ILogger Logger - @if (!string.IsNullOrWhiteSpace(_error)) + @if (_step == DialogStep.Form) { - @_error - } + + + + + + + + + - - - @* ------------------------------------------------------------------ *@ - @* Tab 0: From URL *@ - @* ------------------------------------------------------------------ *@ - -
- - - - - - - - - - - - @if (LooksLikeXtreamUrl(_urlModel.PlaylistUrl)) + + + + + @if (_showImportTab) + { + +
+ @if (_configProviders.Count == 0) + { + No providers found in config.yaml. + } + else { - - - Xtream API detected. Add as Xtream Codes for richer channel data, stable stream IDs, and subscription expiry tracking. - - @if (_urlAddAsXtream) + var selProvider = _configProviders.FirstOrDefault(p => p.Name == _importSelectedName); + var selImported = !string.IsNullOrEmpty(_importSelectedName) && IsAlreadyImported(_importSelectedName); + var selMissing = selProvider?.MissingEnvVars.Count > 0; + + + + — select a provider — + @foreach (var cp in _configProviders) { - + + @cp.Name + @if (IsAlreadyImported(cp.Name)) { (already imported) } + else if (cp.MissingEnvVars.Count > 0) { (missing env vars) } + } - @if (!_encryptionAvailable) + + + @if (selProvider is not null) + { + + + @if (!string.IsNullOrWhiteSpace(selProvider.XmltvUrl)) { - M3UNDLE_ENCRYPTION_KEY must be configured to add as Xtream Codes. + } - - - } - - - - - - @RenderContentToggles(_urlModel) - @RenderStreamFormatSection(_urlModel) - - @RenderProfileSection(_urlModel) - - -
-
- - @* ------------------------------------------------------------------ *@ - @* Tab 1: From File *@ - @* ------------------------------------------------------------------ *@ - -
- @if (_fileBrowseRootMissing) - { - - @(_fileBrowseError ?? "File browser unavailable.") - - } - - - - - - - - Browse - - - @if (_showFileBrowser) - { - - @if (_browseLoading) + @if (selMissing) + { + + Missing environment variables: @string.Join(", ", selProvider.MissingEnvVars) + + } + else if (selImported) { - + + This provider has already been imported. + } else { - @if (_browseData?.ParentPath is not null) - { - - .. - - } - @if (_browseData is not null) - { - @foreach (var entry in _browseData.Entries) - { - if (entry.IsDirectory) - { - - @entry.Name - - } - else - { - - @entry.Name - - } - } - @if (!_browseData.Entries.Any()) - { - No .m3u or .m3u8 files found here. - } - } + + Use this URL → + } - - } - - - - - - @RenderContentToggles(_fileModel) - @RenderStreamFormatSection(_fileModel) - - @RenderProfileSection(_fileModel) - - -
-
- - @* ------------------------------------------------------------------ *@ - @* Tab 2: Xtream Codes *@ - @* ------------------------------------------------------------------ *@ - -
- @if (!_encryptionAvailable) - { - - M3UNDLE_ENCRYPTION_KEY is not set. - - Xtream Codes providers require encrypted password storage. Generate a key and set the - environment variable before adding this provider type: - - - openssl rand -base64 32 - - - Then set M3UNDLE_ENCRYPTION_KEY=<output> and restart M3Undle. - - - } - - - - - - - - - - @RenderContentToggles(_xtreamModel) - @RenderStreamFormatSection(_xtreamModel) - - @RenderProfileSection(_xtreamModel) - - -
-
- - @* ------------------------------------------------------------------ *@ - @* Tab 3: Import from config.yaml (only shown when available) *@ - @* ------------------------------------------------------------------ *@ - @if (_showImportTab) - { - -
- @if (_configProviders.Count == 0) - { - No providers found in config.yaml. - } - else - { - var selProvider = _configProviders.FirstOrDefault(p => p.Name == _importSelectedName); - var selImported = !string.IsNullOrEmpty(_importSelectedName) && IsAlreadyImported(_importSelectedName); - var selMissing = selProvider?.MissingEnvVars.Count > 0; - - - - — select a provider — - @foreach (var cp in _configProviders) - { - - @cp.Name - @if (IsAlreadyImported(cp.Name)) { (already imported) } - else if (cp.MissingEnvVars.Count > 0) { (missing env vars) } - - } - - - @if (selProvider is not null) - { - - - @if (!string.IsNullOrWhiteSpace(selProvider.XmltvUrl)) - { - - } - - @if (selMissing) - { - - Missing environment variables: @string.Join(", ", selProvider.MissingEnvVars) - - } - else if (selImported) - { - - This provider has already been imported. - } - else - { - - Use this URL → - - } - } - - } -
-
- } +
+ } +
+
+ } -
+
+ } + else + { +
+ + + + @_savedProvider!.Name added + + + + Does your subscription limit simultaneous streams? Setting this prevents M3Undle from + accepting new stream requests once the limit is reached, protecting active viewers from + being cut off. + + + + + @if (!string.IsNullOrWhiteSpace(_finalizeError)) + { + @_finalizeError + } + +
+ }
- Cancel - - @if (_activeTab != ImportTabIndex) + @if (_step == DialogStep.Form) { + Cancel + + @if (_activeTab != ImportTabIndex) + { + + @(_isSaving ? "Adding..." : "Add Provider") + + } + } + else + { + - @(_isSaving ? "Adding..." : "Add Provider") + Disabled="_isSaving" + OnClick="FinalizeDoneAsync"> + @(_isSaving ? "Saving..." : "Done") } @@ -303,61 +158,40 @@ [Parameter] public List Providers { get; set; } = []; [Parameter] public EventCallback OnSaved { get; set; } + private enum DialogStep { Form, Finalize } + private DialogStep _step = DialogStep.Form; + private int _activeTab; private int ImportTabIndex => _showImportTab ? 3 : -1; - private MudForm? _urlForm; - private MudForm? _fileForm; - private MudForm? _xtreamForm; + private UrlProviderForm? _urlFormRef; + private UrlProviderForm? _fileFormRef; + private XtreamProviderForm? _xtreamFormRef; private bool _isSaving; - private string? _error; private bool _encryptionAvailable; - private bool _fileBrowseRootMissing; - private string? _fileBrowseError; private bool _showImportTab; - // URL tab - private readonly ProviderFormModel _urlModel = new() { TimeoutSeconds = 120, Enabled = true }; - private bool _urlAddAsXtream; - private bool _urlAddAsXtreamIncludeXmltv; - - // File tab - private readonly ProviderFormModel _fileModel = new() { TimeoutSeconds = 120, Enabled = true }; - private bool _showFileBrowser; - private bool _browseLoading; - private FileBrowseDto? _browseData; + private XtreamPrefill? _xtreamPrefill; + private UrlProviderPrefill? _urlPrefill; - // Xtream tab - private readonly XtreamFormModel _xtreamModel = new() { TimeoutSeconds = 120, Enabled = true }; + private List _localProfiles = []; - // Import tab private string _importSelectedName = string.Empty; private readonly List _configProviders = []; private readonly List _importedProviders = []; - private const string _urlHelperText = - "Enter an http/https URL.\n\n" + - "To keep credentials out of the database, place them in a .env file in M3UNDLE_CONFIG_DIR " + - "and reference them with %VAR_NAME% syntax.\n\n" + - "Example URL:\n" + - " http://my.server:8080/get.php?username=alice&password=%MY_PASSWORD%\n\n" + - ".env file entry:\n" + - " MY_PASSWORD=supersecret"; - - private List _localProfiles = []; + private ProviderDto? _savedProvider; + private bool _finalizeLimit; + private int _finalizeLimitCount = 1; + private string? _finalizeError; + private bool _finalizeSeeded; protected override async Task OnInitializedAsync() { _localProfiles = [.. Profiles]; - _encryptionAvailable = await ProviderPageService.GetEncryptionAvailableAsync(); - var browse = await ProviderPageService.BrowseFilesystemAsync(null); - _fileBrowseRootMissing = browse.Data is null; - if (_fileBrowseRootMissing) - _fileBrowseError = browse.Error; - try { var config = await ProviderPageService.GetAvailableConfigProvidersAsync(CancellationToken.None); @@ -366,342 +200,118 @@ _importedProviders.AddRange(pageData.Providers); _showImportTab = _configProviders.Count > 0; } - catch { } - - var defaultProfile = AvailableProfiles.FirstOrDefault(x => x.Enabled); - if (defaultProfile is not null) + catch (Exception ex) { - _urlModel.ProfileId = defaultProfile.ProfileId; - _fileModel.ProfileId = defaultProfile.ProfileId; - _xtreamModel.ProfileId = defaultProfile.ProfileId; + Logger.LogWarning(ex, "Failed to load config.yaml providers for Import tab."); } } - private IEnumerable AvailableProfiles => _localProfiles.Where(p => - !Providers.Any(prov => prov.AssociatedProfileIds.Contains(p.ProfileId))); - - private bool IsSaveDisabled() => _isSaving || (_activeTab == 2 && !_encryptionAvailable); - private void Cancel() => MudDialog.Cancel(); - private RenderFragment RenderProfileSection(ProviderFormModel model) => __builder => - { - p.ProfileId == id)?.Name ?? id)"> - (Auto-create from name) - @foreach (var profile in AvailableProfiles) - { - @profile.Name - } - - }; - - private RenderFragment RenderProfileSection(XtreamFormModel model) => __builder => - { - p.ProfileId == id)?.Name ?? id)"> - (Auto-create from name) - @foreach (var profile in AvailableProfiles) - { - @profile.Name - } - - }; - - private RenderFragment RenderContentToggles(ProviderFormModel model) => __builder => - { - - Content Types - - - }; - - private RenderFragment RenderContentToggles(XtreamFormModel model) => __builder => - { - - Content Types - - - }; - - private RenderFragment RenderStreamFormatSection(ProviderFormModel model) => __builder => - { - - Stream Format - - @if (model.ForceMpegTs) - { - - Upstream URLs with output=m3u8 will be rewritten to output=ts. HLS delivery to clients is also disabled for this provider. - - } - - @if (model.CleanRelayRemux) - { - - Uses FFmpeg to remux live streams and repair timestamps. Adds CPU usage and some startup latency. - - } - }; - - private RenderFragment RenderStreamFormatSection(XtreamFormModel model) => __builder => - { - - Stream Format - - @if (model.ForceMpegTs) - { - - HLS delivery to clients is disabled for this provider. - - } - - @if (model.CleanRelayRemux) - { - - Uses FFmpeg to remux live streams and repair timestamps. Adds CPU usage and some startup latency. - - } - }; - - // ------------------------------------------------------------------ - // File browser - // ------------------------------------------------------------------ - - private async Task OpenFileBrowserAsync() - { - _showFileBrowser = true; - await BrowseToAsync(null); - } - - private async Task BrowseToAsync(string? path) + private async Task SubmitActiveTabAsync() { - _browseLoading = true; + _isSaving = true; StateHasChanged(); try { - var browse = await ProviderPageService.BrowseFilesystemAsync(path); - if (browse.Data is not null) - { - _browseData = browse.Data; - } - else + await (_activeTab switch { - _fileBrowseRootMissing = true; - _fileBrowseError = browse.Error; - } + 0 => _urlFormRef?.TrySubmitAsync() ?? Task.CompletedTask, + 1 => _fileFormRef?.TrySubmitAsync() ?? Task.CompletedTask, + 2 => _xtreamFormRef?.TrySubmitAsync() ?? Task.CompletedTask, + _ => Task.CompletedTask + }); } - catch { _fileBrowseRootMissing = true; } finally { - _browseLoading = false; + _isSaving = false; StateHasChanged(); } } - private Task SelectFileAsync(string filePath) + private void HandleSwitchToXtream(XtreamPrefill prefill) { - _fileModel.PlaylistUrl = $"file://{filePath}"; - _showFileBrowser = false; + _xtreamPrefill = prefill; + _activeTab = 2; StateHasChanged(); - return Task.CompletedTask; } - // ------------------------------------------------------------------ - // Save - // ------------------------------------------------------------------ + private async Task HandleSaved(ProviderDto provider) + { + var linkedProfileId = provider.AssociatedProfileIds.FirstOrDefault(); + if (!string.IsNullOrEmpty(linkedProfileId)) + await ProfilesPageService.AutoActivateProfileIfNoneAsync(linkedProfileId, CancellationToken.None); + + _savedProvider = provider; + + if (!_finalizeSeeded) + { + _finalizeLimit = provider.MaxConcurrentStreams.HasValue; + _finalizeLimitCount = provider.MaxConcurrentStreams ?? 1; + _finalizeSeeded = true; + } + + _step = DialogStep.Finalize; + StateHasChanged(); + } - private async Task SaveAsync() + private async Task FinalizeDoneAsync() { - _error = null; _isSaving = true; - + _finalizeError = null; + StateHasChanged(); try { - (ProviderDto? Provider, string? Error) result; - - if (_activeTab == 0) + if (_finalizeLimit && _savedProvider is not null) { - if (_urlForm is not null) { await _urlForm.ValidateAsync(); if (!_urlForm.IsValid) return; } - - if (_urlAddAsXtream && LooksLikeXtreamUrl(_urlModel.PlaylistUrl)) + var error = await ProviderPageService.UpdateProviderAsync(_savedProvider.ProviderId, new UpdateProviderRequest { - var creds = ProviderPageService.ParseXtreamUrl(_urlModel.PlaylistUrl.Trim()); - if (creds is null) - { - _error = "Unable to extract Xtream credentials from the URL. Ensure it includes username and password parameters."; - return; - } - result = await ProviderPageService.CreateProviderAsync(new CreateProviderRequest - { - Name = _urlModel.Name.Trim(), - XtreamBaseUrl = creds.Value.BaseUrl, - XtreamUsername = creds.Value.Username, - XtreamPassword = creds.Value.Password, - XtreamIncludeXmltv = _urlAddAsXtreamIncludeXmltv, - Enabled = _urlModel.Enabled, - IncludeVod = _urlModel.IncludeVod, - IncludeSeries = _urlModel.IncludeSeries, - ForceMpegTs = _urlModel.ForceMpegTs, - CleanRelayMode = _urlModel.CleanRelayRemux ? "remux" : "off", - TimeoutSeconds = _urlModel.TimeoutSeconds, - MaxConcurrentStreams = _urlModel.MaxConcurrentStreams, - AssociateToProfileIds = ProfileIdList(_urlModel.ProfileId), - }, CancellationToken.None); - } - else - { - result = await ProviderPageService.CreateProviderAsync(new CreateProviderRequest - { - Name = _urlModel.Name.Trim(), - PlaylistUrl = _urlModel.PlaylistUrl.Trim(), - XmltvUrl = NullIfEmpty(_urlModel.XmltvUrl), - HeadersJson = NullIfEmpty(_urlModel.HeadersJson), - UserAgent = NullIfEmpty(_urlModel.UserAgent), - Enabled = _urlModel.Enabled, - IncludeVod = _urlModel.IncludeVod, - IncludeSeries = _urlModel.IncludeSeries, - ForceMpegTs = _urlModel.ForceMpegTs, - CleanRelayMode = _urlModel.CleanRelayRemux ? "remux" : "off", - TimeoutSeconds = _urlModel.TimeoutSeconds, - MaxConcurrentStreams = _urlModel.MaxConcurrentStreams, - AssociateToProfileIds = ProfileIdList(_urlModel.ProfileId), - }, CancellationToken.None); - } - } - else if (_activeTab == 1) - { - if (_fileForm is not null) { await _fileForm.ValidateAsync(); if (!_fileForm.IsValid) return; } - if (string.IsNullOrWhiteSpace(_fileModel.PlaylistUrl)) + Name = _savedProvider.Name, + PlaylistUrl = _savedProvider.PlaylistUrl, + XmltvUrl = _savedProvider.XmltvUrl, + HeadersJson = _savedProvider.HeadersJson, + UserAgent = _savedProvider.UserAgent, + Enabled = _savedProvider.Enabled, + IncludeVod = _savedProvider.IncludeVod, + IncludeSeries = _savedProvider.IncludeSeries, + ForceMpegTs = _savedProvider.ForceMpegTs, + CleanRelayMode = _savedProvider.CleanRelayMode, + TimeoutSeconds = _savedProvider.TimeoutSeconds, + MaxConcurrentStreams = _finalizeLimitCount, + AssociateToProfileIds = _savedProvider.AssociatedProfileIds.Count > 0 + ? _savedProvider.AssociatedProfileIds + : null, + XtreamBaseUrl = _savedProvider.XtreamBaseUrl, + XtreamUsername = _savedProvider.XtreamUsername, + XtreamIncludeXmltv = _savedProvider.XtreamIncludeXmltv, + }, CancellationToken.None); + + if (!string.IsNullOrWhiteSpace(error)) { - _error = "Please select a file using the Browse button."; + _finalizeError = error; return; } - result = await ProviderPageService.CreateProviderAsync(new CreateProviderRequest - { - Name = _fileModel.Name.Trim(), - PlaylistUrl = _fileModel.PlaylistUrl, - XmltvUrl = NullIfEmpty(_fileModel.XmltvUrl), - Enabled = _fileModel.Enabled, - IncludeVod = _fileModel.IncludeVod, - IncludeSeries = _fileModel.IncludeSeries, - ForceMpegTs = _fileModel.ForceMpegTs, - CleanRelayMode = _fileModel.CleanRelayRemux ? "remux" : "off", - TimeoutSeconds = _fileModel.TimeoutSeconds, - MaxConcurrentStreams = _fileModel.MaxConcurrentStreams, - AssociateToProfileIds = ProfileIdList(_fileModel.ProfileId), - }, CancellationToken.None); - } - else - { - if (_xtreamForm is not null) { await _xtreamForm.ValidateAsync(); if (!_xtreamForm.IsValid) return; } - result = await ProviderPageService.CreateProviderAsync(new CreateProviderRequest - { - Name = _xtreamModel.Name.Trim(), - XtreamBaseUrl = _xtreamModel.XtreamBaseUrl.Trim(), - XtreamUsername = _xtreamModel.XtreamUsername.Trim(), - XtreamPassword = _xtreamModel.XtreamPassword, - XtreamIncludeXmltv = _xtreamModel.XtreamIncludeXmltv, - Enabled = _xtreamModel.Enabled, - IncludeVod = _xtreamModel.IncludeVod, - IncludeSeries = _xtreamModel.IncludeSeries, - ForceMpegTs = _xtreamModel.ForceMpegTs, - CleanRelayMode = _xtreamModel.CleanRelayRemux ? "remux" : "off", - TimeoutSeconds = _xtreamModel.TimeoutSeconds, - MaxConcurrentStreams = _xtreamModel.MaxConcurrentStreams, - AssociateToProfileIds = ProfileIdList(_xtreamModel.ProfileId), - }, CancellationToken.None); - } - - if (!string.IsNullOrWhiteSpace(result.Error)) - { - _error = result.Error; - return; } - var linkedProfileId = result.Provider?.AssociatedProfileIds.FirstOrDefault(); - if (!string.IsNullOrEmpty(linkedProfileId)) - await ProfilesPageService.AutoActivateProfileIfNoneAsync(linkedProfileId, CancellationToken.None); - await OnSaved.InvokeAsync(); MudDialog.Close(DialogResult.Ok(true)); } - catch (Exception ex) - { - _error = $"Failed to save provider: {ex.Message}"; - } finally { _isSaving = false; + StateHasChanged(); } } - // ------------------------------------------------------------------ - // Import tab helpers - // ------------------------------------------------------------------ - - private static bool LooksLikeXtreamUrl(string? url) => - !string.IsNullOrEmpty(url) - && url.Contains("/get.php", StringComparison.OrdinalIgnoreCase) - && url.Contains("username=", StringComparison.OrdinalIgnoreCase) - && url.Contains("password=", StringComparison.OrdinalIgnoreCase); - private bool IsAlreadyImported(string name) => _importedProviders.Any(p => p.Name == name); private void UseImportedProviderUrl(ConfigYamlProviderDto provider) { - _urlModel.Name = provider.Name; - _urlModel.PlaylistUrl = provider.PlaylistUrl; - if (!string.IsNullOrWhiteSpace(provider.XmltvUrl)) - _urlModel.XmltvUrl = provider.XmltvUrl; + _urlPrefill = new UrlProviderPrefill( + Name: provider.Name, + Url: provider.PlaylistUrl, + XmltvUrl: string.IsNullOrWhiteSpace(provider.XmltvUrl) ? null : provider.XmltvUrl); _activeTab = 0; StateHasChanged(); } - - // ------------------------------------------------------------------ - // Helpers - // ------------------------------------------------------------------ - - private static string? NullIfEmpty(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); - private static List? ProfileIdList(string id) => - string.IsNullOrWhiteSpace(id) ? null : [id]; - - // ------------------------------------------------------------------ - // Inner types - // ------------------------------------------------------------------ - - private sealed class ProviderFormModel - { - public string Name { get; set; } = string.Empty; - public string PlaylistUrl { get; set; } = string.Empty; - public string? XmltvUrl { get; set; } - public string? HeadersJson { get; set; } - public string? UserAgent { get; set; } - public bool Enabled { get; set; } = true; - public int TimeoutSeconds { get; set; } = 120; - public int? MaxConcurrentStreams { get; set; } - public bool IncludeVod { get; set; } = true; - public bool IncludeSeries { get; set; } = true; - public bool ForceMpegTs { get; set; } - public bool CleanRelayRemux { get; set; } - public string ProfileId { get; set; } = string.Empty; - } - - private sealed class XtreamFormModel - { - public string Name { get; set; } = string.Empty; - public string XtreamBaseUrl { get; set; } = string.Empty; - public string XtreamUsername { get; set; } = string.Empty; - public string XtreamPassword { get; set; } = string.Empty; - public bool XtreamIncludeXmltv { get; set; } - public bool Enabled { get; set; } = true; - public int TimeoutSeconds { get; set; } = 120; - public int? MaxConcurrentStreams { get; set; } - public bool IncludeVod { get; set; } = true; - public bool IncludeSeries { get; set; } = true; - public bool ForceMpegTs { get; set; } - public bool CleanRelayRemux { get; set; } - public string ProfileId { get; set; } = string.Empty; - } - } diff --git a/src/M3Undle.Web/Components/Pages/Channels/ChannelMapping.razor b/src/M3Undle.Web/Components/Pages/Channels/ChannelMapping.razor index 80a9f75..3d0c8d9 100644 --- a/src/M3Undle.Web/Components/Pages/Channels/ChannelMapping.razor +++ b/src/M3Undle.Web/Components/Pages/Channels/ChannelMapping.razor @@ -10,10 +10,12 @@ @inject IRefreshTrigger RefreshTrigger @inject ISnackbar Snackbar @inject IJSRuntime JS -@implements IDisposable +@implements IAsyncDisposable M3Undle — Channel Mapping +
+ @@ -47,7 +49,9 @@ DebounceInterval="600" Immediate="true" OnDebounceIntervalElapsed="SaveDefaultStartNumAsync" /> - + + + } -@if (_profileId is null && !_isLoading) -{ - - No active profile found. Go to Profiles to set one active. - -} -else +@if (_profileId is not null || _isLoading) { @@ -162,34 +160,80 @@ else @if (!_isLoading && _profileId is not null) { - var liveFilters = _allFilters.Where(f => f.ProviderGroupContentType == "live").ToList(); - var mapped = liveFilters.Where(f => - IsIncluded(f) && - GetSelectedChannelCount(f) > 0) + var cgLinkedGroupIds = _customGroups + .SelectMany(cg => cg.LinkedProviderGroupIds) + .ToHashSet(StringComparer.Ordinal); + + var liveFilters = _allFilters + .Where(f => f.ProviderGroupContentType == "live" && !cgLinkedGroupIds.Contains(f.ProviderGroupId)) .ToList(); - var unmapped = liveFilters.Where(IsUnmappedBySelection).ToList(); - var excluded = liveFilters.Where(IsExcluded).ToList(); - var newCount = liveFilters.Count(f => f.IsNew); - var chSelected = mapped.Sum(GetSelectedChannelCount); + + // Standalone: no OutputName. VirtualParent members: share an OutputName. + var standaloneFilters = liveFilters.Where(f => f.OutputName is null).ToList(); + var vpGroups = liveFilters + .Where(f => f.OutputName is not null) + .GroupBy(f => f.OutputName!, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Standalone counts + var standaloneMapped = standaloneFilters.Where(f => IsIncluded(f) && GetSelectedChannelCount(f) > 0).ToList(); + var standaloneUnmapped = standaloneFilters.Where(IsUnmappedBySelection).ToList(); + var standaloneExcluded = standaloneFilters.Where(IsExcluded).ToList(); + var standaloneNew = standaloneFilters.Count(f => f.IsNew); + var standaloneChSelected = standaloneMapped.Sum(GetSelectedChannelCount); + + // VirtualParent counts — each unique OutputName = 1 logical group + var vpMapped = vpGroups.Count(g => g.Any(f => IsIncluded(f) && GetSelectedChannelCount(f) > 0)); + var vpUnmapped = vpGroups.Count(g => g.Any(IsIncluded) && !g.Any(f => IsIncluded(f) && GetSelectedChannelCount(f) > 0)); + var vpExcluded = vpGroups.Count(g => g.All(IsExcluded)); + var vpNew = vpGroups.Count(g => g.Any(f => f.IsNew)); + var vpChSelected = vpGroups + .Where(g => g.Any(f => IsIncluded(f) && GetSelectedChannelCount(f) > 0)) + .Sum(g => g.Where(IsIncluded).Sum(GetSelectedChannelCount)); + + // Custom group counts + var cgMapped = _customGroups.Count(cg => IsCgIncluded(cg) && cg.SelectedChannelCount > 0); + var cgUnmapped = _customGroups.Count(cg => IsCgIncluded(cg) && cg.SelectedChannelCount == 0); + var cgExcluded = _customGroups.Count(IsCgExcluded); + var cgChSelected = _customGroups + .Where(cg => IsCgIncluded(cg) && cg.SelectedChannelCount > 0) + .Sum(cg => cg.SelectedChannelCount); + + // Removed = included groups no longer present in the provider feed + var standaloneRemoved = standaloneFilters.Count(f => IsIncluded(f) && !f.ProviderGroupActive); + var vpRemoved = vpGroups.Count(g => g.Any(f => IsIncluded(f) && !f.ProviderGroupActive)); + var removedCount = standaloneRemoved + vpRemoved; + + var totalMapped = standaloneMapped.Count + vpMapped + cgMapped; + var totalUnmapped = standaloneUnmapped.Count + vpUnmapped + cgUnmapped; + var totalExcluded = standaloneExcluded.Count + vpExcluded + cgExcluded; + var newCount = standaloneNew + vpNew; + var totalChSelected = standaloneChSelected + vpChSelected + cgChSelected; - @mapped.Count mapped · @chSelected.ToString("N0") ch used - - @unmapped.Count unmapped - - @excluded.Count excluded + + + @if (newCount > 0) { - @newCount new + } + @if (removedCount > 0) + { + @if (newCount == 0) { } + + } } @@ -274,10 +327,12 @@ else var isExpanded = _expandedGroups.Contains(filterId); var chLoading = _loadingChannels.Contains(filterId); var isEditing = _editingFilterId == filterId; - var rowStyle = isExcluded ? "opacity:0.55;" : string.Empty; + var isMapped = !isExcluded && (isAutoMode || GetSelectedChannelCount(filter) > 0); + var rowStyle = isExcluded ? "opacity:0.55;" : (isMapped ? "box-shadow:inset 3px 0 0 var(--mud-palette-primary);" : string.Empty); var displayName = filter.OutputName ?? filter.ProviderGroupRawName; + var showPpvHint = ShowPpvHint(filter); - + @@ -352,6 +407,12 @@ else OnClick="@(() => DismissNewAsync(filter))">new } + @if (showPpvHint) + { + + event? + + }
} @@ -391,6 +452,7 @@ else } @@ -415,7 +477,8 @@ else { var globalActive = _globalChannelSearchResults.Count > 0; var filterText = globalActive ? string.Empty : _groupChannelFilter.GetValueOrDefault(filterId, string.Empty); - var filtered = GetFilteredGroupChannels(filterId, channels, globalActive, filterText); + var showSelectedOnly = _groupShowSelectedOnly.Contains(filterId); + var filtered = GetFilteredGroupChannels(filterId, channels, globalActive, filterText, showSelectedOnly); var selections = _channelSelections.GetValueOrDefault(filterId) ?? []; @if (!globalActive) @@ -469,6 +532,15 @@ else @GetSelectedChannelCount(filter) of @channels.Count selected + @if (GetSelectedChannelCount(filter) > 0) + { + + + + } } else if (!isExcluded && isAutoMode) { @@ -566,12 +638,16 @@ else ? parent.Members.Sum(GetSelectedChannelCount) : -1; var pendingCount = parent.Members.Count(m => m.IsNew); - var parentStyle = allExcluded ? "opacity:0.55;" : string.Empty; + var parentStyle = string.Empty; var providerNames = string.Join(", ", parent.Members.Select(m => m.ProviderName).Distinct()); var isBusy = parent.Members.Any(m => _busyFilters.Contains(m.ProfileGroupFilterId)); var pFirstActive = parent.Members.FirstOrDefault(m => !IsExcluded(m)) ?? parent.Members.FirstOrDefault(); + var showParentPpvHint = parent.Members.Any(ShowPpvHint); + var parentIsMapped = anyActive && parent.Members.Sum(m => m.SelectedChannelCount) > 0; + var parentDomId = "parent-" + SanitizeDomId(parent.OutputName); + parentStyle = allExcluded ? "opacity:0.55;" : (parentIsMapped ? "box-shadow:inset 3px 0 0 var(--mud-palette-primary);" : string.Empty); - + @if (_editingParentOutputName == parent.OutputName) @@ -641,6 +717,12 @@ else } + @if (showParentPpvHint) + { + + event? + + } @@ -664,6 +746,7 @@ else @@ -1051,6 +1134,53 @@ else } } +
@* close #channel-mapping-content *@ + +@* ── Mapped channels panel ── *@ +
+ @if (!_panelOpen) + { +
+ +
+ } + else + { +
+
+
+ + Mapped (@_panelItems.Count) + + + + +
+
+ @if (_panelItems.Count == 0) + { +
No numbered channels mapped yet.
+ } + else + { + @foreach (var item in _panelItems) + { + + + + } + } +
+
+ } +
+ @* ── Settings drawer ── *@ @if (_settingsDrawerOpen && _drawerTargetId is not null) { @@ -1060,7 +1190,9 @@ else Group Settings - + + + @@ -1082,32 +1214,64 @@ else Label="Mode" Variant="Variant.Outlined" Dense="true" - Disabled="@dfExcluded"> - Manual review - Auto-update + Disabled="@dfExcluded" + HelperText="@GetModeHelperText(LineupReviewSemantics.NormalizeGroupMode(df.ChannelMode))"> + + + Manual selection + + + + + Auto-update + + - Review queue - Notify only - Auto-add all events - Auto-add populated events - Auto-add matching events + Disabled="@dfExcluded" + HelperText="@GetTrackingPolicyHelperText(dfPolicy)"> + + + Queue for review + + + + + Notify only + + + + + Auto-add all + + + + + Auto-add (guide data only) + + + + + Auto-add matching + + - + + + @if (dfIsAutoMatch) { @@ -1174,32 +1338,64 @@ else Label="Mode" Variant="Variant.Outlined" Dense="true" - Disabled="@pAllExcluded"> - Manual review - Auto-update + Disabled="@pAllExcluded" + HelperText="@GetModeHelperText(pMode)"> + + + Manual selection + + + + + Auto-update + + - Review queue - Notify only - Auto-add all events - Auto-add populated events - Auto-add matching events + Disabled="@pAllExcluded" + HelperText="@GetTrackingPolicyHelperText(pPolicy)"> + + + Queue for review + + + + + Notify only + + + + + Auto-add all + + + + + Auto-add (guide data only) + + + + + Auto-add matching + + - + + + @if (pIsAutoMatch) { @@ -1240,32 +1436,64 @@ else Label="Mode" Variant="Variant.Outlined" Dense="true" - Disabled="@cgEx"> - Manual review - Auto-update + Disabled="@cgEx" + HelperText="@GetModeHelperText(LineupReviewSemantics.NormalizeGroupMode(cg.ChannelMode))"> + + + Manual selection + + + + + Auto-update + + - Review queue - Notify only - Auto-add all events - Auto-add populated events - Auto-add matching events + Disabled="@cgEx" + HelperText="@GetTrackingPolicyHelperText(cgPolicy)"> + + + Queue for review + + + + + Notify only + + + + + Auto-add all + + + + + Auto-add (guide data only) + + + + + Auto-add matching + + - + + + @if (cgAutoMatch) { @@ -1298,7 +1526,7 @@ else private string? _profileId; private List _allProfiles = []; private List _allFilters = []; - private bool _isLoading; + private bool _isLoading = true; private bool _isRefreshing; private string? _error; @@ -1314,6 +1542,7 @@ else private Dictionary _groupChannelFilter = []; private Dictionary _parentChannelFilter = []; private HashSet _parentShowSelectedOnly = []; + private HashSet _groupShowSelectedOnly = []; private Dictionary> _hiddenParentMembers = []; private Dictionary _parentStartNum = []; private HashSet _dirtyChannelSelectionFilterIds = []; @@ -1364,6 +1593,23 @@ else private string? _editingParentOutputName; private string _editParentNameText = string.Empty; + // ------------------------------------------------------------------------- + // Mapped channels panel state + // ------------------------------------------------------------------------- + private bool _panelOpen = true; + private int _panelWidth = 280; + private List _panelItems = []; + private IJSObjectReference? _panelResizeHandle; + private bool _panelResizeInitializing; + + private string PanelStyle => _panelOpen + ? $"position:fixed; top:var(--mud-appbar-height,64px); right:0; height:calc(100vh - var(--mud-appbar-height,64px)); width:{_panelWidth}px; z-index:101; display:flex; flex-direction:row;" + : "position:fixed; top:var(--mud-appbar-height,64px); right:0; height:calc(100vh - var(--mud-appbar-height,64px)); width:28px; z-index:101;"; + + private string ContentMarginStyle => _panelOpen + ? $"margin-right:{_panelWidth}px; transition:margin-right 0.2s ease;" + : "margin-right:28px; transition:margin-right 0.2s ease;"; + // ------------------------------------------------------------------------- // Settings drawer state // ------------------------------------------------------------------------- @@ -1438,6 +1684,44 @@ else private static bool IsManualMode(GroupFilterDto filter) => !IsAutoMode(filter); + private static readonly Regex _ppvHintPattern = new( + @"\b(PPV|EVENTS?|FIGHTS?|UFC|MMA|BOXING)\b", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private static bool IsPpvLikeName(string? name) => + !string.IsNullOrWhiteSpace(name) && _ppvHintPattern.IsMatch(name); + + private static bool ShowPpvHint(GroupFilterDto filter) => + !IsExcluded(filter) && + string.Equals( + LineupReviewSemantics.NormalizeTrackingPolicy(filter.TrackingPolicy), + LineupReviewSemantics.TrackingPolicyReview, + StringComparison.Ordinal) && + !filter.TrackNewChannels && + (IsPpvLikeName(filter.ProviderGroupRawName) || IsPpvLikeName(filter.OutputName)); + + private static string GetModeHelperText(string mode) => mode switch + { + LineupReviewSemantics.GroupModeAutoUpdate => + "All channels in this group are included automatically. Provider changes flow through on next refresh.", + _ => + "You choose which channels appear in the output. Changes require your action." + }; + + private static string GetTrackingPolicyHelperText(string policy) => policy switch + { + LineupReviewSemantics.TrackingPolicyNotify => + "You get a notification when new channels appear, but nothing is added automatically.", + LineupReviewSemantics.TrackingPolicyAutoAddAll => + "Every new channel is added automatically on refresh.", + LineupReviewSemantics.TrackingPolicyAutoAddPopulated => + "Only channels that have EPG guide data are added automatically.", + LineupReviewSemantics.TrackingPolicyAutoAddMatching => + "Only channels matching your keywords below are added automatically.", + _ => + "New channels wait for your approval before appearing in output." + }; + private sealed record VirtualParent(string OutputName, List Members); private sealed record ParentChannelEntry(GroupFilterDto Filter, ProviderChannelSelectDto Channel); @@ -1473,14 +1757,16 @@ else var showUnmapped = _decisionFilterStates.Contains("unmapped"); var showExclude = _decisionFilterStates.Contains("exclude"); var showNew = _decisionFilterStates.Contains("new"); - var showAll = _decisionFilterStates.Count == 0 || (showUsed && showUnmapped && showExclude); + var showRemoved = _decisionFilterStates.Contains("removed"); + var showAll = showUsed && showUnmapped && showExclude; bool FilterMatchesGroup(GroupFilterDto f) => IsExcluded(f) ? showExclude : showAll || (showUsed && IsIncluded(f) && GetSelectedChannelCount(f) > 0) || (showUnmapped && IsUnmappedBySelection(f)) || - MatchesNew(f, showNew); + MatchesNew(f, showNew) || + (showRemoved && IsIncluded(f) && !f.ProviderGroupActive); bool CgMatchesFilter(CustomGroupDto cg) => IsCgExcluded(cg) ? showExclude : @@ -1508,12 +1794,14 @@ else && ((showUsed && IsIncluded(f) && GetSelectedChannelCount(f) > 0) || (showUnmapped && IsUnmappedBySelection(f)) || (showExclude && IsExcluded(f)) - || MatchesNew(f, showNew)), + || MatchesNew(f, showNew) + || (showRemoved && IsIncluded(f) && !f.ProviderGroupActive)), VirtualParent p => p.Members.Any(m => _sessionChangedFilterIds.Contains(m.ProfileGroupFilterId) && ((showUsed && IsIncluded(m) && GetSelectedChannelCount(m) > 0) || (showUnmapped && IsUnmappedBySelection(m)) || (showExclude && IsExcluded(m)) - || MatchesNew(m, showNew))), + || MatchesNew(m, showNew) + || (showRemoved && IsIncluded(m) && !m.ProviderGroupActive))), _ => false, }; }; @@ -1622,7 +1910,13 @@ else return true; } - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() + { + _ = InitializeAsync(); + return Task.CompletedTask; + } + + private async Task InitializeAsync() { _allProfiles = await ProfilesService.GetProfileStubsAsync(_cts.Token); if (!string.IsNullOrEmpty(InitialProfile) && _allProfiles.Any(p => p.ProfileId == InitialProfile)) @@ -1641,24 +1935,45 @@ else protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender || _jsReady) return; - _jsReady = true; - - try + if (firstRender && !_jsReady) { - var startKey = _profileId is not null ? $"m3undle:default-start-num:{_profileId}" : "m3undle:default-start-num"; - var startStr = await JS.InvokeAsync("localStorage.getItem", startKey); - if (int.TryParse(startStr, out var n) && n > 0) - _defaultStartNum = n; - _startNumProfileId = _profileId; + _jsReady = true; - var presetsJson = await JS.InvokeAsync("localStorage.getItem", "m3undle:channel-filter-presets"); - if (!string.IsNullOrWhiteSpace(presetsJson)) - _savedChannelFilters = System.Text.Json.JsonSerializer.Deserialize>(presetsJson) ?? []; + try + { + var startKey = _profileId is not null ? $"m3undle:default-start-num:{_profileId}" : "m3undle:default-start-num"; + var startStr = await JS.InvokeAsync("localStorage.getItem", startKey); + if (int.TryParse(startStr, out var n) && n > 0) + _defaultStartNum = n; + _startNumProfileId = _profileId; + + var presetsJson = await JS.InvokeAsync("localStorage.getItem", "m3undle:channel-filter-presets"); + if (!string.IsNullOrWhiteSpace(presetsJson)) + _savedChannelFilters = System.Text.Json.JsonSerializer.Deserialize>(presetsJson) ?? []; + + var panelOpenStr = await JS.InvokeAsync("localStorage.getItem", "m3undle:mapping-panel-open"); + var panelWidthStr = await JS.InvokeAsync("localStorage.getItem", "m3undle:mapping-panel-width"); + _panelOpen = panelOpenStr != "false"; + if (int.TryParse(panelWidthStr, out var pw) && pw >= 180 && pw <= 500) + _panelWidth = pw; + } + catch { } + + StateHasChanged(); + return; } - catch { } - StateHasChanged(); + if (_panelOpen && _panelResizeHandle is null && _jsReady && !_panelResizeInitializing) + { + _panelResizeInitializing = true; + try + { + _panelResizeHandle = await JS.InvokeAsync( + "initMappingPanelResize", "mapped-panel-handle", "mapped-panel", "channel-mapping-content", 180, 500); + } + catch { } + finally { _panelResizeInitializing = false; } + } } private async Task SaveDefaultStartNumAsync() @@ -1786,7 +2101,7 @@ else catch (OperationCanceledException) { } } - public void Dispose() + public async ValueTask DisposeAsync() { _cts.Cancel(); _cts.Dispose(); @@ -1795,6 +2110,11 @@ else _channelSaveDebounceCts?.Dispose(); _globalSearchDebounceCts?.Cancel(); _globalSearchDebounceCts?.Dispose(); + if (_panelResizeHandle is not null) + { + try { await _panelResizeHandle.InvokeVoidAsync("dispose"); } catch { } + try { await _panelResizeHandle.DisposeAsync(); } catch { } + } } private Task LoadAsync() => LoadCoreAsync(showSpinner: true); @@ -1849,6 +2169,7 @@ else _sessionChangedFilterIds.Clear(); RebuildDisplayOrder(); + await LoadPanelAsync(); } catch (OperationCanceledException) { @@ -1867,6 +2188,86 @@ else } } + private async Task LoadPanelAsync() + { + if (_profileId is null) return; + try { _panelItems = await ChannelMappingPageService.GetMappedChannelsPanelAsync(_profileId, _cts.Token); } + catch (OperationCanceledException) { } + catch { } + } + + private async Task TogglePanelAsync() + { + _panelOpen = !_panelOpen; + try { await JS.InvokeVoidAsync("localStorage.setItem", "m3undle:mapping-panel-open", _panelOpen ? "true" : "false"); } + catch { } + + if (!_panelOpen && _panelResizeHandle is not null) + { + try { await _panelResizeHandle.InvokeVoidAsync("dispose"); } catch { } + try { await _panelResizeHandle.DisposeAsync(); } catch { } + _panelResizeHandle = null; + } + } + + private static string SanitizeDomId(string name) => + string.Concat(name.Select(c => char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '-')); + + private async Task NavigateToGroupAsync(MappedChannelPanelItem item) + { + if (item.OutputName is not null) + { + // Virtual parent — force all members visible, expand, show selected only + var members = _allFilters + .Where(f => string.Equals(f.OutputName, item.OutputName, StringComparison.OrdinalIgnoreCase)) + .ToList(); + foreach (var m in members) + _sessionChangedFilterIds.Add(m.ProfileGroupFilterId); + + _parentShowSelectedOnly.Add(item.OutputName); + + if (!_expandedParents.Contains(item.OutputName)) + { + _expandedParents.Add(item.OutputName); + StateHasChanged(); + var toLoad = members + .Where(m => !_channelCache.ContainsKey(m.ProfileGroupFilterId)) + .Select(m => LoadChannelsAsync(m.ProfileGroupFilterId)) + .ToList(); + await Task.WhenAll(toLoad); + } + else + { + StateHasChanged(); + } + + await Task.Delay(50); + try { await JS.InvokeVoidAsync("scrollToId", "parent-" + SanitizeDomId(item.OutputName)); } catch { } + } + else + { + // Standalone group — force visible, expand, show selected only + _sessionChangedFilterIds.Add(item.FilterId); + _groupShowSelectedOnly.Add(item.FilterId); + + if (!_expandedGroups.Contains(item.FilterId)) + { + _expandedGroups.Add(item.FilterId); + if (!_channelCache.ContainsKey(item.FilterId)) + await LoadChannelsAsync(item.FilterId); + else + StateHasChanged(); + } + else + { + StateHasChanged(); + } + + await Task.Delay(50); + try { await JS.InvokeVoidAsync("scrollToId", "group-" + item.FilterId); } catch { } + } + } + private async Task BuildSnapshotAsync() { if (_isRefreshing) return; @@ -2540,19 +2941,33 @@ else string filterId, List channels, bool globalActive, - string filterText) + string filterText, + bool showSelectedOnly = false) { + List result; + if (globalActive) { if (!_globalChannelSearchHitIds.TryGetValue(filterId, out var hitIds)) return []; - return channels.Where(c => hitIds.Contains(c.ProviderChannelId)).ToList(); + result = channels.Where(c => hitIds.Contains(c.ProviderChannelId)).ToList(); + } + else if (string.IsNullOrWhiteSpace(filterText)) + { + result = channels; + } + else + { + result = channels.Where(c => c.DisplayName.Contains(filterText, StringComparison.OrdinalIgnoreCase)).ToList(); } - if (string.IsNullOrWhiteSpace(filterText)) - return channels; + if (showSelectedOnly) + { + var sel = _channelSelections.GetValueOrDefault(filterId) ?? []; + result = result.Where(c => sel.GetValueOrDefault(c.ProviderChannelId)).ToList(); + } - return channels.Where(c => c.DisplayName.Contains(filterText, StringComparison.OrdinalIgnoreCase)).ToList(); + return result; } // ------------------------------------------------------------------------- @@ -2939,6 +3354,9 @@ else if (_dirtyChannelSelectionFilterIds.Count > 0) await DebounceDirtyChannelSelectionSaveAsync(); + + await LoadPanelAsync(); + StateHasChanged(); } private async Task SaveChannelSelectionsNowAsync(string filterId) diff --git a/src/M3Undle.Web/Components/Pages/Channels/ChannelMapping.razor.css b/src/M3Undle.Web/Components/Pages/Channels/ChannelMapping.razor.css new file mode 100644 index 0000000..89851f6 --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Channels/ChannelMapping.razor.css @@ -0,0 +1,111 @@ +.panel-resize-handle { + width: 6px; + cursor: col-resize; + background: var(--mud-palette-divider); + flex-shrink: 0; + transition: background 0.15s; +} + +.panel-resize-handle:hover { + background: var(--mud-palette-primary); +} + +.panel-collapsed-strip { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: var(--mud-palette-surface); + border-left: 2px solid var(--mud-palette-divider); + transition: border-color 0.15s, background 0.15s; +} + +.panel-collapsed-strip:hover { + background: var(--mud-palette-action-hover); + border-left-color: var(--mud-palette-primary); +} + +.panel-body-wrapper { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--mud-palette-surface); + border-left: 1px solid var(--mud-palette-divider); + min-width: 0; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 4px 6px 12px; + border-bottom: 1px solid var(--mud-palette-divider); + flex-shrink: 0; +} + +.panel-channel-list { + overflow-y: auto; + flex: 1; + padding: 4px 0; +} + +.panel-empty { + padding: 16px 12px; + font-size: 0.75rem; + color: var(--mud-palette-text-secondary); +} + +.panel-channel-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + font-size: 0.75rem; + line-height: 1.4; + cursor: default; + min-width: 0; +} + +.panel-channel-row:hover { + background: var(--mud-palette-action-hover); +} + +.panel-channel-link { + cursor: pointer; +} + +.ch-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.ch-dot-live { + background-color: var(--mud-palette-success); +} + +.ch-dot-pending { + border: 2px solid var(--mud-palette-warning); + background: transparent; +} + +.ch-num { + font-family: monospace; + font-size: 0.7rem; + color: var(--mud-palette-text-secondary); + flex-shrink: 0; + width: 34px; + text-align: right; +} + +.ch-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} diff --git a/src/M3Undle.Web/Components/Pages/Channels/ChannelsReview.razor b/src/M3Undle.Web/Components/Pages/Channels/ChannelsReview.razor index 8946338..d54aece 100644 --- a/src/M3Undle.Web/Components/Pages/Channels/ChannelsReview.razor +++ b/src/M3Undle.Web/Components/Pages/Channels/ChannelsReview.razor @@ -37,7 +37,9 @@ Href="@($"channels/mapping{(_profileId is not null ? $"?profile={_profileId}" : "")}")"> Group Review - + + +
@@ -47,18 +49,16 @@ @_error } -@if (_profileId is null) -{ - - No profile available. Configure profiles and providers first. - -} -else +@if (_profileId is not null) { - @_queue.PendingTotal pending - @_queue.PendingNotified notify-enabled + + @_queue.PendingTotal pending + + + @_queue.PendingNotified notify-enabled + @group.ProviderGroupRawName } - - + + + + + + @@ -173,14 +177,18 @@ else - - Include all - - - Exclude all - + + + Include all + + + + + Exclude all + + @@ -200,10 +208,14 @@ else @context.LastSeenUtc.ToString("u") - - + + + + + + @@ -213,14 +225,14 @@ else } else { - + - + Channel Group - Last Seen - Notify - Actions + Last Seen + Notify + Actions @@ -237,28 +249,36 @@ else } @if (context.IsEvent) { - event + + event + } @context.ProviderGroupRawName @context.LastSeenUtc.ToString("u") - - @(context.NotifyOnPending ? "notify" : "mute") - + + + @(context.NotifyOnPending ? "notify" : "mute") + + - - + + + + + + @@ -287,7 +307,7 @@ else private string _search = string.Empty; private bool _notifyOnly; private bool _eventCardView; - private bool _isLoading; + private bool _isLoading = true; private bool _busy; private string? _error; @@ -311,9 +331,15 @@ else .ThenBy(c => c.EventTitle ?? c.Representative.DisplayName, StringComparer.OrdinalIgnoreCase) .ToList(); - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() + { + _ = InitializeAsync(); + return Task.CompletedTask; + } + + private async Task InitializeAsync() { - _profiles = await ProfilesService.GetProfileStubsAsync(CancellationToken.None); + _profiles = await Task.Run(() => ProfilesService.GetProfileStubsAsync(CancellationToken.None)); if (_profiles.Count > 0) { @@ -324,10 +350,16 @@ else } else { - var active = await ChannelMappingService.GetActiveProfileAsync(CancellationToken.None); + var active = await Task.Run(() => ChannelMappingService.GetActiveProfileAsync(CancellationToken.None)); _profileId = active?.ProfileId ?? _profiles[0].ProfileId; } - await LoadQueueAsync(); + await LoadQueueAsync(includePendingSummary: false); + _ = RefreshQueueSummaryAsync(); + } + else + { + _isLoading = false; + await InvokeAsync(StateHasChanged); } } @@ -335,31 +367,36 @@ else { _profileId = profileId; _selectedChannelIds.Clear(); - await LoadQueueAsync(); + await LoadQueueAsync(includePendingSummary: false); + _ = RefreshQueueSummaryAsync(); } private async Task OnSearchChangedAsync(string value) { _search = value; - await LoadQueueAsync(); + await LoadQueueAsync(includePendingSummary: false); + _ = RefreshQueueSummaryAsync(); } private async Task ClearSearchAsync() { _search = string.Empty; - await LoadQueueAsync(); + await LoadQueueAsync(includePendingSummary: false); + _ = RefreshQueueSummaryAsync(); } private async Task OnGroupFilterChangedAsync(string? value) { _groupFilter = value; - await LoadQueueAsync(); + await LoadQueueAsync(includePendingSummary: false); + _ = RefreshQueueSummaryAsync(); } private async Task OnNotifyOnlyChangedAsync(bool value) { _notifyOnly = value; - await LoadQueueAsync(); + await LoadQueueAsync(includePendingSummary: false); + _ = RefreshQueueSummaryAsync(); } private Task OnEventCardViewChangedAsync(bool value) @@ -368,7 +405,7 @@ else return Task.CompletedTask; } - private async Task LoadQueueAsync() + private async Task LoadQueueAsync(bool includePendingSummary = true) { if (_profileId is null) return; @@ -377,8 +414,8 @@ else _error = null; try { - _queue = await ChannelMappingService.ListReviewQueueAsync( - _profileId!, _groupFilter, _search, _notifyOnly, 1, 500, CancellationToken.None); + _queue = await Task.Run(() => ChannelMappingService.ListReviewQueueAsync( + _profileId!, _groupFilter, _search, _notifyOnly, 1, 200, CancellationToken.None, includePendingSummary)); _groups = _queue.Items .GroupBy(x => x.ProviderGroupId, StringComparer.Ordinal) .Select(g => g.First()) @@ -394,6 +431,37 @@ else finally { _isLoading = false; + try { await InvokeAsync(StateHasChanged); } catch { } + } + } + + private Task ReloadQueueAsync() => LoadQueueAsync(); + + private async Task RefreshQueueSummaryAsync() + { + if (_profileId is null) + return; + + try + { + var summary = await Task.Run(() => ChannelMappingService.ListReviewQueueAsync( + _profileId!, + _groupFilter, + _search, + _notifyOnly, + 1, + 1, + CancellationToken.None, + includePendingSummary: true)); + + _queue.PendingTotal = summary.PendingTotal; + _queue.PendingNotified = summary.PendingNotified; + _queue.Total = summary.Total; + try { await InvokeAsync(StateHasChanged); } catch { } + } + catch + { + // Keep the currently displayed queue rows visible if summary refresh fails. } } diff --git a/src/M3Undle.Web/Components/Pages/Dashboard.razor b/src/M3Undle.Web/Components/Pages/Dashboard.razor index e7722a0..42e0cce 100644 --- a/src/M3Undle.Web/Components/Pages/Dashboard.razor +++ b/src/M3Undle.Web/Components/Pages/Dashboard.razor @@ -4,14 +4,15 @@ @using M3Undle.Web.Application @using M3Undle.Web.Contracts @inject NavigationManager Nav -@inject IJSRuntime JS @inject ISnackbar Snackbar @inject AppEventBus EventBus @inject IRefreshTrigger RefreshTrigger -@inject M3Undle.Web.Streaming.Observability.StreamingRegistry StreamingRegistry @inject DashboardStatsService DashboardStats @inject LineupStatusService StatusService @inject IRefreshScheduleService RefreshScheduleService +@inject HdHomeRunDeviceService HdHomeRunDeviceService +@inject EndpointUrlService EndpointUrlService +@inject IServiceScopeFactory ScopeFactory @inject PersistentComponentState ApplicationState @implements IDisposable @@ -22,365 +23,565 @@ @_loadError } +@if (HasTodoItems) +{ + + +
+ Needs Attention +
+ + + @foreach (var ep in _dashboardStats?.ExpiringProviders ?? []) + { + var daysLeft = (ep.ExpiresUtc - DateTime.UtcNow).TotalDays; + var expirationText = daysLeft < 0 + ? $"expired on {ep.ExpiresUtc:yyyy-MM-dd}" + : daysLeft < 1 + ? $"expires today ({ep.ExpiresUtc:yyyy-MM-dd})" + : $"expires in {(int)daysLeft + 1}d on {ep.ExpiresUtc:yyyy-MM-dd}"; + + + + Provider subscription @expirationText. + Renew provider "@ep.ProviderName" to avoid stream outages, then update it on Providers. + + + } + + @if (_dashboardStats?.GroupsPendingReview > 0) + { + + + + @_dashboardStats.GroupsPendingReview new group@(_dashboardStats.GroupsPendingReview == 1 ? "" : "s") need review. + Open Channel Mapping to include or exclude them. + + + } + + @if (_dashboardStats?.ChannelsPendingReview > 0) + { + + + + @_dashboardStats.ChannelsPendingReview new channel@(_dashboardStats.ChannelsPendingReview == 1 ? "" : "s") need review. + Open Channel Review to approve or dismiss them. + + + } + + @if (_dashboardStats?.RefreshFailed == true) + { + + + + Last refresh failed. + M3Undle is serving the last known-good version. Fix the provider or source issue, then run another refresh. + + + } + +
+
+} + - + - - System Status - - @if (_streamSessions > 0) - { - - @_streamSessions session@(_streamSessions == 1 ? "" : "s"), @_streamClients client@(_streamClients == 1 ? "" : "s") - - } - - - + Selected Profile @if (_isLoading) { } - else if (_status is null) - { - Status unavailable. - } else { + @{ + var selectedProfile = SelectedProfileSummary; + var visibleProfiles = VisibleProfiles; + } + + @* Profile chips + toggle *@
- - @LineupStatusLabel(_status.Status) - + + @foreach (var profile in visibleProfiles) + { + var chipColor = profile.HealthStatus == ProfileHealthStatus.Degraded + ? Color.Warning + : profile.IsPublished + ? Color.Success + : Color.Default; + + + @if (profile.IsActive) + { + MAIN + } + @profile.DisplayName + + + } + + + +
- @{ var lineup = _status.Lineup; } - - @if (lineup?.ActiveProfile is not null) + @if (selectedProfile is null) { + No profile selected + } + else + { + @* 1. Status *@ - Active Profile - - @lineup.ActiveProfile.Name - @if (SwitchStateLabel(lineup.SwitchState) is { } switchLabel) + Status + @if (selectedProfile.IsActive) + { + @* Use live lineup status for the main (active) profile *@ + @if (ShowRefreshing) { - - @switchLabel - + + + Refreshing… @(!string.IsNullOrEmpty(ElapsedText) ? $"({ElapsedText} elapsed)" : "") + + @(_isCancelling ? "Cancelling…" : "Cancel") + + + @if (LastRunDurationText is not null && _status?.Lineup?.LastRefresh is { } lastRun) + { + + Last run: ~@LastRunDurationText + @(lastRun.ChannelCountSeen.HasValue ? $", {lastRun.ChannelCountSeen.Value:N0} channels" : "") + — please wait. + + } + else + { + Large providers may take several minutes. + } + } + else + { + } - - - - - Provider Expiration - @if (_dashboardStats?.ActiveProfileProviderExpiresUtc is { } expiresUtc) - { - - @ProviderExpirationLabel(expiresUtc) - } else { - Not detected + } - } - else - { - - No active profile — add a provider or go to Profiles to set one active. - - } - - Last Refresh - @if (ShowRefreshing) - { - - - Refreshing… @(!string.IsNullOrEmpty(ElapsedText) ? $"({ElapsedText} elapsed)" : "") - - @(_isCancelling ? "Cancelling…" : "Cancel") - + @* 2. Published content chips *@ + + Published Content + + + + - @if (LastRunDurationText is not null && _status?.Lineup?.LastRefresh is { } lastRun) - { + + + @* 3. Activity *@ + @* TODO: Per-profile stream/client counts not yet available from StreamingRegistry. Showing global link only. *@ + + @* 4. Provider backing *@ + @if (selectedProfile.Providers.Count > 0) + { + - Last run: ~@LastRunDurationText - @(lastRun.ChannelCountSeen.HasValue ? $", {lastRun.ChannelCountSeen.Value:N0} channels" : "") - — please wait. + @(selectedProfile.Providers.Count == 1 ? "Provider" : "Providers") - } - else - { - Large providers may take several minutes. - } - } - else if (lineup?.LastRefresh is not null) - { - - - @lineup.LastRefresh.Status - - @if (lineup.LastRefresh.FinishedUtc.HasValue) + @foreach (var prov in selectedProfile.Providers) { - @lineup.LastRefresh.FinishedUtc.Value.ToString("u") + var provExpiry = prov.ExpiresUtc; + + @prov.ProviderName + @if (prov.MaxConcurrentStreams.HasValue) + { + — @prov.MaxConcurrentStreams max streams + } + else + { + — stream max unknown + } + @if (provExpiry.HasValue) + { + + @ProviderExpirationLabel(provExpiry.Value) + + } + } - @if (lineup.LastRefresh.ChannelCountSeen.HasValue) - { - @lineup.LastRefresh.ChannelCountSeen.Value.ToString("N0") channels seen - } - @if (!string.IsNullOrWhiteSpace(lineup.LastRefresh.ErrorSummary)) - { - - @lineup.LastRefresh.ErrorSummary - @if (lineup.ActiveSnapshot is not null) - { - Serving last known-good version. - } - - } } - else - { - No refresh yet - } - - @if (_nextRefreshUtc.HasValue || _refreshScheduleKind == "manual") - { - - Next Refresh - @if (_nextRefreshUtc.HasValue && !ShowRefreshing) - { - @_nextRefreshUtc.Value.ToLocalTime().ToString("ddd d MMM, h:mm tt") - } - else if (_refreshScheduleKind == "manual") - { - Manual only - } - else + @* 5. Refresh / Publish freshness (active profile only has full detail) *@ + @if (selectedProfile.IsActive) + { + @* Last Refresh *@ + @if (!ShowRefreshing) { - Calculating… + + Last Refresh + @if (_status?.Lineup?.LastRefresh is { } lr) + { + + + @lr.Status + + @if (lr.FinishedUtc.HasValue) + { + @lr.FinishedUtc.Value.ToString("u") + } + + @if (lr.ChannelCountSeen.HasValue) + { + @lr.ChannelCountSeen.Value.ToString("N0") channels seen + } + @if (!string.IsNullOrWhiteSpace(lr.ErrorSummary)) + { + + @lr.ErrorSummary + @if (_status.Lineup?.ActiveSnapshot is not null) + { + Serving last known-good version. + } + + } + } + else + { + No refresh yet + } + } - - } - - Published Version - @if (_dashboardStats is not null && _dashboardStats.PublishedLiveCount > 0) - { - - @_dashboardStats.PublishedLiveCount.ToString("N0") live channels published - - - @_dashboardStats.PublishedMovieCount.ToString("N0") movies, @_dashboardStats.PublishedSeriesCount.ToString("N0") series - - @if (_dashboardStats.LastPublishedUtc.HasValue) + @* Next Refresh *@ + @if (_nextRefreshUtc.HasValue || _refreshScheduleKind == "manual") { - - @_dashboardStats.LastPublishedUtc.Value.ToString("u") - @if (_dashboardStats.LastChangeClass is not null) + + Next Refresh + @if (_nextRefreshUtc.HasValue && !ShowRefreshing) + { + @_nextRefreshUtc.Value.ToLocalTime().ToString("ddd d MMM, h:mm tt") + } + else if (_refreshScheduleKind == "manual") + { + Manual only + } + else { - - @ChangeClasses.ToUserLabel(_dashboardStats.LastChangeClass) - + Calculating… } } + + @* Published version *@ + + Published + @if (selectedProfile.LastPublishedUtc.HasValue) + { + + @selectedProfile.LastPublishedUtc.Value.ToString("u") + @if (_dashboardStats?.LastChangeClass is not null) + { + + @ChangeClasses.ToUserLabel(_dashboardStats.LastChangeClass) + + } + + @if (ShowRefreshing) + { + Updating lineup… + } + } + else + { + None + } + } - else if (lineup?.ActiveSnapshot is not null) - { - - @lineup.ActiveSnapshot.ChannelCountPublished.ToString("N0") entries published - - @lineup.ActiveSnapshot.CreatedUtc.ToString("u") - } - else - { - None - } - @if (ShowRefreshing) + else if (selectedProfile.LastPublishedUtc.HasValue) { - Updating lineup… + + Published + @selectedProfile.LastPublishedUtc.Value.ToString("u") + } - + } }
- + - Output URLs - - - - - Live: @(_dashboardStats?.PublishedLiveCount ?? 0) - - - Movies: @(_dashboardStats?.PublishedMovieCount ?? 0) - - - Series: @(_dashboardStats?.PublishedSeriesCount ?? 0) - - - - @if (_dashboardStats is null || _dashboardStats.PublishedLiveCount == 0) - { - - No channels published yet — configure Channel Mapping to start serving content. - + @{ + var selProfile = SelectedProfileSummary; + var isMainSelected = selProfile?.IsActive == true; } + + Endpoints + @if (selProfile is not null) + { + + @if (isMainSelected) + { + Main: @selProfile.DisplayName + } + else + { + @selProfile.DisplayName + } + + } +
M3U Playlist - - - - - - +
XMLTV Guide - - - - - - +
-
-
-
- - @if (_dashboardStats?.ProfileSummaries.Count > 0) - { - - - - Profiles - - All Profiles - - - - @if (_dashboardStats.ProfileSummaries.Count <= ProfileTileDisplayBudget) - { - - @foreach (var profile in _dashboardStats.ProfileSummaries) + @* Xtream section *@ + +
+ + + + Xtream + + + @if (_endpointSecuritySettings?.XtreamCompatibilityEnabled == true && !_endpointSecuritySettings.Enabled) { - var chipColor = profile.IsActive - ? (profile.HealthStatus == ProfileHealthStatus.Degraded ? Color.Warning : Color.Success) - : (profile.HealthStatus == ProfileHealthStatus.NoOutput ? Color.Default : Color.Info); - var chipVariant = profile.IsActive ? Variant.Filled : Variant.Outlined; - var chipIcon = profile.IsActive - ? (profile.HealthStatus == ProfileHealthStatus.Degraded ? Icons.Material.Filled.Warning : Icons.Material.Filled.CheckCircle) - : (profile.HealthStatus == ProfileHealthStatus.NoOutput ? Icons.Material.Filled.RadioButtonUnchecked : Icons.Material.Filled.Inventory2); - - - @profile.DisplayName — @if (profile.IsActive) { @profile.LiveCount live } else if (profile.HealthStatus != ProfileHealthStatus.NoOutput) { @profile.LiveCount ch } else { no output } - + + Unsecured } - } - else - { - - @_dashboardStats.ProfileSummaries.Count profiles published - - } - - - } - - - @if (HasActionItems) - { - - - Action Items - - @foreach (var ep in _dashboardStats!.ExpiringProviders) + @if (_endpointSecuritySettings is null) { - var daysLeft = (ep.ExpiresUtc - DateTime.UtcNow).TotalDays; - var expSeverity = daysLeft <= 7 ? Severity.Error : Severity.Warning; - var expLabel = daysLeft < 0 - ? $"Provider \"{ep.ProviderName}\" subscription expired on {ep.ExpiresUtc:yyyy-MM-dd} — streams may be down." - : daysLeft < 1 - ? $"Provider \"{ep.ProviderName}\" subscription expires today ({ep.ExpiresUtc:yyyy-MM-dd}) — renew now." - : $"Provider \"{ep.ProviderName}\" subscription expires in {(int)daysLeft + 1}d on {ep.ExpiresUtc:yyyy-MM-dd}."; - @expLabel + Loading… } - @if (_dashboardStats!.GroupsPendingReview > 0) + else if (!_endpointSecuritySettings.XtreamCompatibilityEnabled) { - - @_dashboardStats.GroupsPendingReview new group@(_dashboardStats.GroupsPendingReview == 1 ? "" : "s") to review — - review now - + Disabled } - @if (_dashboardStats!.ChannelsPendingReview > 0) + else if (_endpointSecuritySettings.Enabled && !_endpointSecuritySettings.HasCredential) { - - @_dashboardStats.ChannelsPendingReview new channel@(_dashboardStats.ChannelsPendingReview == 1 ? "" : "s") to review — - review now - + + No credential configured. + Configure → + } - @if (_dashboardStats!.RefreshFailed) + else { - - Last refresh failed — serving last known-good version. - + + + Server + @Nav.BaseUri.TrimEnd('/') + + @if (_endpointSecuritySettings.Username is not null) + { + + Username + @_endpointSecuritySettings.Username + + } + } - - - - } +
+ + @* HDHomeRun section *@ + +
+ + + + HDHomeRun + + + + @if (!_hdhrEnabled) + { + Disabled + } + else if (_hdhrDescriptor is not null) + { + + + Friendly name + @_hdhrDescriptor.FriendlyName + + + Device ID + @_hdhrDescriptor.DeviceId + + + Discovery + @(_hdhrRuntimeSnapshot?.DiscoveryEnabled == true ? "Enabled" : "Disabled") + + + } + else + { + Loading HDHR info… + } +
+
+
+
+
@code { - private const int ProfileTileDisplayBudget = 4; + private string? _selectedProfileId; + private bool _showAllProfiles; + + private IReadOnlyList VisibleProfiles + { + get + { + var all = _dashboardStats?.ProfileSummaries ?? []; + if (_showAllProfiles) + return all; + return all.Where(p => p.IsActive).ToList(); + } + } + + private DashboardProfileSummary? SelectedProfileSummary + { + get + { + var all = _dashboardStats?.ProfileSummaries ?? []; + if (all.Count == 0) return null; + + if (_selectedProfileId is not null) + { + var byId = all.FirstOrDefault(p => p.ProfileId == _selectedProfileId); + if (byId is not null) return byId; + } + + // Default to main (active) profile + return all.FirstOrDefault(p => p.IsActive) ?? all[0]; + } + } + + private string SelectedM3uUrl + { + get + { + var profile = SelectedProfileSummary; + var baseUri = Nav.BaseUri.TrimEnd('/'); + if (profile is null || profile.IsActive) + return $"{baseUri}/m3u/m3undle.m3u"; + return $"{baseUri}/m3u/m3undle.m3u?profile={Uri.EscapeDataString(profile.DisplayName)}"; + } + } + + private string SelectedXmltvUrl + { + get + { + var profile = SelectedProfileSummary; + var baseUri = Nav.BaseUri.TrimEnd('/'); + if (profile is null || profile.IsActive) + return $"{baseUri}/xmltv/m3undle.xml"; + return $"{baseUri}/xmltv/m3undle.xml?profile={Uri.EscapeDataString(profile.DisplayName)}"; + } + } + + private IReadOnlyList GetUrlVariants(string fullUrl) + { + if (!Uri.TryCreate(fullUrl, UriKind.Absolute, out var parsed)) + return []; + + var path = parsed.PathAndQuery; + var variants = new List(); + + var dockerBase = EndpointUrlService.GetDockerBaseUrl(); + if (dockerBase is not null) + variants.Add(new EndpointUrlVariant("Copy Docker URL", $"{dockerBase}{path}")); + + var externalBase = EndpointUrlService.GetExternalBaseUrl(); + if (externalBase is not null) + variants.Add(new EndpointUrlVariant("Copy External URL", $"{externalBase}{path}")); + + return variants; + } + + private void OnShowAllProfilesChanged(bool value) + { + _showAllProfiles = value; + + // If toggling off and the selected profile is no longer visible, snap back to active + if (!value) + { + var visible = VisibleProfiles; + if (_selectedProfileId is not null && !visible.Any(p => p.ProfileId == _selectedProfileId)) + _selectedProfileId = visible.FirstOrDefault(p => p.IsActive)?.ProfileId ?? visible.FirstOrDefault()?.ProfileId; + } + + StateHasChanged(); + } private LineupStatusResponse? _status; private DashboardStatsDto? _dashboardStats; @@ -388,67 +589,95 @@ private bool _isRefreshing; private string? _loadError; - private string _m3uUrl = string.Empty; - private string _xmltvUrl = string.Empty; + private bool _hdhrEnabled; + private HdHomeRunDeviceDescriptor? _hdhrDescriptor; + private HdHomeRunRuntimeSnapshot? _hdhrRuntimeSnapshot; + private EndpointSecuritySettings? _endpointSecuritySettings; private readonly CancellationTokenSource _cts = new(); private IDisposable? _eventSubscription; private DateTime? _refreshStartedAt; private PeriodicTimer? _elapsedTimer; - private PeriodicTimer? _streamTimer; private PersistingComponentStateSubscription? _persistSubscription; private bool _isCancelling; - private int _streamSessions; - private int _streamClients; private DateTime? _nextRefreshUtc; private string _refreshScheduleKind = "6h"; private bool ShowRefreshing => _isRefreshing || _status?.IsRefreshing == true; - private bool HasActionItems => _dashboardStats is not null && + private Color XtreamIconColor => _endpointSecuritySettings is { XtreamCompatibilityEnabled: true, Enabled: true, HasCredential: true } + ? Color.Primary + : Color.Default; + + private bool HasDashboardActionItems => _dashboardStats is not null && (_dashboardStats.ExpiringProviders.Count > 0 || _dashboardStats.GroupsPendingReview > 0 || _dashboardStats.ChannelsPendingReview > 0 || _dashboardStats.RefreshFailed); + private bool HasTodoItems => HasDashboardActionItems; + + private Severity TodoAlertSeverity => _dashboardStats?.ExpiringProviders.Any(x => x.ExpiresUtc <= DateTime.UtcNow) == true + ? Severity.Error + : Severity.Warning; + protected override async Task OnInitializedAsync() { - var baseUri = Nav.BaseUri.TrimEnd('/'); - _m3uUrl = $"{baseUri}/m3u/m3undle.m3u"; - _xmltvUrl = $"{baseUri}/xmltv/m3undle.xml"; + _hdhrEnabled = HdHomeRunDeviceService.IsEnabled; _isRefreshing = RefreshTrigger.IsRefreshing; if (_isRefreshing) - { - _refreshStartedAt = DateTime.UtcNow; - } + _refreshStartedAt = RefreshTrigger.RefreshStartedAt ?? DateTime.UtcNow; - var restoredFromPrerender = TryRestorePersistedState(); - if (!restoredFromPrerender) - { - await LoadAllAsync(); - } + if (!RendererInfo.IsInteractive) + return; + var restoredFromPrerender = TryRestorePersistedState(); _persistSubscription = ApplicationState.RegisterOnPersisting(PersistStateAsync); - if (!RendererInfo.IsInteractive) return; - if (_isRefreshing) - { StartElapsedTimer(); - } var reader = EventBus.Subscribe(out _eventSubscription); _ = ListenForEventsAsync(reader, _cts.Token); - RefreshStreamCounts(); - _streamTimer = new PeriodicTimer(TimeSpan.FromSeconds(3)); - _ = RunStreamTimerAsync(_streamTimer); + if (!restoredFromPrerender) + _ = LoadAllAsync(); + else + _ = LoadRefreshScheduleAsync(); + + _ = LoadHdhrInfoAsync(); + _ = LoadEndpointSecurityAsync(); + } - if (restoredFromPrerender) + private async Task LoadHdhrInfoAsync() + { + if (!_hdhrEnabled) return; + try { - _ = LoadRefreshScheduleAsync(); + var ct = _cts.Token; + _hdhrDescriptor = await Task.Run(() => HdHomeRunDeviceService.GetDeviceDescriptorAsync(ct), ct); + _hdhrRuntimeSnapshot = HdHomeRunDeviceService.GetRuntimeSnapshot(); + await InvokeAsync(StateHasChanged); } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch { } + } + + private async Task LoadEndpointSecurityAsync() + { + try + { + var ct = _cts.Token; + await using var scope = ScopeFactory.CreateAsyncScope(); + var svc = scope.ServiceProvider.GetRequiredService(); + _endpointSecuritySettings = await svc.GetSettingsAsync(ct); + await InvokeAsync(StateHasChanged); + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + catch { } } private async Task ListenForEventsAsync(System.Threading.Channels.ChannelReader reader, CancellationToken ct) @@ -500,13 +729,15 @@ { try { - var settings = await RefreshScheduleService.GetActiveProfileSettingsAsync(_cts.Token); + var ct = _cts.Token; + var settings = await Task.Run(() => RefreshScheduleService.GetActiveProfileSettingsAsync(ct), ct); _refreshScheduleKind = settings?.Settings.ScheduleKind ?? "manual"; _nextRefreshUtc = settings?.ProfileId is null ? null - : await RefreshScheduleService.GetNextScheduledRefreshUtcAsync(settings.ProfileId, _cts.Token); + : await Task.Run(() => RefreshScheduleService.GetNextScheduledRefreshUtcAsync(settings.ProfileId, ct), ct); } catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } catch { } } @@ -517,14 +748,13 @@ try { - _status = await StatusService.GetStatusAsync(_cts.Token); + var ct = _cts.Token; + _status = await Task.Run(() => StatusService.GetStatusAsync(ct), ct); - // Keep the page-level refresh spinner/timer in sync with server state - // when the page loads mid-refresh or event delivery is delayed. if (_status.IsRefreshing && !_isRefreshing) { _isRefreshing = true; - _refreshStartedAt ??= DateTime.UtcNow; + _refreshStartedAt ??= RefreshTrigger.RefreshStartedAt ?? DateTime.UtcNow; StartElapsedTimer(); } else if (!_status.IsRefreshing && _isRefreshing) @@ -535,10 +765,8 @@ StopElapsedTimer(); } } - catch (OperationCanceledException) - { - return; - } + catch (OperationCanceledException) { return; } + catch (ObjectDisposedException) { return; } catch (Exception ex) { _loadError = $"Failed to load status: {ex.Message}"; @@ -546,7 +774,7 @@ finally { _isLoading = false; - StateHasChanged(); + try { await InvokeAsync(StateHasChanged); } catch { } } } @@ -554,11 +782,20 @@ { try { - _dashboardStats = await DashboardStats.GetStatsAsync(_cts.Token); + var ct = _cts.Token; + _dashboardStats = await Task.Run(() => DashboardStats.GetStatsAsync(ct), ct); + + // Initialize chip selection to the active profile on first load + if (_selectedProfileId is null && _dashboardStats.ProfileSummaries.Count > 0) + { + _selectedProfileId = _dashboardStats.ProfileSummaries.FirstOrDefault(p => p.IsActive)?.ProfileId + ?? _dashboardStats.ProfileSummaries[0].ProfileId; + } } catch (OperationCanceledException) { return; } + catch (ObjectDisposedException) { return; } - StateHasChanged(); + try { await InvokeAsync(StateHasChanged); } catch { } } public void Dispose() @@ -568,23 +805,6 @@ _cts.Dispose(); _eventSubscription?.Dispose(); _elapsedTimer?.Dispose(); - _streamTimer?.Dispose(); - } - - private void RefreshStreamCounts() - { - _streamSessions = StreamingRegistry.GetActiveSessions().Count; - _streamClients = StreamingRegistry.GetActiveClients().Count; - } - - private async Task RunStreamTimerAsync(PeriodicTimer timer) - { - try - { - while (await timer.WaitForNextTickAsync(_cts.Token)) - await InvokeAsync(() => { RefreshStreamCounts(); StateHasChanged(); }); - } - catch (OperationCanceledException) { } } private void StartElapsedTimer() @@ -632,62 +852,60 @@ return $"{(int)ts.TotalHours}h {ts.Minutes}m"; } - private static Color ChangeClassChipColor(string? changeClass) => changeClass switch + private string ProfileStatusLabel(string? lineupStatus, DashboardProfileSummary profile) { - ChangeClasses.None => Color.Default, - ChangeClasses.GuideOnly => Color.Info, - ChangeClasses.Lineup => Color.Primary, - ChangeClasses.Breaking => Color.Warning, - _ => Color.Default, - }; - - private static string LineupStatusLabel(string? status) => status switch - { - LineupStatusCodes.Ok => "Output Active", - LineupStatusCodes.Refreshing => "Output Active — Refreshing", - LineupStatusCodes.Switching => "Profile Switch In Progress", - LineupStatusCodes.Degraded => "Degraded — refresh failing", - LineupStatusCodes.NoActiveProfile => "No Active Profile", - LineupStatusCodes.NoActiveSnapshot => "No Published Lineup", - _ => "Status Unknown", - }; + if (profile.IsActive && lineupStatus is not null) + { + return lineupStatus switch + { + LineupStatusCodes.Ok => "Serving", + LineupStatusCodes.Refreshing => "Refreshing", + LineupStatusCodes.Switching => "Switching", + LineupStatusCodes.Degraded => "Degraded — serving last known-good", + LineupStatusCodes.NoActiveSnapshot => "No Published Output", + LineupStatusCodes.NoActiveProfile => "No Active Profile", + _ => "Unknown", + }; + } - private static Color LineupStatusChipColor(string? status) => status switch - { - LineupStatusCodes.Ok => Color.Success, - LineupStatusCodes.Refreshing => Color.Info, - LineupStatusCodes.Switching => Color.Info, - LineupStatusCodes.Degraded => Color.Warning, - LineupStatusCodes.NoActiveProfile => Color.Warning, - LineupStatusCodes.NoActiveSnapshot => Color.Warning, - _ => Color.Default, - }; + if (!profile.IsEnabled) return "Disabled"; + if (!profile.IsPublished) return "No Published Output"; + return profile.HealthStatus switch + { + ProfileHealthStatus.Ok => "Published", + ProfileHealthStatus.Degraded => "Degraded", + ProfileHealthStatus.NoOutput => "No Published Output", + _ => "Unknown", + }; + } - private static string LineupStatusChipIcon(string? status) => status switch + private Color ProfileStatusChipColor(DashboardProfileSummary profile) { - LineupStatusCodes.Ok => Icons.Material.Filled.CheckCircle, - LineupStatusCodes.Refreshing => Icons.Material.Filled.Autorenew, - LineupStatusCodes.Switching => Icons.Material.Filled.Sync, - LineupStatusCodes.Degraded => Icons.Material.Filled.Warning, - LineupStatusCodes.NoActiveProfile => Icons.Material.Filled.Info, - LineupStatusCodes.NoActiveSnapshot => Icons.Material.Filled.Warning, - _ => Icons.Material.Filled.Help, - }; + if (!profile.IsEnabled) return Color.Default; + if (!profile.IsPublished) return Color.Warning; + return profile.HealthStatus switch + { + ProfileHealthStatus.Ok => Color.Success, + ProfileHealthStatus.Degraded => Color.Warning, + _ => Color.Default, + }; + } - private static string? SwitchStateLabel(string? switchState) => switchState switch + private string ProfileStatusTooltip(string? lineupStatus, DashboardProfileSummary profile) { - LineupSwitchStates.Requested => "Switch requested", - LineupSwitchStates.InProgress => "Switching…", - LineupSwitchStates.Failed => "Switch failed (last known-good active)", - _ => null, - }; + var label = ProfileStatusLabel(lineupStatus, profile); + return profile.IsActive + ? $"Current main profile status: {label}." + : $"Profile status: {label}."; + } - private static Color SwitchStateChipColor(string? switchState) => switchState switch + private static Color ChangeClassChipColor(string? changeClass) => changeClass switch { - LineupSwitchStates.Requested => Color.Info, - LineupSwitchStates.InProgress => Color.Info, - LineupSwitchStates.Failed => Color.Warning, - _ => Color.Default, + ChangeClasses.None => Color.Default, + ChangeClasses.GuideOnly => Color.Info, + ChangeClasses.Lineup => Color.Primary, + ChangeClasses.Breaking => Color.Warning, + _ => Color.Default, }; private static Color ProviderExpirationChipColor(DateTime expiresUtc) @@ -716,26 +934,6 @@ await Task.CompletedTask; } - private async Task CopyAsync(string text) - { - try - { - var copied = await JS.InvokeAsync("m3undleCopyText", text); - if (copied) - { - Snackbar.Add("Copied to clipboard", Severity.Success); - } - else - { - Snackbar.Add("Copy failed. Please copy manually.", Severity.Warning); - } - } - catch (JSException) - { - Snackbar.Add("Copy failed. Please copy manually.", Severity.Warning); - } - } - private bool TryRestorePersistedState() { if (!ApplicationState.TryTakeFromJson(nameof(DashboardPersistentState), out var state) diff --git a/src/M3Undle.Web/Components/Pages/EditProviderDialog.razor b/src/M3Undle.Web/Components/Pages/EditProviderDialog.razor index 899cf06..544acb9 100644 --- a/src/M3Undle.Web/Components/Pages/EditProviderDialog.razor +++ b/src/M3Undle.Web/Components/Pages/EditProviderDialog.razor @@ -1,4 +1,5 @@ @using M3Undle.Web.Contracts.Providers +@using M3Undle.Web.Components.ProviderForms @inject M3Undle.Web.Application.ProviderPageService ProviderPageService @@ -54,6 +55,48 @@ HelperText="e.g. http://yourserver.com:8080" /> + + @if (!string.IsNullOrWhiteSpace(_passwordError)) + { + @_passwordError + } + @if (!string.IsNullOrWhiteSpace(_passwordSuccess)) + { + @_passwordSuccess + } + @if (!_editingPassword) + { + + + Change + + @if (!_encryptionAvailable) + { + + M3UNDLE_ENCRYPTION_KEY must be configured to change the password. + + } + } + else + { + + + + @(_isChangingPassword ? "Saving..." : "Save") + + Cancel + + } + } @@ -69,30 +112,27 @@ - - } - - - - p.ProfileId == id)?.Name ?? id)"> + p.ProfileId == id)?.Name ?? id)"> (None) @foreach (var profile in AvailableProfiles) { @profile.Name } + + + + New Profile... + + - @if (!_showCreateProfile) - { - New Profile - } - else + @if (_showCreateProfile) { } - - Content Types - - - - Stream Format - - @if (_model.ForceMpegTs) - { - - Upstream URLs with output=m3u8 will be rewritten to output=ts. HLS delivery to clients is also disabled for this provider. - - } - - @if (_model.CleanRelayRemux) - { - - Uses FFmpeg to remux live streams and repair timestamps. Adds CPU usage and some startup latency. - - } + + + + + + + Advanced options + + + + @if (!Provider.IsXtreamProvider) + { + + + } + + + + - - @if (Provider.IsXtreamProvider) - { - - Change Password - - @if (!_encryptionAvailable) - { - - - M3UNDLE_ENCRYPTION_KEY is not set. Password changes require encryption to be configured. - - - } - else - { - @if (!string.IsNullOrWhiteSpace(_passwordError)) - { - @_passwordError - } - @if (!string.IsNullOrWhiteSpace(_passwordSuccess)) - { - @_passwordSuccess - } - - - - @(_isChangingPassword ? "Updating..." : "Update") - - - } - } Cancel @@ -183,10 +192,14 @@ [Parameter] public List Providers { get; set; } = []; [Parameter] public EventCallback OnSaved { get; set; } + private const string NewProfileSentinel = "__new_profile__"; + private MudForm? _form; private bool _isSaving; private bool _isChangingPassword; + private bool _editingPassword; private bool _isUpgrading; + private bool _showAdvanced; private string? _error; private string? _passwordError; private string? _passwordSuccess; @@ -220,11 +233,12 @@ XtreamIncludeXmltv = Provider.XtreamIncludeXmltv, Enabled = Provider.Enabled, TimeoutSeconds = Provider.TimeoutSeconds, - MaxConcurrentStreams = Provider.MaxConcurrentStreams, + LimitConcurrentStreams = Provider.MaxConcurrentStreams.HasValue, + ConcurrentStreamLimit = Provider.MaxConcurrentStreams ?? 1, IncludeVod = Provider.IncludeVod, IncludeSeries = Provider.IncludeSeries, ForceMpegTs = Provider.ForceMpegTs, - CleanRelayRemux = string.Equals(Provider.CleanRelayMode, "remux", StringComparison.OrdinalIgnoreCase), + CleanRelayMode = NormalizeCleanRelayMode(Provider.CleanRelayMode), ProfileId = Provider.AssociatedProfileIds.FirstOrDefault() ?? string.Empty, } : new EditModel @@ -236,20 +250,18 @@ HeadersJson = Provider.HeadersJson, Enabled = Provider.Enabled, TimeoutSeconds = Provider.TimeoutSeconds, - MaxConcurrentStreams = Provider.MaxConcurrentStreams, + LimitConcurrentStreams = Provider.MaxConcurrentStreams.HasValue, + ConcurrentStreamLimit = Provider.MaxConcurrentStreams ?? 1, IncludeVod = Provider.IncludeVod, IncludeSeries = Provider.IncludeSeries, ForceMpegTs = Provider.ForceMpegTs, - CleanRelayRemux = string.Equals(Provider.CleanRelayMode, "remux", StringComparison.OrdinalIgnoreCase), + CleanRelayMode = NormalizeCleanRelayMode(Provider.CleanRelayMode), ProfileId = Provider.AssociatedProfileIds.FirstOrDefault() ?? string.Empty, }; if (Provider.IsXtreamProvider || Provider.XtreamDetectedCapable) { - try - { - _encryptionAvailable = await ProviderPageService.GetEncryptionAvailableAsync(); - } + try { _encryptionAvailable = await ProviderPageService.GetEncryptionAvailableAsync(); } catch { _encryptionAvailable = false; } } } @@ -280,10 +292,18 @@ private void Cancel() => MudDialog.Cancel(); - private void ShowCreateProfile() + private void HandleProfileChanged(string value) { - _newProfileName = string.Empty; - _showCreateProfile = true; + if (value == NewProfileSentinel) + { + _newProfileName = string.Empty; + _showCreateProfile = true; + } + else + { + _model.ProfileId = value; + _showCreateProfile = false; + } } private void CancelCreateProfile() @@ -310,6 +330,38 @@ finally { _isCreatingProfile = false; } } + private void StartPasswordEdit() + { + _newPassword = string.Empty; + _passwordError = null; + _passwordSuccess = null; + _editingPassword = true; + } + + private void CancelPasswordEdit() + { + _newPassword = string.Empty; + _passwordError = null; + _editingPassword = false; + } + + private async Task ChangePasswordAsync() + { + _passwordError = null; + _passwordSuccess = null; + _isChangingPassword = true; + try + { + var error = await ProviderPageService.UpdateXtreamPasswordAsync(Provider.ProviderId, _newPassword, CancellationToken.None); + if (!string.IsNullOrWhiteSpace(error)) { _passwordError = error; return; } + _passwordSuccess = "Password updated."; + _newPassword = string.Empty; + _editingPassword = false; + } + catch (Exception ex) { _passwordError = $"Failed to update password: {ex.Message}"; } + finally { _isChangingPassword = false; } + } + private async Task SaveAsync() { _error = null; @@ -331,11 +383,11 @@ XtreamIncludeXmltv = _model.XtreamIncludeXmltv, Enabled = _model.Enabled, TimeoutSeconds = _model.TimeoutSeconds, - MaxConcurrentStreams = _model.MaxConcurrentStreams, + MaxConcurrentStreams = _model.LimitConcurrentStreams ? _model.ConcurrentStreamLimit : null, IncludeVod = _model.IncludeVod, IncludeSeries = _model.IncludeSeries, ForceMpegTs = _model.ForceMpegTs, - CleanRelayMode = _model.CleanRelayRemux ? "remux" : "off", + CleanRelayMode = _model.CleanRelayMode, AssociateToProfileIds = profileIds, }; } @@ -350,11 +402,11 @@ HeadersJson = NullIfEmpty(_model.HeadersJson), Enabled = _model.Enabled, TimeoutSeconds = _model.TimeoutSeconds, - MaxConcurrentStreams = _model.MaxConcurrentStreams, + MaxConcurrentStreams = _model.LimitConcurrentStreams ? _model.ConcurrentStreamLimit : null, IncludeVod = _model.IncludeVod, IncludeSeries = _model.IncludeSeries, ForceMpegTs = _model.ForceMpegTs, - CleanRelayMode = _model.CleanRelayRemux ? "remux" : "off", + CleanRelayMode = _model.CleanRelayMode, AssociateToProfileIds = profileIds, }; } @@ -369,44 +421,36 @@ finally { _isSaving = false; } } - private async Task ChangePasswordAsync() - { - _passwordError = null; - _passwordSuccess = null; - _isChangingPassword = true; - try - { - var error = await ProviderPageService.UpdateXtreamPasswordAsync(Provider.ProviderId, _newPassword, CancellationToken.None); - if (!string.IsNullOrWhiteSpace(error)) { _passwordError = error; return; } - _passwordSuccess = "Password updated successfully."; - _newPassword = string.Empty; - } - catch (Exception ex) { _passwordError = $"Failed to update password: {ex.Message}"; } - finally { _isChangingPassword = false; } - } - private static string? NullIfEmpty(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); private sealed class EditModel { public string Name { get; set; } = string.Empty; - // URL/File fields public string? PlaylistUrl { get; set; } public string? XmltvUrl { get; set; } public string? UserAgent { get; set; } public string? HeadersJson { get; set; } - // Xtream fields public string XtreamBaseUrl { get; set; } = string.Empty; public string XtreamUsername { get; set; } = string.Empty; public bool XtreamIncludeXmltv { get; set; } - // Common public bool Enabled { get; set; } = true; public int TimeoutSeconds { get; set; } = 120; - public int? MaxConcurrentStreams { get; set; } + public bool LimitConcurrentStreams { get; set; } + public int ConcurrentStreamLimit { get; set; } = 1; public bool IncludeVod { get; set; } public bool IncludeSeries { get; set; } public bool ForceMpegTs { get; set; } - public bool CleanRelayRemux { get; set; } + public string CleanRelayMode { get; set; } = "auto"; public string ProfileId { get; set; } = string.Empty; } + + private static string NormalizeCleanRelayMode(string? value) + { + if (string.Equals(value, "on", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "remux", StringComparison.OrdinalIgnoreCase)) + return "on"; + if (string.Equals(value, "off", StringComparison.OrdinalIgnoreCase)) + return "off"; + return "auto"; + } } diff --git a/src/M3Undle.Web/Components/Pages/Epg/EpgSources.razor b/src/M3Undle.Web/Components/Pages/Epg/EpgSources.razor index fc9f20c..5d4876f 100644 --- a/src/M3Undle.Web/Components/Pages/Epg/EpgSources.razor +++ b/src/M3Undle.Web/Components/Pages/Epg/EpgSources.razor @@ -19,21 +19,24 @@ EPG Sources @if (_sources.Count > 0) { - - @_sources.Count @(_sources.Count == 1 ? "source" : "sources") - + } @if (_sources.Any(s => s.LastSuccessUtc.HasValue && (s.LastFailureUtc is null || s.LastSuccessUtc > s.LastFailureUtc))) { - - @_sources.Count(s => s.LastSuccessUtc.HasValue && (s.LastFailureUtc is null || s.LastSuccessUtc > s.LastFailureUtc)) OK - + s.LastSuccessUtc.HasValue && (s.LastFailureUtc is null || s.LastSuccessUtc > s.LastFailureUtc))} OK")" + Size="Size.Small" + Color="Color.Success" + Variant="Variant.Outlined" /> } @if (_sources.Any(s => s.LastFailureUtc.HasValue && (s.LastSuccessUtc is null || s.LastFailureUtc > s.LastSuccessUtc))) { - - @_sources.Count(s => s.LastFailureUtc.HasValue && (s.LastSuccessUtc is null || s.LastFailureUtc > s.LastSuccessUtc)) failed - + s.LastFailureUtc.HasValue && (s.LastSuccessUtc is null || s.LastFailureUtc > s.LastSuccessUtc))} failed")" + Size="Size.Small" + Color="Color.Error" + Variant="Variant.Outlined" /> } @@ -90,14 +93,18 @@ else Guide Sources - - - Add Source - + + + + + + Add Source + + @@ -117,11 +124,11 @@ else Name - Kind + Kind URL / Path - Priority - Status - Actions + Priority + Status + Actions @@ -129,14 +136,17 @@ else @context.Name @if (!context.Enabled) { - Disabled + + + } - - @KindLabel(context.Kind) - + @@ -151,37 +161,49 @@ else } @if (context.LastSuccessUtc.HasValue && !isFailed) { - - OK - + } else if (isFailed) { - - Failed - + } else { - Never fetched + + + } - - + + + + - + - + + + @@ -197,7 +219,9 @@ else Color="@(_testResult.Success ? Color.Success : Color.Error)" Size="Size.Small" /> Test Result — @_testingSourceName - + + + @if (_testResult.Success) { @@ -246,8 +270,10 @@ else @p.Name } - + + + @@ -276,20 +302,28 @@ else var unmappedCount = _mappings.Count(m => string.IsNullOrEmpty(m.EpgChannelMappingId)); var manualCount = _mappings.Count(m => m.MappingMode == "manual"); } - @allCount total - @mappedCount mapped - @unmappedCount unmapped + + + @if (manualCount > 0) { - @manualCount manual + } @@ -304,11 +338,11 @@ else Channel - tvg-id + tvg-id EPG Source EPG Channel - Mode - Actions + Mode + Actions @context.ProviderChannelDisplayName @@ -320,22 +354,23 @@ else @(string.IsNullOrEmpty(context.XmltvChannelId) ? "—" : context.XmltvChannelId) - - @MappingModeLabel(context.MappingMode) - + + + - + @if (!string.IsNullOrEmpty(context.EpgChannelMappingId)) { - + @@ -366,7 +401,7 @@ else private ProviderListItemDto? _selectedProvider; private ProfileListItemDto? _selectedProfile; private int _activeTab; - private bool _isLoading; + private bool _isLoading = true; private bool _isMappingsLoading; private string? _error; private EpgSourceTestResult? _testResult; @@ -381,7 +416,13 @@ else private readonly CancellationTokenSource _cts = new(); private IDisposable? _eventSubscription; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() + { + _ = InitializeAsync(); + return Task.CompletedTask; + } + + private async Task InitializeAsync() { var reader = EventBus.Subscribe(out _eventSubscription); _ = ListenForEventsAsync(reader, _cts.Token); @@ -719,6 +760,16 @@ else _ => mode, }; + private static string MappingModeTooltip(string mode) => mode switch + { + "manual" => "You manually selected this EPG channel mapping", + "auto_id" => "Matched automatically by tvg-id", + "auto_name" => "Matched automatically by channel name", + "auto_fuzzy" => "Matched automatically by fuzzy name search — verify this is correct", + "none" => "No EPG channel matched — use Edit to set one manually", + _ => mode, + }; + private static string FailedTooltip(EpgSourceDto source) { var lines = new System.Text.StringBuilder(); diff --git a/src/M3Undle.Web/Components/Pages/HdHomeRun.razor b/src/M3Undle.Web/Components/Pages/HdHomeRun.razor new file mode 100644 index 0000000..5b38e43 --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/HdHomeRun.razor @@ -0,0 +1,265 @@ +@attribute [StreamRendering] +@page "/hdhr" +@using M3Undle.Web.Application +@inject NavigationManager Nav +@inject HdHomeRunDeviceService HdHomeRunDeviceService +@inject IHdHomeRunSettingsService HdHomeRunSettings +@inject EndpointUrlService EndpointUrlService +@implements IDisposable + +HDHomeRun — M3Undle + +HDHomeRun + + Virtual HDHomeRun device identity and endpoint URLs. Manage settings on the + Settings → HDHomeRun page. + + +@if (_isLoading) +{ + +} +else if (!_isEnabled) +{ + + HDHomeRun emulation is disabled. Enable it on the + Settings → HDHomeRun page. + +} +else +{ + + + @* ── Device Identity ─────────────────────────────────────────── *@ + + Device Identity + + + Friendly name + @_descriptor?.FriendlyName + + + Device ID + @_descriptor?.DeviceId + + + Model + @_descriptor?.ModelNumber + + + HDHR tuners + @_descriptor?.TunerCount + + + Base URL + @(_settingsState?.Applied.ResolvedBaseUrl ?? Nav.BaseUri.TrimEnd('/')) + + @if (!string.IsNullOrWhiteSpace(_settingsState?.Saved.AdvertisedBaseUrl)) + { + + Advertised URL (configured) + @_settingsState.Saved.AdvertisedBaseUrl + + } + + + Some clients (Jellyfin, Plex) may display this device as + HDHomeRun @_descriptor?.DeviceId instead of the friendly name. + If you see an unexpected device in your client, compare its Device ID to the one shown here. + + + + @* ── Discovery Status ────────────────────────────────────────── *@ + + Discovery Status + + + HDHR + + + + SSDP discovery + + + + SiliconDust discovery + + + + + @if (DiscoveryWarnings.Count > 0) + { + + @foreach (var w in DiscoveryWarnings) + { + @w + } + + } + + + @* ── HDHR URLs ───────────────────────────────────────────────── *@ + + HDHR Endpoints + +
+ Discover JSON + +
+
+ Device XML + +
+
+ Lineup JSON + +
+
+ Lineup Status + +
+
+ + Use the Discover JSON URL in clients that support manual HDHomeRun device entry. + Some clients auto-discover via SSDP or SiliconDust broadcast — no URL needed in that case. + +
+ + @* ── Client Setup Notes ──────────────────────────────────────── *@ + + Client Setup Notes + +
+ Jellyfin + + Add M3Undle as a Live TV source by selecting HDHomeRun tuner type. + If auto-discovery does not find the device, use the manual Discover JSON URL above. + Jellyfin may display the device as + HDHomeRun @_descriptor?.DeviceId. + +
+ +
+ NextPVR + + NextPVR may show individual tuners as + HDHomeRun @(_descriptor?.DeviceId)-0, + HDHomeRun @(_descriptor?.DeviceId)-1, etc. + The number at the end is the tuner index — this is normal behavior. + +
+ +
+ Identifying the Device + + If your client shows a device by Device ID rather than friendly name, + compare the ID shown in the client to the Device ID field above. + You can change the friendly name on the + Settings → HDHomeRun page. + +
+
+
+ +
+} + +@code { + private bool _isLoading = true; + private bool _isEnabled; + private HdHomeRunDeviceDescriptor? _descriptor; + private HdHomeRunRuntimeSnapshot? _runtimeSnapshot; + private HdhrSettingsState? _settingsState; + private readonly CancellationTokenSource _cts = new(); + + private string HdhrBaseUrl => _runtimeSnapshot?.ResolvedBaseUrl.TrimEnd('/') + ?? Nav.BaseUri.TrimEnd('/'); + + private string HdhrUrl(string path) => $"{HdhrBaseUrl}/hdhr/{path}"; + + private IReadOnlyList GetHdhrUrlVariants(string endpoint) + { + var variants = new List(); + + var dockerBase = EndpointUrlService.GetDockerBaseUrl(); + if (dockerBase is not null) + variants.Add(new EndpointUrlVariant("Copy Docker URL", $"{dockerBase}/hdhr/{endpoint}")); + + var externalBase = EndpointUrlService.GetExternalBaseUrl(); + if (externalBase is not null) + variants.Add(new EndpointUrlVariant("Copy External URL", $"{externalBase}/hdhr/{endpoint}")); + + return variants; + } + + private IReadOnlyList DiscoveryWarnings + { + get + { + var warnings = new List(); + + if (_runtimeSnapshot is not null) + { + if (!_runtimeSnapshot.DiscoveryEnabled) + warnings.Add("Network discovery (SSDP and SiliconDust) is disabled. Clients must add the device manually using the Discover JSON URL."); + + var resolvedBase = _runtimeSnapshot.ResolvedBaseUrl; + if (string.IsNullOrWhiteSpace(resolvedBase) + || resolvedBase.Contains("localhost", StringComparison.OrdinalIgnoreCase) + || resolvedBase.Contains("127.0.0.1")) + { + warnings.Add("No non-loopback base URL is configured. Remote clients may not be able to reach stream URLs embedded in lineup responses. Set an Advertised Base URL in Settings → HDHomeRun."); + } + } + + return warnings; + } + } + + protected override async Task OnInitializedAsync() + { + _isEnabled = HdHomeRunDeviceService.IsEnabled; + + if (!RendererInfo.IsInteractive) + return; + + _ = LoadAsync(); + } + + private async Task LoadAsync() + { + try + { + var ct = _cts.Token; + _settingsState = await Task.Run(() => HdHomeRunSettings.GetSettingsAsync(ct), ct); + + if (_isEnabled) + { + _descriptor = await Task.Run(() => HdHomeRunDeviceService.GetDeviceDescriptorAsync(ct), ct); + _runtimeSnapshot = HdHomeRunDeviceService.GetRuntimeSnapshot(); + } + } + catch (OperationCanceledException) { } + catch (ObjectDisposedException) { } + finally + { + _isLoading = false; + try { await InvokeAsync(StateHasChanged); } catch { } + } + } + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/src/M3Undle.Web/Components/Pages/Logs.razor b/src/M3Undle.Web/Components/Pages/Logs.razor index 018c552..0d5036c 100644 --- a/src/M3Undle.Web/Components/Pages/Logs.razor +++ b/src/M3Undle.Web/Components/Pages/Logs.razor @@ -8,7 +8,9 @@ Logs — M3Undle -
+
+ +
Live Log @if (!_autoScroll) { @@ -22,15 +24,43 @@ }
- + + + + + + + @foreach (var level in LogLevels) + { + + } + + +
- @foreach (var entry in _entries) + style="overflow-y:auto; height:100%; font-family:monospace; font-size:0.80rem; padding:8px 12px"> + @foreach (var entry in FilteredEntries) {
@entry.Timestamp.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss") @entry.Level[..3].ToUpperInvariant() + Style="height:18px; font-size:0.70rem; padding:0 6px; margin:0 4px; vertical-align:middle">@ShortLevel(entry.Level) @if (!string.IsNullOrEmpty(entry.EventType)) { [@entry.EventType] @@ -45,17 +75,35 @@
+
+ @code { + private static readonly string[] DefaultLogLevels = ["Fatal", "Error", "Warning", "Information", "Debug", "Verbose"]; + private readonly List _entries = []; + private readonly HashSet _enabledLevels = new(DefaultLogLevels, StringComparer.OrdinalIgnoreCase); + private readonly HashSet _seenLevels = new(DefaultLogLevels, StringComparer.OrdinalIgnoreCase); private readonly CancellationTokenSource _cts = new(); private IDisposable? _subscription; private DotNetObjectReference? _dotNetRef; private IJSObjectReference? _scrollCleanup; + private string _searchTerm = string.Empty; private bool _autoScroll = true; + private IEnumerable LogLevels => + _seenLevels + .OrderBy(LevelSortOrder) + .ThenBy(level => level, StringComparer.OrdinalIgnoreCase); + + private IEnumerable FilteredEntries => + _entries.Where(entry => IsLevelEnabled(entry.Level) && MatchesSearch(entry)); + protected override void OnInitialized() { _entries.AddRange(Store.GetRecent()); + foreach (var entry in _entries) + AddSeenLevel(entry.Level); + var reader = Sink.Subscribe(out _subscription); _ = StreamLogsAsync(reader, _cts.Token); } @@ -90,6 +138,7 @@ { await foreach (var entry in reader.ReadAllAsync(ct)) { + AddSeenLevel(entry.Level); _entries.Add(entry); if (_entries.Count > 500) _entries.RemoveAt(0); @@ -109,10 +158,78 @@ { await JS.InvokeVoidAsync("scrollToBottom", "log-container"); } + catch (JSDisconnectedException) { } catch (JSException) { } catch (TaskCanceledException) { } } + private void ToggleLevel(string level) + { + if (!_enabledLevels.Add(level)) + _enabledLevels.Remove(level); + } + + private string LevelFilterTooltip(string level) + { + var label = LevelDisplayName(level); + return _enabledLevels.Contains(level) + ? $"{label} log entries are visible. Click to hide this level." + : $"{label} log entries are hidden. Click to show this level."; + } + + private void ClearSearch() + { + _searchTerm = string.Empty; + } + + private int CountForLevel(string level) => + _entries.Count(entry => + string.Equals(entry.Level, level, StringComparison.OrdinalIgnoreCase) && + MatchesSearch(entry)); + + private bool IsLevelEnabled(string level) => + _enabledLevels.Contains(level); + + private void AddSeenLevel(string level) + { + if (string.IsNullOrWhiteSpace(level)) + return; + + if (_seenLevels.Add(level)) + _enabledLevels.Add(level); + } + + private bool MatchesSearch(LogEntry entry) + { + if (string.IsNullOrWhiteSpace(_searchTerm)) + return true; + + var haystack = $"{entry.Timestamp.LocalDateTime:yyyy-MM-dd HH:mm:ss} {entry.Level} {entry.EventType} {entry.Message} {entry.Exception}"; + var tokens = _searchTerm.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return tokens.All(token => haystack.Contains(token, StringComparison.OrdinalIgnoreCase)); + } + + private static int LevelSortOrder(string level) => level switch + { + "Fatal" => 0, + "Error" => 1, + "Warning" => 2, + "Information" => 3, + "Debug" => 4, + "Verbose" => 5, + _ => 100, + }; + + private static string LevelDisplayName(string level) => level switch + { + "Warn" => "Warning", + "Info" => "Information", + _ => level, + }; + + private static string ShortLevel(string level) => + level.Length <= 3 ? level.ToUpperInvariant() : level[..3].ToUpperInvariant(); + private static Color LevelColor(string level) => level switch { "Fatal" or "Error" => Color.Error, @@ -129,13 +246,10 @@ _subscription?.Dispose(); if (_scrollCleanup is not null) { - try - { - await _scrollCleanup.InvokeVoidAsync("dispose"); - } + try { await _scrollCleanup.InvokeVoidAsync("dispose"); } catch (JSDisconnectedException) { } catch (JSException) { } - await _scrollCleanup.DisposeAsync(); + finally { await _scrollCleanup.DisposeAsync(); } } _dotNetRef?.Dispose(); } diff --git a/src/M3Undle.Web/Components/Pages/ProfileDetail.razor b/src/M3Undle.Web/Components/Pages/ProfileDetail.razor index a680323..7c479b4 100644 --- a/src/M3Undle.Web/Components/Pages/ProfileDetail.razor +++ b/src/M3Undle.Web/Components/Pages/ProfileDetail.razor @@ -32,22 +32,22 @@ else Size="Size.Small" OnClick="@(() => Nav.NavigateTo("profiles"))" /> @profile.Name - - @(profile.HealthStatus == ProfileHealthStatus.Ok ? "Published" - : profile.HealthStatus == ProfileHealthStatus.Degraded ? "Degraded" - : "No output") - + @if (profile.IsActive) { - Active + } @if (!profile.Enabled) { - Disabled + } @if (!profile.IsActive) @@ -94,13 +94,6 @@ else Profile Name @profile.Name - - Output Name - @profile.OutputName - - This is the stable identifier used in published URLs (e.g. /m3u/@(profile.OutputName).m3u) - - Created @profile.CreatedUtc.ToString("u") @@ -128,7 +121,7 @@ else } else { - @eLabel + } } @@ -150,17 +143,18 @@ else { - - @profile.LiveCount live - - - @profile.MovieCount movies - - - @profile.SeriesCount series - + + + Last published @@ -175,6 +169,13 @@ else } + @if (profile.GroupsRemovedFromProvider > 0) + { + + @profile.GroupsRemovedFromProvider group@(profile.GroupsRemovedFromProvider == 1 ? "" : "s") removed from provider — + review in Channel Mapping + + } @if (profile.GroupsPendingReview > 0) { @@ -189,6 +190,12 @@ else review now } + @if (profile.LastPublishedUtc.HasValue && profile.LiveCount == 0) + { + + No channels in output — open Channel Mapping to include groups + + }
@@ -213,16 +220,16 @@ else - Provider - Priority - Expires - Status + Provider + Priority + Expires + Status @foreach (var p in profile.Providers) { - + @p.Name @p.Priority @@ -244,18 +251,28 @@ else } else { - @pl + } } @if (!p.Enabled) { - Disabled + + } + else if (p.LastFetchStatus == "fail") + { + + + + } + else if (p.LastFetchStatus is null) + { + } else { - Enabled + } @@ -340,6 +357,7 @@ else @(_savingRefreshSchedule ? "Saving..." : "Save Refresh Schedule") @@ -363,38 +381,60 @@ else - Date - Status - Live - Notes + Date + Status + Live + Movies + Series + Change - @foreach (var snap in _detail.History) + @for (var i = 0; i < _detail.History.Count; i++) { + var snap = _detail.History[i]; + var prev = i + 1 < _detail.History.Count ? _detail.History[i + 1] : null; + var delta = prev is not null + ? snap.ChannelCountPublished - prev.ChannelCountPublished + : (int?)null; + var hasError = !string.IsNullOrWhiteSpace(snap.ErrorSummary); @snap.CreatedUtc.ToString("u") - - @snap.Status - - - - @snap.LiveChannelCount.ToString("N0") - - - @if (!string.IsNullOrWhiteSpace(snap.ErrorSummary)) + @if (hasError) { - + } + else + { + + } + + @snap.LiveChannelCount.ToString("N0") + @snap.VodChannelCount.ToString("N0") + @snap.SeriesChannelCount.ToString("N0") + + @if (delta is null) + { + + } + else if (delta == 0) + { + 0 + } + else + { + + @(delta > 0 ? "+" : "")@delta.Value.ToString("N0") + + } } diff --git a/src/M3Undle.Web/Components/Pages/Profiles.razor b/src/M3Undle.Web/Components/Pages/Profiles.razor index b0a1017..7eb9ff4 100644 --- a/src/M3Undle.Web/Components/Pages/Profiles.razor +++ b/src/M3Undle.Web/Components/Pages/Profiles.razor @@ -22,7 +22,9 @@ OnClick="OpenCreateDialogAsync"> New Profile - + + +
@@ -72,7 +74,7 @@ else if (_loadError is not null) else if (_profiles.Count == 0) { - No profiles found. Profiles are created automatically when you add a provider, or use the New Profile button above. + No profiles configured yet. } else @@ -87,21 +89,30 @@ else @profile.Name - - Output: @profile.OutputName - + @if (profile.IsActive && profile.HealthStatus != ProfileHealthStatus.NoOutput) + { + + + @if (_xtreamEnabled) + { + + } + @if (_hdhrEnabled) + { + + } + + } - - @(profile.HealthStatus == ProfileHealthStatus.Ok ? "Published" - : profile.HealthStatus == ProfileHealthStatus.Degraded ? "Degraded" - : "No output") - + @@ -109,8 +120,10 @@ else @if (profile.IsActive) { - Active + } else { @@ -140,28 +153,25 @@ else @if (profile.LiveCount > 0 || profile.LastPublishedUtc.HasValue) { - - @profile.LiveCount live - + } @if (profile.MovieCount > 0) { - - @profile.MovieCount movies - + } @if (profile.SeriesCount > 0) { - - @profile.SeriesCount series - + } @if (!profile.Enabled) { - Disabled + } @@ -186,16 +196,31 @@ else } + @if (profile.GroupsRemovedFromProvider > 0) + { + + @profile.GroupsRemovedFromProvider group@(profile.GroupsRemovedFromProvider == 1 ? "" : "s") removed from provider — + review in Channel Mapping + + } @if (profile.GroupsPendingReview > 0) { - @profile.GroupsPendingReview new group@(profile.GroupsPendingReview == 1 ? "" : "s") to review + @profile.GroupsPendingReview new group@(profile.GroupsPendingReview == 1 ? "" : "s") to review — + review now } @if (profile.ChannelsPendingReview > 0) { - @profile.ChannelsPendingReview pending channel@(profile.ChannelsPendingReview == 1 ? "" : "s") to review + @profile.ChannelsPendingReview pending channel@(profile.ChannelsPendingReview == 1 ? "" : "s") to review — + review now + + } + @if (profile.LastPublishedUtc.HasValue && profile.LiveCount == 0) + { + + No channels in output — open Channel Mapping to include groups } @@ -218,8 +243,10 @@ else @code { private List _profiles = []; - private bool _isLoading; + private bool _isLoading = true; private string? _loadError; + private bool _xtreamEnabled = true; + private bool _hdhrEnabled = true; private string? _activatingProfileId; @@ -235,19 +262,30 @@ else CloseOnEscapeKey = true, }; - protected override async Task OnInitializedAsync() + protected override Task OnInitializedAsync() { - await LoadAsync(); + _ = InitializeAsync(); + return Task.CompletedTask; } - private async Task LoadAsync() + private async Task InitializeAsync() + { + await LoadAsync(includePendingCounts: false); + _ = RefreshPendingCountsAsync(); + } + + private async Task LoadAsync(bool includePendingCounts = true) { _isLoading = true; _loadError = null; try { - _profiles = await ProfilesService.GetProfilesAsync(CancellationToken.None); + var profilesTask = ProfilesService.GetProfilesAsync(CancellationToken.None, includePendingCounts); + var flagsTask = ProfilesService.GetEndpointFlagsAsync(CancellationToken.None); + await Task.WhenAll(profilesTask, flagsTask); + _profiles = await profilesTask; + (_xtreamEnabled, _hdhrEnabled) = await flagsTask; } catch (Exception ex) { @@ -256,7 +294,39 @@ else finally { _isLoading = false; - StateHasChanged(); + try { await InvokeAsync(StateHasChanged); } catch { } + } + } + + private Task ReloadAsync() => LoadAsync(); + + private async Task RefreshPendingCountsAsync() + { + try + { + var counts = await ProfilesService.GetPendingReviewCountsAsync(CancellationToken.None); + + foreach (var profile in _profiles) + { + if (counts.TryGetValue(profile.ProfileId, out var value)) + { + profile.GroupsPendingReview = value.Groups; + profile.ChannelsPendingReview = value.Channels; + profile.GroupsRemovedFromProvider = value.Removed; + } + else + { + profile.GroupsPendingReview = 0; + profile.ChannelsPendingReview = 0; + profile.GroupsRemovedFromProvider = 0; + } + } + + try { await InvokeAsync(StateHasChanged); } catch { } + } + catch + { + // Keep initial profile data visible even if deferred counter refresh fails. } } diff --git a/src/M3Undle.Web/Components/Pages/Providers.razor b/src/M3Undle.Web/Components/Pages/Providers.razor index 7f59cd2..4a550ec 100644 --- a/src/M3Undle.Web/Components/Pages/Providers.razor +++ b/src/M3Undle.Web/Components/Pages/Providers.razor @@ -5,6 +5,7 @@ @inject AppEventBus EventBus @inject IRefreshTrigger RefreshTrigger @inject IDialogService DialogService +@inject IJSRuntime JS @implements IDisposable M3Undle @@ -47,10 +48,7 @@ } - - Configured Providers - Reload - + Configured Providers @if (_isLoading) { @@ -62,14 +60,17 @@ } else { - + Name Profile Max Streams - Last Refresh + + + Last Refresh + + Expires - Published Status Actions @@ -79,15 +80,21 @@ @context.Name @if (context.IsXtreamProvider) { - Xtream + + Xtream + } else if (context.PlaylistUrl?.StartsWith("file://", StringComparison.OrdinalIgnoreCase) == true) { - File + + File + } else { - M3U + + M3U + } @if (!context.Enabled) { @@ -110,39 +117,56 @@ } @(context.MaxConcurrentStreams?.ToString() ?? "—") - @FormatRefresh(context.LastRefresh) + + + @FormatRefresh(context.LastRefresh) + + @ExpiryCell(context.PlaylistExpiresUtc) - @FormatSnapshots(context.LatestSnapshots) @if (context.LastRefresh?.Status == "fail") { @if (context.LatestSnapshots.Any(s => s.Status == "active")) { - Degraded + + Degraded + } else { - Failed + + Failed + } } else if (context.LatestSnapshots.Any(s => s.Status == "active")) { - Published + + Published + } - - + + + + + + @if (_deletingProviderId == context.ProviderId) { } else { - + + + } - Preview + + Preview + @@ -205,10 +229,13 @@ @* Add and Edit dialogs are opened imperatively via DialogService *@ @code { + [SupplyParameterFromQuery(Name = "provider")] + public string? HighlightProvider { get; set; } + private readonly List _providers = []; private readonly List _profiles = []; - private bool _isLoading; + private bool _isLoading = true; private bool _isRefreshing; private bool _isPreviewLoading; private string? _previewLoadingProviderId; @@ -222,17 +249,46 @@ private readonly CancellationTokenSource _cts = new(); private IDisposable? _eventSubscription; + private PeriodicTimer? _autoRefreshTimer; + + protected override Task OnInitializedAsync() + { + _ = InitializeAsync(); + return Task.CompletedTask; + } - protected override async Task OnInitializedAsync() + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && HighlightProvider is not null) + await JS.InvokeVoidAsync("scrollToClass", "provider-row-highlight"); + } + + private async Task InitializeAsync() { _isRefreshing = RefreshTrigger.IsRefreshing; var reader = EventBus.Subscribe(out _eventSubscription); _ = ListenForEventsAsync(reader, _cts.Token); + _autoRefreshTimer = new PeriodicTimer(TimeSpan.FromSeconds(30)); + _ = RunAutoRefreshAsync(_cts.Token); + await LoadAsync(); } + private async Task RunAutoRefreshAsync(CancellationToken ct) + { + try + { + while (await _autoRefreshTimer!.WaitForNextTickAsync(ct)) + { + if (!_isLoading && !_isDeletingProvider && !_isRefreshing) + await InvokeAsync(LoadAsync); + } + } + catch (OperationCanceledException) { } + } + private async Task ListenForEventsAsync(System.Threading.Channels.ChannelReader reader, CancellationToken ct) { try @@ -264,6 +320,7 @@ _cts.Cancel(); _cts.Dispose(); _eventSubscription?.Dispose(); + _autoRefreshTimer?.Dispose(); } private Task OpenAddDialogAsync() @@ -422,17 +479,40 @@ } } + private static string FailTooltip(string baseMessage, string? errorSummary) + => string.IsNullOrWhiteSpace(errorSummary) ? baseMessage : $"{baseMessage}\n\n{errorSummary}"; + private static string FormatRefresh(ProviderLastRefreshDto? refresh) { - if (refresh is null) return "No refresh yet"; - if (refresh.Status == "running") return $"running since {refresh.StartedUtc:u}"; - return $"{refresh.Status} @ {refresh.FinishedUtc?.ToString("u") ?? "?"}"; + if (refresh is null) return "Never"; + if (refresh.Status == "running") return "Checking now…"; + return refresh.FinishedUtc is { } t ? t.ToString("MMM d, yyyy h:mm tt") : "—"; + } + + private static string FormatRefreshTooltip(ProviderDto provider) + { + var refresh = provider.LastRefresh; + if (refresh is null) return "This provider has never been checked for a channel lineup."; + if (refresh.Status == "running") return "Downloading channel lineup from provider…"; + var what = provider.IsXtreamProvider && provider.XtreamIncludeXmltv + ? "Channel lineup and guide data (EPG)" + : "Channel lineup"; + if (refresh.Status == "fail") + return $"{what} update failed — {refresh.ErrorSummary ?? "check the logs for details"}"; + return refresh.ChannelCountSeen.HasValue + ? $"{what} updated — {refresh.ChannelCountSeen} channels found" + : $"{what} updated successfully"; } - private static string FormatSnapshots(List snapshots) + private static string GetToggleTooltip(ProviderDto provider) => + provider.Enabled ? "Disable this provider" : "Enable this provider"; + + private static string FormatSnapshotsTooltip(List snapshots) { - if (snapshots.Count == 0) return "None"; - return string.Join(" | ", snapshots.Select(x => $"{x.Status} @ {x.CreatedUtc:u}")); + var active = snapshots.Where(x => x.Status == "active").ToList(); + if (active.Count == 0) return "No active lineup published"; + if (active.Count == 1) return $"Lineup published {active[0].CreatedUtc:u}"; + return "Published lineups: " + string.Join(", ", active.Select(x => x.CreatedUtc.ToString("u"))); } private bool IsProviderActionDisabled(ProviderDto provider) @@ -451,9 +531,14 @@ : daysLeft < 1 ? $"Expires today" : daysLeft <= 30 ? $"{expiry.Value:yyyy-MM-dd} (in {(int)daysLeft + 1}d)" : expiry.Value.ToString("yyyy-MM-dd"); + var tooltipText = daysLeft < 0 + ? $"Your provider access expired on {expiry.Value:MMMM d, yyyy}. Streams from this provider may no longer work." + : daysLeft < 1 + ? $"Your provider access expires today. Renew your subscription to keep streams working." + : $"Your provider access expires on {expiry.Value:MMMM d, yyyy}. After this date, streams may stop working."; builder.OpenComponent(0); - builder.AddAttribute(1, "Text", expiry.Value.ToString("u")); + builder.AddAttribute(1, "Text", tooltipText); builder.AddAttribute(2, "ChildContent", (RenderFragment)(inner => { if (color == Color.Default) diff --git a/src/M3Undle.Web/Components/Pages/Providers/ProviderConcurrentStreams.razor b/src/M3Undle.Web/Components/Pages/Providers/ProviderConcurrentStreams.razor new file mode 100644 index 0000000..606c746 --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Providers/ProviderConcurrentStreams.razor @@ -0,0 +1,28 @@ + + + + M3Undle will reject new stream requests once this limit is reached, protecting active viewers + from being cut off. Set it to your subscription's maximum simultaneous connections. + +@if (LimitConcurrentStreams) +{ + +} + +@code { + [Parameter] public bool LimitConcurrentStreams { get; set; } + [Parameter] public EventCallback LimitConcurrentStreamsChanged { get; set; } + [Parameter] public int ConcurrentStreamLimit { get; set; } = 1; + [Parameter] public EventCallback ConcurrentStreamLimitChanged { get; set; } + [Parameter] public bool Disabled { get; set; } + + private async Task OnLimitChangedAsync(bool value) + { + if (value && ConcurrentStreamLimit < 1) + await ConcurrentStreamLimitChanged.InvokeAsync(1); + await LimitConcurrentStreamsChanged.InvokeAsync(value); + } +} diff --git a/src/M3Undle.Web/Components/Pages/Providers/ProviderContentToggles.razor b/src/M3Undle.Web/Components/Pages/Providers/ProviderContentToggles.razor new file mode 100644 index 0000000..a307492 --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Providers/ProviderContentToggles.razor @@ -0,0 +1,14 @@ + +Content Types + + + +@code { + [Parameter] public bool IncludeVod { get; set; } + [Parameter] public EventCallback IncludeVodChanged { get; set; } + [Parameter] public bool IncludeSeries { get; set; } + [Parameter] public EventCallback IncludeSeriesChanged { get; set; } + [Parameter] public bool Disabled { get; set; } +} diff --git a/src/M3Undle.Web/Components/Pages/Providers/ProviderFormTypes.cs b/src/M3Undle.Web/Components/Pages/Providers/ProviderFormTypes.cs new file mode 100644 index 0000000..136a0cd --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Providers/ProviderFormTypes.cs @@ -0,0 +1,24 @@ +namespace M3Undle.Web.Components.ProviderForms; + +public sealed record XtreamPrefill( + string Name, + string BaseUrl, + string Username, + string Password, + bool IncludeXmltv, + bool Enabled = true, + int TimeoutSeconds = 120, + bool LimitConcurrentStreams = false, + int ConcurrentStreamLimit = 1, + bool IncludeVod = true, + bool IncludeSeries = true, + bool ForceMpegTs = false, + string CleanRelayMode = "auto", + string ProfileId = ""); + +public sealed record UrlProviderPrefill( + string Name, + string Url, + string? XmltvUrl); + +public enum UrlProviderMode { Url, File } diff --git a/src/M3Undle.Web/Components/Pages/Providers/ProviderProfileSelect.razor b/src/M3Undle.Web/Components/Pages/Providers/ProviderProfileSelect.razor new file mode 100644 index 0000000..e231957 --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Providers/ProviderProfileSelect.razor @@ -0,0 +1,21 @@ + p.ProfileId == id)?.Name ?? id)"> + + @(AllowAutocreate ? "(Auto-create from name)" : "(None)") + + @foreach (var profile in AvailableProfiles) + { + @profile.Name + } + + +@code { + [Parameter] public string ProfileId { get; set; } = string.Empty; + [Parameter] public EventCallback ProfileIdChanged { get; set; } + [Parameter] public IEnumerable AvailableProfiles { get; set; } = []; + [Parameter] public List AllProfiles { get; set; } = []; + [Parameter] public bool AllowAutocreate { get; set; } = true; +} diff --git a/src/M3Undle.Web/Components/Pages/Providers/ProviderStreamFormat.razor b/src/M3Undle.Web/Components/Pages/Providers/ProviderStreamFormat.razor new file mode 100644 index 0000000..36fba36 --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Providers/ProviderStreamFormat.razor @@ -0,0 +1,37 @@ + +Stream Format + + + @if (IsXtream) + { + Disables HLS delivery to clients and instructs the Xtream server to serve MPEG-TS output. + Enable only if streams stutter or your player handles TS containers better than HLS segments. + } + else + { + Requests MPEG-TS transport from the provider instead of HLS. Playlist URLs containing + output=m3u8 are automatically rewritten to output=ts, and HLS delivery + to clients is disabled for this provider. Enable only if streams stutter or your player handles + TS containers better than HLS segments. + } + + + Auto + Off + On + + + Auto keeps stable channels direct and uses clean remux for channels classified as unstable. + On forces FFmpeg clean remux for every channel on this provider. Off forces direct relay. + + +@code { + [Parameter] public bool ForceMpegTs { get; set; } + [Parameter] public EventCallback ForceMpegTsChanged { get; set; } + [Parameter] public string CleanRelayMode { get; set; } = "auto"; + [Parameter] public EventCallback CleanRelayModeChanged { get; set; } + [Parameter] public bool IsXtream { get; set; } + [Parameter] public bool Disabled { get; set; } +} diff --git a/src/M3Undle.Web/Components/Pages/Providers/UrlProviderForm.razor b/src/M3Undle.Web/Components/Pages/Providers/UrlProviderForm.razor new file mode 100644 index 0000000..a82899f --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Providers/UrlProviderForm.razor @@ -0,0 +1,368 @@ +@inject ProviderPageService ProviderPageService + +
+ @if (!string.IsNullOrWhiteSpace(_error)) + { + @_error + } + + @* File browser unavailable warning (File mode only) *@ + @if (Mode == UrlProviderMode.File && _fileBrowseRootMissing) + { + + @(_fileBrowseError ?? "File browser unavailable.") + + } + + + + + + @* ---- URL mode: playlist URL input ---- *@ + @if (Mode == UrlProviderMode.Url) + { + + + + + + + + @* Xtream detection *@ + @if (LooksLikeXtreamUrl(_model.PlaylistUrl)) + { + + + + Xtream API detected. Xtream Codes mode gives you richer channel data, + stable stream IDs, and subscription expiry tracking. + + + + + Switch to Xtream Codes → + + + @if (!EncryptionAvailable) + { + + M3UNDLE_ENCRYPTION_KEY must be configured to use Xtream Codes mode. + + } + + + } + + + } + + @* ---- File mode: file browser ---- *@ + @if (Mode == UrlProviderMode.File) + { + + + + Browse + + + @if (_showFileBrowser) + { + + @if (_browseLoading) + { + + } + else + { + @if (_browseData?.ParentPath is not null) + { + + .. + + } + @if (_browseData is not null) + { + @foreach (var entry in _browseData.Entries) + { + if (entry.IsDirectory) + { + + @entry.Name + + } + else + { + + @entry.Name + + } + } + @if (!_browseData.Entries.Any()) + { + + No .m3u or .m3u8 files found here. + + } + } + } + + } + + + + } + + + + + + + + + + + Advanced options + + + + @if (Mode == UrlProviderMode.Url) + { + + + } + + + + + + +
+ +@code { + [Parameter] public UrlProviderMode Mode { get; set; } + [Parameter] public List Profiles { get; set; } = []; + [Parameter] public List ExistingProviders { get; set; } = []; + [Parameter] public bool EncryptionAvailable { get; set; } + [Parameter] public UrlProviderPrefill? Prefill { get; set; } + [Parameter] public EventCallback OnSaved { get; set; } + [Parameter] public EventCallback OnSwitchToXtream { get; set; } + + private MudForm? _form; + private string? _error; + private bool _showAdvanced; + + // File browser + private bool _fileBrowseRootMissing; + private string? _fileBrowseError; + private bool _showFileBrowser; + private bool _browseLoading; + private FileBrowseDto? _browseData; + + private UrlProviderPrefill? _appliedPrefill; + + private FormModel _model = new() { TimeoutSeconds = 120, Enabled = true }; + + private IEnumerable AvailableProfiles => Profiles.Where(p => + !ExistingProviders.Any(prov => prov.AssociatedProfileIds.Contains(p.ProfileId))); + + private const string _urlHelperText = + "Enter an http/https URL.\n\n" + + "To keep credentials out of the database, place them in a .env file in M3UNDLE_CONFIG_DIR " + + "and reference them with %VAR_NAME% syntax.\n\n" + + "Example URL:\n" + + " http://my.server:8080/get.php?username=alice&password=%MY_PASSWORD%\n\n" + + ".env file entry:\n" + + " MY_PASSWORD=supersecret"; + + protected override async Task OnInitializedAsync() + { + if (Mode == UrlProviderMode.File) + { + var browse = await ProviderPageService.BrowseFilesystemAsync(null); + _fileBrowseRootMissing = browse.Data is null; + if (_fileBrowseRootMissing) + _fileBrowseError = browse.Error; + } + + var defaultProfile = AvailableProfiles.FirstOrDefault(x => x.Enabled); + if (defaultProfile is not null) + _model.ProfileId = defaultProfile.ProfileId; + } + + protected override void OnParametersSet() + { + if (Prefill is not null && !ReferenceEquals(Prefill, _appliedPrefill)) + { + _model.Name = Prefill.Name; + _model.PlaylistUrl = Prefill.Url; + _model.XmltvUrl = Prefill.XmltvUrl; + _appliedPrefill = Prefill; + } + } + + public async Task TrySubmitAsync() + { + _error = null; + + if (_form is not null) { await _form.ValidateAsync(); if (!_form.IsValid) return; } + + if (Mode == UrlProviderMode.File && string.IsNullOrWhiteSpace(_model.PlaylistUrl)) + { + _error = "Please select a file using the Browse button."; + StateHasChanged(); + return; + } + + var result = await ProviderPageService.CreateProviderAsync(new CreateProviderRequest + { + Name = _model.Name.Trim(), + PlaylistUrl = _model.PlaylistUrl.Trim(), + XmltvUrl = NullIfEmpty(_model.XmltvUrl), + HeadersJson = NullIfEmpty(_model.HeadersJson), + UserAgent = NullIfEmpty(_model.UserAgent), + Enabled = _model.Enabled, + IncludeVod = _model.IncludeVod, + IncludeSeries = _model.IncludeSeries, + ForceMpegTs = _model.ForceMpegTs, + CleanRelayMode = _model.CleanRelayMode, + TimeoutSeconds = _model.TimeoutSeconds, + MaxConcurrentStreams = _model.LimitConcurrentStreams ? _model.ConcurrentStreamLimit : null, + AssociateToProfileIds = ProfileIdList(_model.ProfileId), + }, CancellationToken.None); + + if (!string.IsNullOrWhiteSpace(result.Error)) + { + _error = result.Error; + StateHasChanged(); + return; + } + + await OnSaved.InvokeAsync(result.Provider!); + } + + private async Task SwitchToXtreamAsync() + { + var creds = ProviderPageService.ParseXtreamUrl(_model.PlaylistUrl.Trim()); + if (creds is null) return; + await OnSwitchToXtream.InvokeAsync(new XtreamPrefill( + Name: string.IsNullOrWhiteSpace(_model.Name) ? string.Empty : _model.Name.Trim(), + BaseUrl: creds.Value.BaseUrl, + Username: creds.Value.Username, + Password: creds.Value.Password, + IncludeXmltv: true, + Enabled: _model.Enabled, + TimeoutSeconds: _model.TimeoutSeconds, + LimitConcurrentStreams: _model.LimitConcurrentStreams, + ConcurrentStreamLimit: _model.ConcurrentStreamLimit, + IncludeVod: _model.IncludeVod, + IncludeSeries: _model.IncludeSeries, + ForceMpegTs: _model.ForceMpegTs, + CleanRelayMode: _model.CleanRelayMode, + ProfileId: _model.ProfileId)); + } + + // ------------------------------------------------------------------ + // File browser + // ------------------------------------------------------------------ + + private async Task OpenFileBrowserAsync() + { + _showFileBrowser = true; + await BrowseToAsync(null); + } + + private async Task BrowseToAsync(string? path) + { + _browseLoading = true; + StateHasChanged(); + try + { + var browse = await ProviderPageService.BrowseFilesystemAsync(path); + if (browse.Data is not null) + _browseData = browse.Data; + else + { + _fileBrowseRootMissing = true; + _fileBrowseError = browse.Error; + } + } + catch { _fileBrowseRootMissing = true; } + finally + { + _browseLoading = false; + StateHasChanged(); + } + } + + private Task SelectFileAsync(string filePath) + { + _model.PlaylistUrl = $"file://{filePath}"; + _showFileBrowser = false; + StateHasChanged(); + return Task.CompletedTask; + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + private static bool LooksLikeXtreamUrl(string? url) => + !string.IsNullOrEmpty(url) + && url.Contains("/get.php", StringComparison.OrdinalIgnoreCase) + && url.Contains("username=", StringComparison.OrdinalIgnoreCase) + && url.Contains("password=", StringComparison.OrdinalIgnoreCase); + + private static string? NullIfEmpty(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); + + private static List? ProfileIdList(string id) => + string.IsNullOrWhiteSpace(id) ? null : [id]; + + private sealed class FormModel + { + public string Name { get; set; } = string.Empty; + public string PlaylistUrl { get; set; } = string.Empty; + public string? XmltvUrl { get; set; } + public string? UserAgent { get; set; } + public string? HeadersJson { get; set; } + public bool Enabled { get; set; } = true; + public int TimeoutSeconds { get; set; } = 120; + public bool LimitConcurrentStreams { get; set; } + public int ConcurrentStreamLimit { get; set; } = 1; + public bool IncludeVod { get; set; } = true; + public bool IncludeSeries { get; set; } = true; + public bool ForceMpegTs { get; set; } + public string CleanRelayMode { get; set; } = "auto"; + public string ProfileId { get; set; } = string.Empty; + } +} diff --git a/src/M3Undle.Web/Components/Pages/Providers/XtreamProviderForm.razor b/src/M3Undle.Web/Components/Pages/Providers/XtreamProviderForm.razor new file mode 100644 index 0000000..48ab0cc --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Providers/XtreamProviderForm.razor @@ -0,0 +1,190 @@ +@inject ProviderPageService ProviderPageService + +
+ @if (!_encryptionAvailable) + { + + M3UNDLE_ENCRYPTION_KEY is not set. + + Xtream Codes providers require encrypted password storage. Generate a key and set the + environment variable before adding this provider type: + + + openssl rand -base64 32 + + + Then set M3UNDLE_ENCRYPTION_KEY=<output> and restart M3Undle. + + + } + + @if (!string.IsNullOrWhiteSpace(_error)) + { + @_error + } + + + + + + + + + + + + + + + + + + + Advanced options + + + + + + + + + +
+ +@code { + [Parameter] public List Profiles { get; set; } = []; + [Parameter] public List ExistingProviders { get; set; } = []; + [Parameter] public bool EncryptionAvailable { get; set; } + [Parameter] public XtreamPrefill? Prefill { get; set; } + [Parameter] public EventCallback OnSaved { get; set; } + + private MudForm? _form; + private string? _error; + private bool _showAdvanced; + private bool _encryptionAvailable; + private XtreamPrefill? _appliedPrefill; + + private FormModel _model = new() { TimeoutSeconds = 120, Enabled = true }; + + private IEnumerable AvailableProfiles => Profiles.Where(p => + !ExistingProviders.Any(prov => prov.AssociatedProfileIds.Contains(p.ProfileId))); + + protected override void OnInitialized() + { + _encryptionAvailable = EncryptionAvailable; + + var defaultProfile = AvailableProfiles.FirstOrDefault(x => x.Enabled); + if (defaultProfile is not null) + _model.ProfileId = defaultProfile.ProfileId; + } + + protected override void OnParametersSet() + { + _encryptionAvailable = EncryptionAvailable; + + if (Prefill is not null && !ReferenceEquals(Prefill, _appliedPrefill)) + { + _model.Name = Prefill.Name; + _model.XtreamBaseUrl = Prefill.BaseUrl; + _model.XtreamUsername = Prefill.Username; + _model.XtreamPassword = Prefill.Password; + _model.XtreamIncludeXmltv = Prefill.IncludeXmltv; + _model.Enabled = Prefill.Enabled; + _model.TimeoutSeconds = Prefill.TimeoutSeconds; + _model.LimitConcurrentStreams = Prefill.LimitConcurrentStreams; + _model.ConcurrentStreamLimit = Prefill.ConcurrentStreamLimit; + _model.IncludeVod = Prefill.IncludeVod; + _model.IncludeSeries = Prefill.IncludeSeries; + _model.ForceMpegTs = Prefill.ForceMpegTs; + _model.CleanRelayMode = Prefill.CleanRelayMode; + _model.ProfileId = Prefill.ProfileId; + _appliedPrefill = Prefill; + } + } + + public async Task TrySubmitAsync() + { + if (!_encryptionAvailable) return; + + _error = null; + + if (_form is not null) { await _form.ValidateAsync(); if (!_form.IsValid) return; } + + var result = await ProviderPageService.CreateProviderAsync(new CreateProviderRequest + { + Name = _model.Name.Trim(), + XtreamBaseUrl = _model.XtreamBaseUrl.Trim(), + XtreamUsername = _model.XtreamUsername.Trim(), + XtreamPassword = _model.XtreamPassword, + XtreamIncludeXmltv = _model.XtreamIncludeXmltv, + Enabled = _model.Enabled, + IncludeVod = _model.IncludeVod, + IncludeSeries = _model.IncludeSeries, + ForceMpegTs = _model.ForceMpegTs, + CleanRelayMode = _model.CleanRelayMode, + TimeoutSeconds = _model.TimeoutSeconds, + MaxConcurrentStreams = _model.LimitConcurrentStreams ? _model.ConcurrentStreamLimit : null, + AssociateToProfileIds = ProfileIdList(_model.ProfileId), + }, CancellationToken.None); + + if (!string.IsNullOrWhiteSpace(result.Error)) + { + _error = result.Error; + StateHasChanged(); + return; + } + + await OnSaved.InvokeAsync(result.Provider!); + } + + private static List? ProfileIdList(string id) => + string.IsNullOrWhiteSpace(id) ? null : [id]; + + private sealed class FormModel + { + public string Name { get; set; } = string.Empty; + public string XtreamBaseUrl { get; set; } = string.Empty; + public string XtreamUsername { get; set; } = string.Empty; + public string XtreamPassword { get; set; } = string.Empty; + public bool XtreamIncludeXmltv { get; set; } = true; + public bool Enabled { get; set; } = true; + public int TimeoutSeconds { get; set; } = 120; + public bool LimitConcurrentStreams { get; set; } + public int ConcurrentStreamLimit { get; set; } = 1; + public bool IncludeVod { get; set; } = true; + public bool IncludeSeries { get; set; } = true; + public bool ForceMpegTs { get; set; } + public string CleanRelayMode { get; set; } = "auto"; + public string ProfileId { get; set; } = string.Empty; + } +} diff --git a/src/M3Undle.Web/Components/Pages/Providers/_Imports.razor b/src/M3Undle.Web/Components/Pages/Providers/_Imports.razor new file mode 100644 index 0000000..f7be665 --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/Providers/_Imports.razor @@ -0,0 +1,3 @@ +@namespace M3Undle.Web.Components.ProviderForms +@using M3Undle.Web.Contracts.Providers +@using M3Undle.Web.Application diff --git a/src/M3Undle.Web/Components/Pages/Settings.razor b/src/M3Undle.Web/Components/Pages/Settings.razor index 0b43d6f..e866caa 100644 --- a/src/M3Undle.Web/Components/Pages/Settings.razor +++ b/src/M3Undle.Web/Components/Pages/Settings.razor @@ -5,6 +5,7 @@ @using M3Undle.Web.Observability @using M3Undle.Web.Streaming.Configuration @using M3Undle.Web.Streaming.Observability +@inject NavigationManager Nav @inject ISiteSettingsService SiteSettings @inject IEndpointSecurityService EndpointSecurity @inject IStreamingSettingsService StreamingSettings @@ -20,1021 +21,1356 @@ @inject IMetricsTokenService MetricsTokenService @inject IDialogService DialogService @inject IJSRuntime JS +@inject EndpointUrlService EndpointUrlService Settings — M3Undle Settings - - - - Global Refresh Schedule - - Default cadence for profiles that do not set their own refresh schedule. - - - @if (_loadingRefreshSettings) - { - - } - else - { -
- -
- - - - When enabled, M3Undle will refresh on startup if the last published snapshot is stale. +
+ + @* ── Sidebar nav ───────────────────────────────────────────────────── *@ + + + Schedule + + Security + + + + Streaming + @if (_streamingRestartRequired || _genHlsRestartRequired) + { + + + + } + + + + + + HDHomeRun + @if (_hdhrRestartRequired) + { + + + + } + + + + Endpoint URLs + + Integrations + + Observability + + System + + + + @* ── Section content ───────────────────────────────────────────────── *@ +
+ + @* ════════════════════════════════════════════ SCHEDULE ══════════ *@ + @if (_section == "schedule") + { + + Schedule + + How often M3Undle fetches a fresh lineup from your provider. Individual profiles can + override this. If your provider updates channels infrequently, every 6–12 hours is plenty. - @if (_nextRefreshUtc.HasValue) + @if (_loadingRefreshSettings) { - - Next scheduled refresh: @_nextRefreshUtc.Value.ToLocalTime().ToString("ddd d MMM, h:mm tt") - + } - else if (_refreshScheduleKind == "manual") + else { - - Automatic refresh is disabled. Use the manual refresh button on the Dashboard. - +
+ +
+ + +
+ + If M3Undle was offline during a scheduled refresh, it runs one immediately on startup. + Disable this if you manage refresh timing with an external scheduler. + + @if (_nextRefreshUtc.HasValue) + { + +  Next scheduled refresh: @_nextRefreshUtc.Value.ToLocalTime().ToString("ddd d MMM, h:mm tt") + + } + else if (_refreshScheduleKind == "manual") + { + +  Automatic refresh is disabled. Use the manual refresh button on the Dashboard. + + } +
+ + @if (!string.IsNullOrWhiteSpace(_refreshError)) + { + @_refreshError + } + @if (!string.IsNullOrWhiteSpace(_refreshSuccess)) + { + @_refreshSuccess + } + +
+ + @(_savingRefreshSettings ? "Applying..." : "Apply") + +
} +
+ } + + @* ════════════════════════════════════════════ SECURITY ══════════ *@ + @if (_section == "security") + { + + Security + + Controls who can reach the M3Undle UI and its media endpoints. + - @if (!string.IsNullOrWhiteSpace(_refreshError)) + @* UI Auth (read-only status) *@ + UI Authentication + @if (_uiAuthEnabled) { - - @_refreshError + + UI authentication is enabled. Users must log in to access the interface. } - - @if (!string.IsNullOrWhiteSpace(_refreshSuccess)) + else { - - @_refreshSuccess + + UI authentication is disabled — the interface is open to anyone who can + reach this server. Configure ASP.NET Core Identity to enable it. } - - @(_savingRefreshSettings ? "Saving..." : "Save Refresh Settings") - - } - - - - - - UI Security - - @if (_uiAuthEnabled) - { - - UI Authentication is enabled. - - } - else - { - - UI authentication is disabled. The UI is open on your network. - - } - - + - - - Endpoint Security - - Controls M3U/XMLTV/stream/HDHR endpoint authentication. - + @* Endpoint Credentials *@ + + Endpoint Credentials + @if (!_loadingEndpointSettings) + { + @if (_endpointHasCredential) + { + Configured + } + else + { + Not configured + } + } + + + One credential covers all media endpoints: M3U, XMLTV, streams, and Xtream-compatible + players (TiviMate, IPTV Smarters). Leave enforcement disabled only if M3Undle is not + reachable outside your home network. + - @if (_loadingEndpointSettings) - { - - } - else - { - - - - - - - - - @if (!string.IsNullOrWhiteSpace(_activeProfileId)) + @if (_loadingEndpointSettings) { - - Bound profile: @(_activeProfileName ?? _activeProfileId) - + } - - @if (!string.IsNullOrWhiteSpace(_endpointError)) + else { - - @_endpointError - - } + + + + + + + + + + @* Xtream connection block — shown when enforcement is on, credential is set, and Xtream protocol is on *@ + @if (_endpointEnabled && _endpointHasCredential && _xtreamEnabled) + { + + Xtream connection details + + + Server + @Nav.BaseUri.TrimEnd('/') + + + Username + @_endpointUsername + + + Password + ●●●● (set) + + + + } - @if (!string.IsNullOrWhiteSpace(_endpointSuccess)) - { - - @_endpointSuccess - - } + @if (!string.IsNullOrWhiteSpace(_activeProfileId)) + { + + Bound to profile: @(_activeProfileName ?? _activeProfileId) + + } - - - @_saveButtonText - + +
+ + When enabled, M3U, XMLTV, stream, and Xtream endpoints require the credential above. + +
+ + @* Advanced *@ +
+ + Advanced options + +
+ +
+ + + Identifies M3Undle to players that support HDHomeRun tuner reuse (Channels DVR, + some Plex setups). When a player retunes to the same channel it reattaches to the + existing stream instead of opening a new connection. Only change this if you run + multiple M3Undle instances and need unique IDs. + + + + + Disable only if you have no Xtream-compatible clients. When disabled, all Xtream + routes return 404 regardless of credential state. + +
+
- @if (_endpointHasCredential) + @if (!string.IsNullOrWhiteSpace(_endpointError)) { - Credential configured + @_endpointError } - else + @if (!string.IsNullOrWhiteSpace(_endpointSuccess)) { - No credential configured + @_endpointSuccess } -
- } -
-
- - - Observability - - Controls Prometheus metrics scraping and metrics token access. - - - @if (_loadingObservabilitySettings) - { - - } - else - { - @if (_metricsMode == MetricsAccessModes.Public) - { - - Metrics are public. Anyone who can reach this server can scrape runtime details. - + + + @(_savingEndpointSettings ? "Applying..." : "Apply") + + } + + } - - - - Local only (loopback + CIDRs) - Token (Bearer auth required) - Public — no authentication - Disabled (no endpoint) - - - - - Adds channel_name/channel_id labels to stream metrics. Avoid on large lineups — significantly increases Prometheus cardinality. + @* ════════════════════════════════════════════ STREAMING ═════════ *@ + @if (_section == "streaming") + { + @* ── Stream Proxy ─────────────────────────────────────────── *@ + + Stream Proxy + + Controls how M3Undle relays live streams to your media player. New viewer sessions + inherit these settings immediately; the overall limits take effect after a restart. - - - @if (!string.IsNullOrWhiteSpace(_observabilityError)) + @if (_loadingStreamingSettings) { - - @_observabilityError - + } - - @if (!string.IsNullOrWhiteSpace(_observabilitySuccess)) + else { - - @_observabilitySuccess - - } - - - @(_savingObservabilitySettings ? "Saving..." : "Save Observability Settings") - - - - Scrape endpoint: @_metricsPath - + @if (_streamingRestartRequired) + { + + Saved settings are not yet active. Restart M3Undle to apply them. + + } + @if (_activeStreamCount > 0) + { + + @_activeStreamCount active stream@(_activeStreamCount == 1 ? "" : "s") currently playing. + Restarting will disconnect all active viewers. + + } - + - Metrics Tokens + Session Limits + + Hard cap on concurrent viewers. When reached, new tune requests receive a "tuner busy" + response. Match this to your provider's connection limit — exceeding it will get + streams cut off at the source. + + + +
+ + + + +
+
+ +
+ + + + +
+
+
+ + @* Advanced *@ + + Advanced options + + +
+ Buffering + + Memory M3Undle uses to smooth over brief provider hiccups. The defaults work well + for most setups. Raise buffer sizes only if viewers report stuttering on otherwise + reliable networks. + + + +
+ + + + +
+
+ +
+ + + + +
+
+ +
+ + + + +
+
+ +
+ + + + +
+
+
+ + Reconnect Behaviour + + How M3Undle responds when the upstream provider goes quiet or drops the connection. + Increase timeouts if your provider occasionally pauses during live broadcasts. + + + +
+ + + + +
+
+ +
+ + + + +
+
+ +
+ + + + +
+
+
+ + @if (_streamingRestartRequired && _appliedStreamingSettings is not null) + { + + + Currently running (before restart) + + + + Stream Proxy + @(_appliedStreamingSettings.StreamingEnabled ? "Enabled" : "Disabled") + + + Max Streams + @_appliedStreamingSettings.MaxConcurrentSessions + + + Disconnect Grace + @_appliedStreamingSettings.IdleGraceSeconds s + + + Buffer / Stream + @FormatMiB(_appliedStreamingSettings.BufferMaxBytesPerSession) MiB + + + Total Buffer + @FormatMiB(_appliedStreamingSettings.BufferMaxBytesHardCap) MiB + + + Stall Timeout + @_appliedStreamingSettings.ReconnectReadStallTimeoutSeconds s + + + } +
+
- @if (!string.IsNullOrWhiteSpace(_newMetricsToken)) - { - - - New token for @_newMetricsTokenCreatedName: @_newMetricsToken - - - - } + @if (!string.IsNullOrWhiteSpace(_streamingError)) + { + @_streamingError + } + @if (!string.IsNullOrWhiteSpace(_streamingSuccess)) + { + @_streamingSuccess + } - - - - - - - - - - @(_creatingMetricsToken ? "Creating..." : "Generate") + + + @(_savingStreamingSettings ? "Applying..." : "Apply") - - + @if (_streamingRestartRequired) + { + + @(_restartingApplication ? "Restarting..." : "Restart M3Undle") + + } + + } +
+ + @* ── Browser Playback ──────────────────────────────────────── *@ + + Browser Playback + + When a browser or Electron client requests a stream that is only available as MPEG-TS, + M3Undle uses FFmpeg to transcode it to HLS on the fly so the browser can play it natively. + Requires FFmpeg to be installed on the server. Changes take effect after a restart. + - @if (_metricsTokens.Count == 0) + @if (_loadingGenHlsSettings) { - No metrics tokens configured. + } else { - - - Name - Scope - Created - Last Used - Expires - - - - @context.Name - @context.Scope - @context.CreatedUtc.ToLocalTime().ToString("g") - @(context.LastUsedUtc?.ToLocalTime().ToString("g") ?? "Never") - @(context.ExpiresUtc?.ToLocalTime().ToString("g") ?? "Never") - - - - - - } - } - -
- - - - HDHomeRun Emulation - - M3Undle emulates an HDHomeRun tuner so HDHR-compatible apps (NextPVR, Plex, Channels DVR, etc.) can discover and tune channels. - Changes require a restart. - - - @if (_loadingHdhrSettings) - { - - } - else - { - @if (_hdhrDisabledByEnv) - { - - HDHomeRun emulation is disabled by the M3UNDLE_HDHR_ENABLED=false environment variable. - Remove the variable and restart to manage HDHR settings here. - - } - - @if (_hdhrRestartRequired) - { - - HDHR settings have been saved but are not active yet. Restart M3Undle to apply them. - - } - - - - Effective Tuner Count - @_hdhrEffectiveTunerCount - - - Stream Limit - - @if (_hdhrIsStreamLimitEnforced) - { - Enforced - } - else + @if (!_genHlsFfmpegAvailable) + { + + FFmpeg not found — browser playback of TS-only streams is unavailable. + @if (!string.IsNullOrWhiteSpace(_genHlsFfmpegUnavailableReason)) { - Unlimited (no provider limit or override set) +
@_genHlsFfmpegUnavailableReason } -
-
- - Provider Stream Limit - @(_hdhrProviderLimit?.ToString() ?? "Not set") - -
+
Install FFmpeg and restart, or set a custom path in Advanced options below. + + } + else if (_genHlsEffectivelyEnabled) + { + + FFmpeg is available. Browser playback is active. + + } + else + { + + Generated HLS is disabled. Browser clients will not receive HLS for TS-only streams. + + } - + @if (_genHlsRestartRequired) + { + + Browser Playback settings have been saved but are not yet active. Restart M3Undle to apply them. + + } - - -
- - - - -
-
- -
- - - - -
-
- -
- + + @* Advanced *@ + + Advanced options + + +
+ - - - + Placeholder="ffmpeg" + HelperText="@($"Leave blank to use 'ffmpeg' from PATH. Active: {_genHlsConfiguredFfmpegPath}")" + Style="max-width:420px;" /> + + Set an absolute path only if FFmpeg is installed in a location not on your system PATH. + Most users leave this blank. +
- - +
- Discovery Protocols - - - - - - - - - - - + @if (!string.IsNullOrWhiteSpace(_genHlsError)) + { + @_genHlsError + } + @if (!string.IsNullOrWhiteSpace(_genHlsSuccess)) + { + @_genHlsSuccess + } - @if (!string.IsNullOrWhiteSpace(_hdhrError)) - { - - @_hdhrError - + + + @(_savingGenHlsSettings ? "Applying..." : "Apply") + + @if (_genHlsRestartRequired) + { + + @(_restartingApplication ? "Restarting..." : "Restart M3Undle") + + } + } + + } + + @* ════════════════════════════════════════════ HDHR ══════════════ *@ + @if (_section == "hdhr") + { + + HDHomeRun Emulation + + M3Undle emulates an HDHomeRun network tuner so apps like Plex, Channels DVR, and + NextPVR can discover and tune channels via the HDHR protocol. Changes take effect + after a restart. + - @if (!string.IsNullOrWhiteSpace(_hdhrSuccess)) + @if (_loadingHdhrSettings) { - - @_hdhrSuccess - + } + else + { + @if (_hdhrDisabledByEnv) + { + + HDHomeRun emulation is disabled by the M3UNDLE_HDHR_ENABLED=false + environment variable. Remove the variable and restart to manage settings here. + + } + @if (_hdhrRestartRequired) + { + + Saved settings are not yet active. Restart M3Undle to apply them. + + } - - - @(_savingHdhrSettings ? "Saving..." : "Save HDHR Settings") + + + Effective Tuner Count + @_hdhrEffectiveTunerCount + + + Stream Limit + @(_hdhrIsStreamLimitEnforced ? "Enforced" : "Unlimited (no provider limit or override set)") + + + Provider Stream Limit + @(_hdhrProviderLimit?.ToString() ?? "Not set") + + + + + + + +
+ + + + +
+
+ +
+ + + + +
+
+
+ + Discovery Protocols + + How M3Undle announces itself on your network. Discovery (master) must be enabled for + SSDP and SiliconDust to work. Disable all if you add the device URL to your player manually. + + + + + + + + + + + Network Access + + Restricts all HDHR access — discovery, lineup, and stream tuning — to these networks. + Loopback is always allowed. Leave blank to allow all connections (only safe on a trusted LAN). + + + + @* Advanced *@ + + Advanced options + +
+
+ + + + +
- @if (_hdhrRestartRequired) + + + Enables the SiliconDust-specific UDP broadcast that some older HDHR clients rely on. + Leave enabled unless you have a specific reason to disable it. + + + @if (_hdhrRestartRequired && _appliedHdhrSettings is not null) + { + + + Currently running (before restart) + + + + Emulation + @(_appliedHdhrSettings.Enabled ? "Enabled" : "Disabled") + + + Tuner Count + @_appliedHdhrSettings.EffectiveTunerCount + + + Friendly Name + @_appliedHdhrSettings.ResolvedFriendlyName + + + Base URL + @_appliedHdhrSettings.ResolvedBaseUrl + + + Discovery + @(_appliedHdhrSettings.DiscoveryEnabled ? "On" : "Off") + + + SSDP + @(_appliedHdhrSettings.SsdpEnabled ? "On" : "Off") + + + } +
+
+ + @if (!string.IsNullOrWhiteSpace(_hdhrError)) { - - @(_restartingApplication ? "Restarting..." : "Restart M3Undle") - + @_hdhrError + } + @if (!string.IsNullOrWhiteSpace(_hdhrSuccess)) + { + @_hdhrSuccess } -
- + + + @(_savingHdhrSettings ? "Applying..." : "Apply") + + @if (_hdhrRestartRequired) + { + + @(_restartingApplication ? "Restarting..." : "Restart M3Undle") + + } + + } +
+ } - - Active configuration — requires restart to update + @* ════════════════════════════════════════════ ENDPOINT URLs ═════ *@ + @if (_section == "endpoints") + { + + Endpoint URLs + + Configure alternate base URLs so the copy buttons on the Dashboard and HDHomeRun pages + can offer Docker-network or external variants. Set these via environment variables; + the values shown here are read-only. - - - HDHR Emulation - @(_appliedHdhrSettings?.Enabled == true ? "Enabled" : "Disabled") - - - Tuner Count - @_appliedHdhrSettings?.EffectiveTunerCount - - - Friendly Name - @_appliedHdhrSettings?.ResolvedFriendlyName - - - Base URL - @_appliedHdhrSettings?.ResolvedBaseUrl - - - Discovery - @(_appliedHdhrSettings?.DiscoveryEnabled == true ? "On" : "Off") - - - SSDP - @(_appliedHdhrSettings?.SsdpEnabled == true ? "On" : "Off") - - - SiliconDust - @(_appliedHdhrSettings?.SiliconDustDiscoveryEnabled == true ? "On" : "Off") - - - } - - + @* Host / Public URL *@ + Host / Public Base URL + + Override the base URL derived from the browser for copy buttons. Useful if M3Undle runs + behind a port mapping and the browser URL is not what clients should use. + + @{ + var publicUrl = EndpointUrlService.GetPublicBaseUrl(); + } + @if (publicUrl is not null) + { + + @publicUrl + from env + + } + else + { + + Not configured — M3UNDLE_PUBLIC_BASE_URL not set. + + } - - - Stream Proxy - - Controls how M3Undle relays live streams to your media player. Changes are saved immediately but only take effect after restarting the app. - + - @if (_loadingStreamingSettings) - { - - } - else - { - @if (_streamingRestartRequired) + @* Docker URL *@ + Docker Base URL + + The URL other containers on the same Docker network should use to reach M3Undle. + Typically the Compose service name: http://m3undle:8080. + + @{ + var dockerUrl = EndpointUrlService.GetDockerBaseUrl(); + } + @if (dockerUrl is not null) { - - Stream settings have been saved but are not active yet. Restart M3Undle to apply them. - + + @dockerUrl + from env + } - - @if (_activeStreamCount > 0) + else { - - @_activeStreamCount active stream@(_activeStreamCount == 1 ? "" : "s") currently playing. - Restarting will disconnect all active viewers. - + + Not configured — M3UNDLE_DOCKER_BASE_URL not set. + } - - - Session Limits - - -
- - - - -
-
- -
- - - - -
-
- -
- - - - -
-
-
+ - Buffering - - -
- - - - -
-
- -
- - - - -
-
- -
- - - - -
-
-
+ @* External URL *@ + External / Reverse Proxy URL + + The publicly reachable URL when M3Undle is behind a reverse proxy. + Example: https://tv.example.com. + + @{ + var externalUrl = EndpointUrlService.GetExternalBaseUrl(); + } + @if (externalUrl is not null) + { + + @externalUrl + from env + + } + else + { + + Not configured — M3UNDLE_EXTERNAL_BASE_URL not set. + + } - Reconnect Behaviour - - -
- - - - -
-
- -
- - - - -
+ + + @* Container detection *@ + Container Detection + + + Container detected + + @(EndpointUrlService.IsContainerDetected ? "Yes" : "No") + - -
- - - - -
+ + Detected hostname + @EndpointUrlService.DetectedHostname
- @if (!string.IsNullOrWhiteSpace(_streamingError)) + @if (EndpointUrlService.IsContainerDetected && EndpointUrlService.IsHostnameLikelyContainerId) { - - @_streamingError + + Container detected, but the hostname (@EndpointUrlService.DetectedHostname) looks like a + randomly generated container ID rather than a service name. Other containers may not be + able to reach M3Undle using this hostname. Set M3UNDLE_DOCKER_BASE_URL to + specify the Docker Compose service name (e.g. http://m3undle:8080). } - - @if (!string.IsNullOrWhiteSpace(_streamingSuccess)) + else if (EndpointUrlService.IsContainerDetected && dockerUrl is null) { - - @_streamingSuccess + + Container detected with hostname @EndpointUrlService.DetectedHostname. + If this is the Docker Compose service name and other containers should use it, + set M3UNDLE_DOCKER_BASE_URL=http://@EndpointUrlService.DetectedHostname:8080 + to enable Docker URL copy options on the Dashboard and HDHomeRun pages. } - - - @(_savingStreamingSettings ? "Saving..." : "Save Stream Settings") - + + URL overrides are set via environment variables on the host or in your + docker-compose.yml. Changes take effect after restarting M3Undle. + +
+ } - @if (_streamingRestartRequired) - { - - @(_restartingApplication ? "Restarting..." : "Restart M3Undle") - - } + @* ════════════════════════════════════════════ INTEGRATIONS ══════ *@ + @if (_section == "integrations") + { + + + Downstream Integrations + + Add Integration + - - - - - Active configuration — requires restart to update + + After a successful lineup refresh, M3Undle can notify connected media servers so they pick + up new channels and guide data automatically. Supported targets: Jellyfin, Emby, and generic + webhooks. - - - Stream Proxy - @(_appliedStreamingSettings?.StreamingEnabled == true ? "Enabled" : "Disabled") - - - Max Simultaneous Streams - @_appliedStreamingSettings?.MaxConcurrentSessions - - - Disconnect Grace - @_appliedStreamingSettings?.IdleGraceSeconds s - - - Max Idle Time - @_appliedStreamingSettings?.IdleGraceHardCapSeconds s - - - Buffer per Stream - @FormatMiB(_appliedStreamingSettings?.BufferMaxBytesPerSession ?? 0) MiB - - - Total Buffer Limit - @FormatMiB(_appliedStreamingSettings?.BufferMaxBytesHardCap ?? 0) MiB - - - Download Chunk Size - @((_appliedStreamingSettings?.BufferReadChunkSizeBytes ?? 0) / 1024) KiB - - - Stall Detection Timeout - @_appliedStreamingSettings?.ReconnectReadStallTimeoutSeconds s - - - Reconnect Window - @_appliedStreamingSettings?.ReconnectOutageWindowSeconds s - - - Connection Timeout - @_appliedStreamingSettings?.ReconnectConnectTimeoutSeconds s - - - } - -
- - - - Browser Playback - - Controls HLS generation for browser and Electron clients. When a browser requests a stream - that is only available as MPEG-TS, M3Undle uses FFmpeg to convert it to HLS on the fly. - Changes require a restart. - - - @if (_loadingGenHlsSettings) - { - - } - else - { - @if (!_genHlsFfmpegAvailable) + @if (_loadingIntegrations) { - - FFmpeg not found — browser playback of TS-only streams is unavailable. - @if (!string.IsNullOrWhiteSpace(_genHlsFfmpegUnavailableReason)) - { -
@_genHlsFfmpegUnavailableReason - } -
Install FFmpeg and restart, or set the path below. -
+ } - else if (_genHlsEffectivelyEnabled) + else if (_integrations.Count == 0) { - - FFmpeg is available. Browser playback is active. - + + No integrations configured. Add one to enable automatic downstream notifications. + } else { - - Generated HLS is disabled. Browser clients will not receive HLS for TS-only streams. - + + + Name + Type + URL + Triggers + Status + + + + + + @if (!context.Enabled) + { + Disabled + } + @context.Name + + + @IntegrationKindLabel(context.Kind) + + @context.BaseUrl + + + + @if (context.TriggerOnLineupUpdate) + { + Lineup + } + @if (context.TriggerOnGuideUpdate) + { + Guide + } + + + + @if (!string.IsNullOrWhiteSpace(context.LastNotifyError)) + { + + Error + + } + else if (context.LastNotifiedUtc.HasValue) + { + + OK @context.LastNotifiedUtc.Value.ToLocalTime().ToString("d MMM h:mm tt") + + } + else + { + Never notified + } + + + + + + + + + } +
+ } + + @* ════════════════════════════════════════════ OBSERVABILITY ═════ *@ + @if (_section == "observability") + { + + Observability + + Exposes a Prometheus-compatible metrics endpoint so you can scrape runtime statistics + into Grafana or any other monitoring stack. Access can be restricted by IP range or bearer token. + - @if (_genHlsRestartRequired) + @if (_loadingObservabilitySettings) { - - Browser Playback settings have been saved but are not active yet. Restart M3Undle to apply them. - + } + else + { + @if (_metricsMode == MetricsAccessModes.Public) + { + + Metrics are public — anyone who can reach this server can scrape runtime details + including stream activity and channel counts. + + } - + + + + + + Local only (loopback + CIDRs) + Token (Bearer auth required) + Public — no authentication + Disabled (no endpoint) + + + + + @if (_metricsMode == MetricsAccessModes.LocalOnly) + { + + } - - -
- - - - + @* Advanced *@ + + Advanced options + + +
+ + + Adds channel_name and channel_id labels to stream metrics. + Useful for per-channel Grafana dashboards, but each unique channel multiplies the + number of Prometheus time series. Avoid on lineups larger than a few hundred channels. +
- - +
- @if (!string.IsNullOrWhiteSpace(_genHlsError)) - { - - @_genHlsError - - } - - @if (!string.IsNullOrWhiteSpace(_genHlsSuccess)) - { - - @_genHlsSuccess - - } + @if (!string.IsNullOrWhiteSpace(_observabilityError)) + { + @_observabilityError + } + @if (!string.IsNullOrWhiteSpace(_observabilitySuccess)) + { + @_observabilitySuccess + } - - - @(_savingGenHlsSettings ? "Saving..." : "Save Browser Playback Settings") + + @(_savingObservabilitySettings ? "Applying..." : "Apply") - @if (_genHlsRestartRequired) + + + Metrics Tokens + + Bearer tokens for Prometheus scrapers when access mode is set to Token. + Generate one token per scraper so you can revoke individual scrapers independently. + + + @if (!string.IsNullOrWhiteSpace(_newMetricsToken)) { - - @(_restartingApplication ? "Restarting..." : "Restart M3Undle") - - } - - } - - - - - - - Downstream Integrations - - Add Integration - - - - After a successful refresh, M3Undle can automatically notify connected media servers so they pick up new channels and guide data. - - - @if (_loadingIntegrations) - { - - } - else if (_integrations.Count == 0) - { - - No integrations configured. Add one to enable automatic downstream notifications. - - } - else - { - - - Name - Type - URL - Triggers - Status - - - - - - @if (!context.Enabled) - { - Disabled - } - @context.Name - - - @IntegrationKindLabel(context.Kind) - - @context.BaseUrl - - - - @if (context.TriggerOnLineupUpdate) - { - Lineup - } - @if (context.TriggerOnGuideUpdate) - { - Guide - } - - - - @if (!string.IsNullOrWhiteSpace(context.LastNotifyError)) - { - - Error - - } - else if (context.LastNotifiedUtc.HasValue) - { - - OK @context.LastNotifiedUtc.Value.ToLocalTime().ToString("d MMM h:mm tt") - - } - else - { - Never notified - } - - - - - + + + Token for @_newMetricsTokenCreatedName: @_newMetricsToken + - - - - } - - + + } - - - Event History - - System events (provider failures, lineup changes, login attempts) are kept for this many days before being automatically removed. - + + + + + + + + + + @(_creatingMetricsToken ? "Creating..." : "Generate") + + + + + @if (_metricsTokens.Count == 0) + { + No tokens configured. + } + else + { + + + Name + Scope + Created + Last Used + Expires + + + + @context.Name + @context.Scope + @context.CreatedUtc.ToLocalTime().ToString("g") + @(context.LastUsedUtc?.ToLocalTime().ToString("g") ?? "Never") + @(context.ExpiresUtc?.ToLocalTime().ToString("g") ?? "Never") + + + + + + } + } + + } - @if (_loadingEventSettings) - { - - } - else - { - + @* ════════════════════════════════════════════ SYSTEM ════════════ *@ + @if (_section == "system") + { + + System + + Low-level operational settings. Most users never need to change these from their defaults. + - @if (!string.IsNullOrWhiteSpace(_eventSettingsError)) + Event History + + System events — provider failures, lineup changes, login attempts — are kept for this many + days before being automatically purged. Raising this value increases database size; lowering + it reduces the history available for diagnostics. + + + @if (_loadingEventSettings) { - - @_eventSettingsError - + } - @if (!string.IsNullOrWhiteSpace(_eventSettingsSuccess)) + else { - - @_eventSettingsSuccess - + + + @if (!string.IsNullOrWhiteSpace(_eventSettingsError)) + { + @_eventSettingsError + } + @if (!string.IsNullOrWhiteSpace(_eventSettingsSuccess)) + { + @_eventSettingsSuccess + } + + + @(_savingEventSettings ? "Applying..." : "Apply") + } + + } - - @(_savingEventSettings ? "Saving..." : "Save") - - } - - - +
+
@code { + private string _section = "schedule"; + + private void NavigateToSection(string section) + { + _section = section; + Nav.NavigateTo($"/settings?section={section}", replace: true); + } + + private bool _showSecurityAdvanced; + private bool _showStreamingAdvanced; + private bool _showBrowserPlaybackAdvanced; + private bool _showHdhrAdvanced; + private bool _showObservabilityAdvanced; + private bool _uiAuthEnabled; private bool _loadingEndpointSettings; private bool _savingEndpointSettings; private bool _endpointEnabled; private bool _endpointHasCredential; + private bool _xtreamEnabled = true; private string _endpointUsername = string.Empty; private string _endpointPassword = string.Empty; private string _virtualTunerId = "hdhr-main"; @@ -1094,6 +1430,7 @@ private int? _hdhrProviderLimit; private bool _hdhrIsStreamLimitEnforced; private string _hdhrResolvedBaseUrl = string.Empty; + private string _hdhrAllowedNetworks = string.Empty; private string? _hdhrError; private string? _hdhrSuccess; private bool _hdhrDisabledByEnv; @@ -1125,8 +1462,6 @@ private string? _genHlsError; private string? _genHlsSuccess; - private string _saveButtonText => _savingEndpointSettings ? "Saving..." : "Save Endpoint Security"; - private bool _loadingIntegrations; private List _integrations = []; @@ -1146,6 +1481,16 @@ protected override async Task OnInitializedAsync() { + var uri = new Uri(Nav.Uri); + var qs = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + if (qs.TryGetValue("section", out var sectionParam)) + { + var requested = sectionParam.ToString().ToLowerInvariant(); + if (requested is "schedule" or "security" or "streaming" or "hdhr" + or "endpoints" or "integrations" or "observability" or "system") + _section = requested; + } + _uiAuthEnabled = await SiteSettings.GetAuthenticationEnabledAsync(); await Task.WhenAll(LoadRefreshScheduleAsync(), LoadEndpointSecurityAsync(), LoadObservabilitySettingsAsync(), LoadStreamingSettingsAsync(), LoadHdhrSettingsAsync(), LoadGenHlsSettingsAsync(), LoadProfilesAsync(), LoadIntegrationsAsync(), LoadEventSettingsAsync()); } @@ -1434,6 +1779,7 @@ _endpointEnabled = settings.Enabled; _endpointUsername = settings.Username ?? string.Empty; _endpointHasCredential = settings.HasCredential; + _xtreamEnabled = settings.XtreamCompatibilityEnabled; _activeProfileId = settings.ActiveProfileId; _virtualTunerId = string.IsNullOrWhiteSpace(settings.VirtualTunerId) ? "hdhr-main" : settings.VirtualTunerId; } @@ -1545,7 +1891,8 @@ Username: string.IsNullOrWhiteSpace(_endpointUsername) ? null : _endpointUsername.Trim(), Password: string.IsNullOrWhiteSpace(_endpointPassword) ? null : _endpointPassword, ActiveProfileId: _activeProfileId, - VirtualTunerId: string.IsNullOrWhiteSpace(_virtualTunerId) ? "hdhr-main" : _virtualTunerId.Trim()), CancellationToken.None); + VirtualTunerId: string.IsNullOrWhiteSpace(_virtualTunerId) ? "hdhr-main" : _virtualTunerId.Trim(), + XtreamCompatibilityEnabled: _xtreamEnabled), CancellationToken.None); if (!result.Succeeded) { @@ -1556,10 +1903,11 @@ _endpointEnabled = result.Settings.Enabled; _endpointUsername = result.Settings.Username ?? string.Empty; _endpointHasCredential = result.Settings.HasCredential; + _xtreamEnabled = result.Settings.XtreamCompatibilityEnabled; _activeProfileId = result.Settings.ActiveProfileId; _virtualTunerId = string.IsNullOrWhiteSpace(result.Settings.VirtualTunerId) ? "hdhr-main" : result.Settings.VirtualTunerId; _endpointPassword = string.Empty; - _endpointSuccess = "Endpoint security settings saved."; + _endpointSuccess = "Endpoint credentials saved."; } catch (Exception ex) { @@ -1623,7 +1971,8 @@ AdvertisedBaseUrl: string.IsNullOrWhiteSpace(_hdhrAdvertisedBaseUrl) ? null : _hdhrAdvertisedBaseUrl.Trim(), DiscoveryEnabled: _hdhrDiscoveryEnabled, SsdpEnabled: _hdhrSsdpEnabled, - SiliconDustDiscoveryEnabled: _hdhrSiliconDustDiscoveryEnabled), CancellationToken.None); + SiliconDustDiscoveryEnabled: _hdhrSiliconDustDiscoveryEnabled, + AllowedNetworks: string.IsNullOrWhiteSpace(_hdhrAllowedNetworks) ? null : _hdhrAllowedNetworks.Trim()), CancellationToken.None); if (!result.Succeeded) { @@ -1663,6 +2012,7 @@ _hdhrProviderLimit = settings.ProviderTunerLimit; _hdhrIsStreamLimitEnforced = settings.IsStreamLimitEnforced; _hdhrResolvedBaseUrl = settings.ResolvedBaseUrl; + _hdhrAllowedNetworks = settings.AllowedNetworks ?? string.Empty; } private async Task LoadGenHlsSettingsAsync() diff --git a/src/M3Undle.Web/Components/Pages/StreamSessionDetailsDialog.razor b/src/M3Undle.Web/Components/Pages/StreamSessionDetailsDialog.razor new file mode 100644 index 0000000..5ba6417 --- /dev/null +++ b/src/M3Undle.Web/Components/Pages/StreamSessionDetailsDialog.razor @@ -0,0 +1,344 @@ +@using M3Undle.Web.Streaming.Configuration +@using M3Undle.Web.Streaming.Observability +@using Microsoft.Extensions.Options +@inject IStreamChannelHealthProfileService HealthProfileService +@inject IOptions ReconnectOptions + + + + + + + @Session.DisplayName + + + + + @if (_loading) + { + + } + + Health Profile + + @if (Session.HealthProfile is { } hp) + { + + @hp.ToString() + + } + else + { + Not yet derived + } + 24-hour observation window + + @if (!string.IsNullOrWhiteSpace(Session.HealthProfileReason)) + { + @Session.HealthProfileReason + } + else + { +
+ } + + @if (_evidence is { } ev) + { + Evidence (last 24 h) + + + + Upstream failures + @ev.UpstreamFailures + + + Recoveries + @ev.RecoveryResumes + + + Fallback recoveries + + @ev.FallbackRecoveryResumes + + + + Client aborts after recovery + + @ev.ClientAbortAfterRecovery + + + + Forced retunes + + @ev.ForcedRetunes + + + + TS sync loss + + @ev.TsSyncLoss + + + + Clean watch + @ev.CleanWatchDuration.TotalMinutes.ToString("F0") min (@ev.CleanWatchEvents sessions) + + + + + @if (ev.LastAdverseEventUtc.HasValue || ev.LastCleanWatchUtc.HasValue) + { + + @if (ev.LastAdverseEventUtc.HasValue) + { + + Last adverse event: @ev.LastAdverseEventUtc.Value.ToLocalTime().ToString("g") + + } + @if (ev.LastCleanWatchUtc.HasValue) + { + + Last clean watch: @ev.LastCleanWatchUtc.Value.ToLocalTime().ToString("g") + + } + + } + + @if (ev.CleanWatchDuration > TimeSpan.Zero) + { + var decayPct = Math.Min(100.0, ev.CleanWatchDuration.TotalMinutes / 30.0 * 100.0); + + Clean-watch toward decay: + + @((int)Math.Round(decayPct))% + + } + else + { +
+ } + + Health Trend (1-hour windows) + + + + @ev.Trend.Trend.ToString() + + + @if (!string.IsNullOrWhiteSpace(ev.Trend.Reason)) + { + @ev.Trend.Reason + } + @if (ev.Trend.Trend != StreamChannelHealthTrend.Unknown) + { + + + + + Last hour + Prior hour + + + + + Adverse events + @ev.Trend.RecentAdverseCount + @ev.Trend.ComparisonAdverseCount + + + Clean watch + @ev.Trend.RecentCleanWatchDuration.TotalMinutes.ToString("F0") min + @ev.Trend.ComparisonCleanWatchDuration.TotalMinutes.ToString("F0") min + + @if (ev.Trend.RecentProfile.HasValue && ev.Trend.ComparisonProfile.HasValue) + { + + Window profile + @ev.Trend.RecentProfile + @ev.Trend.ComparisonProfile + + } + + + @if (ev.Trend.RecentAdverseCount > 0 || ev.Trend.ComparisonAdverseCount > 0) + { + var maxAdverse = Math.Max(ev.Trend.RecentAdverseCount, ev.Trend.ComparisonAdverseCount); + var recentPct = maxAdverse > 0 ? (double)ev.Trend.RecentAdverseCount / maxAdverse * 100.0 : 0; + var compPct = maxAdverse > 0 ? (double)ev.Trend.ComparisonAdverseCount / maxAdverse * 100.0 : 0; + + + Last hr + + @ev.Trend.RecentAdverseCount + + + Prior hr + + @ev.Trend.ComparisonAdverseCount + + + } + @if (ev.Trend.CleanWatchSinceLastAdverse > TimeSpan.Zero) + { + + @ev.Trend.CleanWatchSinceLastAdverse.TotalMinutes.ToString("F0") min clean watch since last adverse event + + } + else + { +
+ } + } + else + { +
+ } + + Effective Recovery Policy + + + + Output hold limit + @ev.RecoveryPolicy.RecoveryOutputHoldLimit.TotalSeconds.ToString("F0") s + + + Safe-start search limit + @(ev.RecoveryPolicy.RecoverySafeStartSearchLimitBytes / 1024) KiB + + + Packet boundary fallback + @(ev.RecoveryPolicy.AllowPacketBoundaryRecoveryFallback ? "Allowed" : "Blocked") + + + Downstream retune + @(ev.RecoveryPolicy.RequireDownstreamRetune ? "Required" : "Not required") + + + + @if (!string.IsNullOrWhiteSpace(ev.RecoveryPolicy.DownstreamRetuneReason)) + { + + @ev.RecoveryPolicy.DownstreamRetuneReason + + } + else + { +
+ } + } + + Relay + + + + Provider relay policy + @(FormatRelayPolicy(Session.RelayPolicy)) + + + Selected relay mode + @FormatRelayMode(Session.RelayMode) + + + Selection basis + @(string.IsNullOrWhiteSpace(Session.LastRelayFallbackReason) ? "Startup decision" : "Fallback") + + + + @if (!string.IsNullOrWhiteSpace(Session.RelayDecisionReason)) + { + + @Session.RelayDecisionReason + + } + @if (!string.IsNullOrWhiteSpace(Session.LastRelayFallbackReason)) + { + + Fell back to direct relay: @Session.LastRelayFallbackReason + + } + + + + Session @Session.SessionId · started @Session.StartedUtc.ToLocalTime().ToString("g") + + + @* TODO: Session lifecycle (stop reason, controlled retune reason, replacement session evidence) + requires structured per-session stop data not yet persisted in stream_channel_health_events. + Add when session-end diagnostics are promoted to durable storage. *@ + + + Close + + + +@code { + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public StreamSessionSnapshot Session { get; set; } = null!; + + private StreamChannelHealthEvidence? _evidence; + private bool _loading = true; + + protected override async Task OnInitializedAsync() + { + try + { + _evidence = await HealthProfileService.GetEvidenceAsync( + Session.ProviderId, + Session.ProviderChannelId, + ReconnectOptions.Value); + } + finally + { + _loading = false; + } + } + + private void Close() => MudDialog.Close(); + + private static Color HealthProfileColor(StreamChannelHealthProfile profile) => profile switch + { + StreamChannelHealthProfile.Stable => Color.Success, + StreamChannelHealthProfile.Cautious => Color.Warning, + StreamChannelHealthProfile.Unstable => Color.Error, + _ => Color.Default, + }; + + private static Color TrendColor(StreamChannelHealthTrend trend) => trend switch + { + StreamChannelHealthTrend.Improving => Color.Success, + StreamChannelHealthTrend.Stable => Color.Primary, + StreamChannelHealthTrend.Worsening => Color.Error, + _ => Color.Default, + }; + + private static string TrendIcon(StreamChannelHealthTrend trend) => trend switch + { + StreamChannelHealthTrend.Improving => Icons.Material.Filled.TrendingUp, + StreamChannelHealthTrend.Stable => Icons.Material.Filled.TrendingFlat, + StreamChannelHealthTrend.Worsening => Icons.Material.Filled.TrendingDown, + _ => Icons.Material.Outlined.HelpOutline, + }; + + private static string FormatRelayMode(string relayMode) => relayMode switch + { + "Direct" => "Direct", + "FfmpegCleanRemux" => "Clean remux", + "FfmpegHlsToMpegTs" => "HLS→TS", + _ => relayMode, + }; + + private static string FormatRelayPolicy(string? policy) => policy switch + { + "auto" => "Auto", + "on" or "remux" => "On (always remux)", + "off" => "Off (always direct)", + null or "" => "—", + _ => policy, + }; +} diff --git a/src/M3Undle.Web/Components/Pages/Streams.razor b/src/M3Undle.Web/Components/Pages/Streams.razor index 3dd454f..962b2ac 100644 --- a/src/M3Undle.Web/Components/Pages/Streams.razor +++ b/src/M3Undle.Web/Components/Pages/Streams.razor @@ -2,28 +2,21 @@ @using M3Undle.Web.Streaming.Observability @using M3Undle.Web.Streaming.Models @inject StreamingRegistry Registry +@inject IDialogService DialogService @implements IDisposable Streams — M3Undle Stream Monitor - - @if (_sessions.Count > 0) - { - - @_sessions.Count active session@(_sessions.Count == 1 ? "" : "s"), @_clients.Count client@(_clients.Count == 1 ? "" : "s") - - } - Auto-refreshes every 3s - + Auto-refreshes every 3s -Active Sessions +Active Streams @if (_sessions.Count == 0) { - No active stream sessions. + No active streams. } else @@ -33,19 +26,60 @@ else Channel State + Relay + Health Clients + Bitrate Buffer Reconnects Running Last Data + - @context.DisplayName - @context.SessionId[..8] + + @context.DisplayName + + - @context.State + + + + + @FormatRelayMode(context.RelayMode) + + @if (!string.IsNullOrWhiteSpace(context.LastRelayFallbackReason)) + { + + + + } + + + @if (context.HealthProfile is { } hp) + { + + @if (context.RequiresDownstreamRetune) + { + + + + } + } + else + { + + + + } @if (context.InferredHlsSubscriberCount > 0) @@ -57,34 +91,47 @@ else } else { - @context.SubscriberCount + + @context.SubscriberCount + } + + + @FormatBitrate(context.UpstreamBytesPerSecond) + + - @if (context.BufferMaxBytes > 0) - { - var pct = (double)context.BufferUsedBytes / context.BufferMaxBytes * 100.0; - - @FormatBytes(context.BufferUsedBytes) / @FormatBytes(context.BufferMaxBytes) - } - else - { - - } + + @if (context.BufferMaxBytes > 0) + { + var pct = (double)context.BufferUsedBytes / context.BufferMaxBytes * 100.0; + + @FormatBytes(context.BufferUsedBytes) / @FormatBytes(context.BufferMaxBytes) + } + else + { + + } + - @if (context.ReconnectAttempts > 0) - { - @context.ReconnectAttempts - @if (context.LastFailureKind is not null) + + @if (context.ReconnectAttempts > 0) { - @context.LastFailureKind + + @if (context.LastFailureKind is not null) + { + @FormatFailureKind(context.LastFailureKind) + } } - } - else - { - 0 - } + else + { + 0 + } + @@ -106,6 +153,15 @@ else } + + + + + @@ -123,41 +179,75 @@ else - Route + Client IP - Sent - Queue + Transfer + Backlog Connected - - @context.RequestedRoute - @if (context.Transport == ClientTransport.GeneratedHls) - { - HLS (inferred) + + @{ + var clientSession = _sessions.FirstOrDefault(s => s.SessionId == context.SessionId); + var channelName = clientSession?.DisplayName; } + + @DetectClientType(context.UserAgent) + + + @(channelName ?? context.RequestedRoute) + @if (context.Transport == ClientTransport.GeneratedHls) + { + · HLS + } + - @(context.RemoteIp ?? "—") + + @(context.RemoteIp ?? "—") + - + @if (context.Transport == ClientTransport.GeneratedHls) { } else { - @FormatBytes(context.BytesSent) + var sessionUpstream = _sessions.FirstOrDefault(s => s.SessionId == context.SessionId)?.UpstreamBytesPerSecond; + var isSlow = context.BytesPerSecond is > 0 + && sessionUpstream is > 0 + && context.BytesPerSecond.Value < sessionUpstream.Value / 4 + && context.QueueDepth > 2; + var tooltipText = isSlow + ? $"Receiving {FormatBitrate(context.BytesPerSecond)} vs {FormatBitrate(sessionUpstream)} stream rate — client may be slow or stalled. Total sent: {FormatBytes(context.BytesSent)}" + : $"Total sent: {FormatBytes(context.BytesSent)}"; + + @if (context.BytesPerSecond.HasValue) + { + @FormatBitrate(context.BytesPerSecond) + @FormatBytes(context.BytesSent) total + } + else + { + @FormatBytes(context.BytesSent) + total + } + } - + @if (context.Transport == ClientTransport.GeneratedHls) { } else { - @context.QueueDepth + + + @(context.QueueDepth == 0 ? "0" : $"{context.QueueDepth} chunk{(context.QueueDepth == 1 ? "" : "s")}") + + } @@ -178,7 +268,9 @@ else Channel Final State + Relay Reconnects + Last Recovery Started @@ -186,13 +278,32 @@ else @context.DisplayName - @context.State + @if (context.LastFailureKind is not null) { - @context.LastFailureKind + @FormatFailureKind(context.LastFailureKind) } + + @FormatRelayMode(context.RelayMode) + @context.ReconnectAttempts + + @if (context.LastSafeStartKind is not null) + { + @context.LastSafeStartKind + @if (context.LastRecoveryOutputHeldMs is { } heldMs) + { + held @heldMs.ToString("F0") ms + } + } + else + { + + } + @context.StartedUtc.ToString("u") @@ -207,8 +318,45 @@ else private IReadOnlyList _clients = []; private IReadOnlyList _recentEnded = []; + private async Task OpenDetailsAsync(StreamSessionSnapshot session) + { + var options = new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = false, CloseButton = true }; + var parameters = new DialogParameters + { + { x => x.Session, session } + }; + await DialogService.ShowAsync(session.DisplayName, parameters, options); + } + private PeriodicTimer? _timer; private readonly CancellationTokenSource _cts = new(); + private int _lastFingerprint; + + protected override bool ShouldRender() + { + var fp = ComputeFingerprint(); + if (fp == _lastFingerprint) return false; + _lastFingerprint = fp; + return true; + } + + private int ComputeFingerprint() + { + var hc = new HashCode(); + foreach (var s in _sessions) + { + hc.Add(s.SessionId); hc.Add(s.State); hc.Add(s.ReconnectAttempts); + hc.Add(s.SubscriberCount); hc.Add(s.HealthProfile); hc.Add(s.RequiresDownstreamRetune); + hc.Add(s.RelayMode); hc.Add(s.UpstreamBytesPerSecond); hc.Add(s.BufferUsedBytes); + hc.Add(s.LastUpstreamByteUtc); hc.Add(s.LastFailureKind); + } + foreach (var c in _clients) + { + hc.Add(c.ClientId); hc.Add(c.BytesPerSecond); hc.Add(c.QueueDepth); hc.Add(c.BytesSent); + } + hc.Add(_recentEnded.Count); + return hc.ToHashCode(); + } protected override void OnInitialized() { @@ -224,11 +372,17 @@ else try { while (await timer.WaitForNextTickAsync(_cts.Token)) - await InvokeAsync(() => { Refresh(); StateHasChanged(); }); + await InvokeAsync(RefreshAndRender); } catch (OperationCanceledException) { } } + private void RefreshAndRender() + { + Refresh(); + StateHasChanged(); // ShouldRender() gates the actual re-render + } + private void Refresh() { _sessions = Registry.GetActiveSessions(); @@ -248,17 +402,166 @@ else SessionState.Live => Color.Success, SessionState.Connecting or SessionState.Initializing => Color.Info, SessionState.Reconnecting => Color.Warning, + SessionState.HoldingOutput => Color.Warning, SessionState.Faulted => Color.Error, _ => Color.Default, }; - private static Color BufferColor(double pct) => pct switch + private static string StateTooltip(StreamSessionSnapshot s) + { + var desc = s.State switch + { + SessionState.Live => "Stream is active and data is flowing.", + SessionState.Initializing => "Session is initializing.", + SessionState.Connecting => "Connecting to the upstream provider.", + SessionState.Reconnecting => "Upstream connection lost — attempting to reconnect.", + SessionState.HoldingOutput => "Holding output while scanning for a safe MPEG-TS restart point.", + SessionState.Faulted => "Session has faulted and will not recover.", + _ => s.State.ToString(), + }; + + var parts = new System.Text.StringBuilder(desc); + if (s.LastSafeStartKind is not null) + { + parts.Append($"\nLast recovery: {s.LastSafeStartKind}"); + if (s.LastRecoveryOutputHeldMs is { } heldMs) + parts.Append($", held {heldMs:F0} ms"); + if (s.LastRecoveryStartedUtc is { } ts) + parts.Append($" at {ts.ToLocalTime():HH:mm:ss}"); + } + + return parts.ToString(); + } + + private static string RelayTooltip(StreamSessionSnapshot s) + { + var desc = s.RelayMode switch + { + "Direct" => "Direct relay — bytes passed through unchanged.", + "FfmpegCleanRemux" => "Clean remux — FFmpeg re-muxes stream for clean MPEG-TS output.", + "FfmpegHlsToMpegTs" => "HLS→TS — upstream HLS repackaged as MPEG-TS.", + _ => s.RelayMode, + }; + var policy = s.RelayPolicy is { Length: > 0 } p ? $" Policy: {p}." : string.Empty; + return $"{desc}{policy} Click the details icon for decision reason."; + } + + private static string IpTooltip(string? remoteIp) + { + if (string.IsNullOrWhiteSpace(remoteIp)) + return "Remote IP address unknown."; + + var isPrivate = remoteIp.StartsWith("10.", StringComparison.Ordinal) + || remoteIp.StartsWith("192.168.", StringComparison.Ordinal) + || remoteIp.StartsWith("172.", StringComparison.Ordinal) + || remoteIp == "::1" + || remoteIp == "127.0.0.1"; + + var note = isPrivate + ? "\nPrivate/local address — may be a container, proxy, or media server fetching on behalf of a device." + : string.Empty; + + return $"{remoteIp}{note}"; + } + + private static string ClientTypeTooltip(string? userAgent, string requestedRoute) + { + var uaPart = string.IsNullOrWhiteSpace(userAgent) + ? "No User-Agent header — client type unknown." + : $"User-Agent: {userAgent}"; + var type = DetectClientType(userAgent); + var note = type is "VLC" or "FFmpeg" + ? "\nNote: media servers (Jellyfin, Emby, Plex) use VLC or FFmpeg internally to fetch streams. This may be a server-side connection, not a direct player." + : string.Empty; + return $"{uaPart}{note}\nRoute: {requestedRoute}"; + } + + private static Color HealthProfileColor(StreamChannelHealthProfile profile) => profile switch + { + StreamChannelHealthProfile.Stable => Color.Success, + StreamChannelHealthProfile.Cautious => Color.Warning, + StreamChannelHealthProfile.Unstable => Color.Error, + _ => Color.Default, + }; + + private static string HealthProfileTooltip(StreamChannelHealthProfile profile) => profile switch { - >= 90 => Color.Error, - >= 70 => Color.Warning, - _ => Color.Success, + StreamChannelHealthProfile.Stable => "No recent issues. Click the details icon for evidence.", + StreamChannelHealthProfile.Cautious => "Recent recovery events. Click the details icon for evidence.", + StreamChannelHealthProfile.Unstable => "Repeated failures detected. Click the details icon for evidence.", + _ => profile.ToString()!, }; + private static string FormatRelayMode(string relayMode) => relayMode switch + { + "Direct" => "Direct", + "FfmpegCleanRemux" => "Clean remux", + "FfmpegHlsToMpegTs" => "HLS→TS", + _ => relayMode, + }; + + private static string FormatFailureKind(string? kind) => kind switch + { + "TimeoutOrStall" => "Timeout/Stall", + "EndOfStream" => "End of stream", + "UpstreamAuth" => "Auth failure", + "UpstreamNotFound" => "Not found (404)", + "UpstreamServerError" => "Server error", + "UpstreamRateLimited" => "Rate limited", + "UpstreamProxyAuthRequired" => "Proxy auth required", + "Transport" => "Transport error", + "StartupFatal" => "Startup failure", + _ => kind ?? "Unknown", + }; + + private static string DetectClientType(string? userAgent) + { + if (string.IsNullOrWhiteSpace(userAgent)) return "Unknown"; + // Named apps — check before generic components they may use internally. + if (userAgent.Contains("TiviMate", StringComparison.OrdinalIgnoreCase)) return "TiviMate"; + if (userAgent.Contains("IPTV Smarters", StringComparison.OrdinalIgnoreCase) || + userAgent.Contains("IPTVSmarters", StringComparison.OrdinalIgnoreCase)) return "Smarters"; + if (userAgent.Contains("Jellyfin", StringComparison.OrdinalIgnoreCase)) return "Jellyfin"; + if (userAgent.Contains("Emby", StringComparison.OrdinalIgnoreCase)) return "Emby"; + if (userAgent.Contains("Plex", StringComparison.OrdinalIgnoreCase)) return "Plex"; + if (userAgent.Contains("Kodi", StringComparison.OrdinalIgnoreCase)) return "Kodi"; + if (userAgent.Contains("NextPVR", StringComparison.OrdinalIgnoreCase)) return "NextPVR"; + if (userAgent.Contains("Infuse", StringComparison.OrdinalIgnoreCase)) return "Infuse"; + if (userAgent.Contains("IPTVnator", StringComparison.OrdinalIgnoreCase)) return "IPTVnator"; + if (userAgent.Contains("Televizo", StringComparison.OrdinalIgnoreCase)) return "Televizo"; + if (userAgent.Contains("OTTNavigator", StringComparison.OrdinalIgnoreCase)) return "OTT Navigator"; + if (userAgent.Contains("Perfect Player", StringComparison.OrdinalIgnoreCase)) return "Perfect Player"; + if (userAgent.Contains("GSE", StringComparison.OrdinalIgnoreCase)) return "GSE Player"; + if (userAgent.Contains("MrMC", StringComparison.OrdinalIgnoreCase)) return "MrMC"; + // Generic media components — likely a media server's internal fetcher. + if (userAgent.Contains("VLC", StringComparison.OrdinalIgnoreCase)) return "VLC"; + // Lavf is the ffmpeg libavformat UA prefix used by Jellyfin/Emby LiveTV transcoder. + if (userAgent.StartsWith("Lavf", StringComparison.OrdinalIgnoreCase)) return "FFmpeg"; + if (userAgent.Contains("ffmpeg", StringComparison.OrdinalIgnoreCase)) return "FFmpeg"; + if (userAgent.Contains("mpv", StringComparison.OrdinalIgnoreCase)) return "mpv"; + if (userAgent.Contains("Chrome", StringComparison.OrdinalIgnoreCase) || + userAgent.Contains("Firefox", StringComparison.OrdinalIgnoreCase) || + userAgent.Contains("Safari", StringComparison.OrdinalIgnoreCase)) return "Browser"; + return "Unknown"; + } + + private static string FormatRate(long? bytesPerSecond) => bytesPerSecond switch + { + null or 0 => "—", + >= 1_048_576 => $"{bytesPerSecond.Value / 1_048_576.0:F1} MB/s", + >= 1_024 => $"{bytesPerSecond.Value / 1_024.0:F0} KB/s", + _ => $"{bytesPerSecond.Value} B/s", + }; + + private static string FormatBitrate(long? bytesPerSecond) + { + if (bytesPerSecond is null or 0) return "—"; + var bitsPerSec = bytesPerSecond.Value * 8; + return bitsPerSec >= 1_000_000 + ? $"{bitsPerSec / 1_000_000.0:F1} Mbps" + : $"{bitsPerSec / 1_000.0:F0} Kbps"; + } + private static string FormatBytes(long bytes) => bytes switch { >= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F1} GB", diff --git a/src/M3Undle.Web/Components/Shared/ChipFilterToggle.razor b/src/M3Undle.Web/Components/Shared/ChipFilterToggle.razor new file mode 100644 index 0000000..2d4e74a --- /dev/null +++ b/src/M3Undle.Web/Components/Shared/ChipFilterToggle.razor @@ -0,0 +1,20 @@ +@using Microsoft.AspNetCore.Components.Web + + + + @Label + + + +@code { + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter, EditorRequired] public string TooltipText { get; set; } = string.Empty; + [Parameter] public Color Color { get; set; } = Color.Default; + [Parameter] public bool IsActive { get; set; } + [Parameter] public EventCallback OnClick { get; set; } +} \ No newline at end of file diff --git a/src/M3Undle.Web/Components/Shared/StatusChip.razor b/src/M3Undle.Web/Components/Shared/StatusChip.razor new file mode 100644 index 0000000..7a979e3 --- /dev/null +++ b/src/M3Undle.Web/Components/Shared/StatusChip.razor @@ -0,0 +1,19 @@ + + @Label + + +@code { + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter] public Color Color { get; set; } = Color.Default; + [Parameter] public Variant Variant { get; set; } = Variant.Filled; + [Parameter] public Size Size { get; set; } = Size.Small; + [Parameter] public string? Icon { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter] public string? Style { get; set; } +} \ No newline at end of file diff --git a/src/M3Undle.Web/Components/Shared/TooltipChip.razor b/src/M3Undle.Web/Components/Shared/TooltipChip.razor new file mode 100644 index 0000000..e478192 --- /dev/null +++ b/src/M3Undle.Web/Components/Shared/TooltipChip.razor @@ -0,0 +1,49 @@ +@using Microsoft.AspNetCore.Components.Web + + + + @Label + + + +@code { + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter] public string? TooltipText { get; set; } + [Parameter] public Color Color { get; set; } = Color.Default; + [Parameter] public Variant Variant { get; set; } = Variant.Outlined; + [Parameter] public Size Size { get; set; } = Size.Small; + [Parameter] public string? Icon { get; set; } + [Parameter] public string? Style { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter] public Placement Placement { get; set; } = Placement.Top; + [Parameter] public bool Arrow { get; set; } = true; + [Parameter] public EventCallback OnClick { get; set; } + + private string ComputedStyle + { + get + { + var baseStyle = Style?.Trim() ?? string.Empty; + if (!OnClick.HasDelegate) + { + return baseStyle; + } + + if (string.IsNullOrEmpty(baseStyle)) + { + return "cursor:pointer;"; + } + + return baseStyle.EndsWith(';') + ? $"{baseStyle}cursor:pointer;" + : $"{baseStyle}; cursor:pointer;"; + } + } +} \ No newline at end of file diff --git a/src/M3Undle.Web/Components/_Imports.razor b/src/M3Undle.Web/Components/_Imports.razor index d697d01..caf9283 100644 --- a/src/M3Undle.Web/Components/_Imports.razor +++ b/src/M3Undle.Web/Components/_Imports.razor @@ -8,6 +8,7 @@ @using Microsoft.JSInterop @using M3Undle.Web @using M3Undle.Web.Components +@using M3Undle.Web.Components.Shared @using M3Undle.Web.Components.Layout @using MudBlazor diff --git a/src/M3Undle.Web/Contracts/ChannelFilterContracts.cs b/src/M3Undle.Web/Contracts/ChannelFilterContracts.cs index 9ef863a..07d8f74 100644 --- a/src/M3Undle.Web/Contracts/ChannelFilterContracts.cs +++ b/src/M3Undle.Web/Contracts/ChannelFilterContracts.cs @@ -236,6 +236,15 @@ public sealed class WhatsOnGroupDto public List Events { get; set; } = []; } +public sealed class MappedChannelPanelItem +{ + public int? ChannelNumber { get; set; } + public string DisplayName { get; set; } = string.Empty; + public bool IsLive { get; set; } + public string FilterId { get; set; } = string.Empty; + public string? OutputName { get; set; } +} + public sealed class WhatsOnThisWeekResponse { public string ProfileId { get; set; } = string.Empty; diff --git a/src/M3Undle.Web/Contracts/CustomGroupContracts.cs b/src/M3Undle.Web/Contracts/CustomGroupContracts.cs index c58035a..9f18c10 100644 --- a/src/M3Undle.Web/Contracts/CustomGroupContracts.cs +++ b/src/M3Undle.Web/Contracts/CustomGroupContracts.cs @@ -16,6 +16,7 @@ public sealed record CustomGroupDto public int ChannelCount { get; set; } public int SelectedChannelCount { get; set; } public int ProviderLinkCount { get; set; } + public HashSet LinkedProviderGroupIds { get; set; } = []; public DateTime CreatedUtc { get; set; } public DateTime UpdatedUtc { get; set; } } diff --git a/src/M3Undle.Web/Contracts/DashboardContracts.cs b/src/M3Undle.Web/Contracts/DashboardContracts.cs index b406b36..7322354 100644 --- a/src/M3Undle.Web/Contracts/DashboardContracts.cs +++ b/src/M3Undle.Web/Contracts/DashboardContracts.cs @@ -7,6 +7,12 @@ public enum ProfileHealthStatus NoOutput } +public sealed record DashboardProviderSummary( + string ProviderId, + string ProviderName, + int? MaxConcurrentStreams, + DateTime? ExpiresUtc); + public sealed class DashboardProfileSummary { public string ProfileId { get; set; } = string.Empty; @@ -14,9 +20,13 @@ public sealed class DashboardProfileSummary public string OutputName { get; set; } = string.Empty; public bool IsEnabled { get; set; } public bool IsActive { get; set; } + public bool IsPublished { get; set; } public DateTime? LastPublishedUtc { get; set; } public int LiveCount { get; set; } + public int MovieCount { get; set; } + public int SeriesCount { get; set; } public ProfileHealthStatus HealthStatus { get; set; } + public List Providers { get; set; } = []; } public sealed record ExpiringProviderWarning(string ProviderId, string ProviderName, DateTime ExpiresUtc); diff --git a/src/M3Undle.Web/Contracts/ProfilesContracts.cs b/src/M3Undle.Web/Contracts/ProfilesContracts.cs index 1276b9b..a418362 100644 --- a/src/M3Undle.Web/Contracts/ProfilesContracts.cs +++ b/src/M3Undle.Web/Contracts/ProfilesContracts.cs @@ -13,6 +13,8 @@ public sealed class ProfileProviderInfoDto public int Priority { get; set; } public bool Enabled { get; set; } public DateTime? PlaylistExpiresUtc { get; set; } + public string? LastFetchStatus { get; set; } + public string? LastFetchErrorSummary { get; set; } } public sealed class ProfilePageItemDto @@ -32,6 +34,7 @@ public sealed class ProfilePageItemDto public ProfileHealthStatus HealthStatus { get; set; } public int GroupsPendingReview { get; set; } public int ChannelsPendingReview { get; set; } + public int GroupsRemovedFromProvider { get; set; } } public sealed class ProfileSnapshotHistoryDto diff --git a/src/M3Undle.Web/Contracts/Providers/ProviderContracts.cs b/src/M3Undle.Web/Contracts/Providers/ProviderContracts.cs index 980a061..fcbd943 100644 --- a/src/M3Undle.Web/Contracts/Providers/ProviderContracts.cs +++ b/src/M3Undle.Web/Contracts/Providers/ProviderContracts.cs @@ -49,7 +49,7 @@ public sealed class ProviderDto public bool IncludeVod { get; set; } public bool IncludeSeries { get; set; } public bool ForceMpegTs { get; set; } - public string CleanRelayMode { get; set; } = "off"; + public string CleanRelayMode { get; set; } = "auto"; public List AssociatedProfileIds { get; set; } = []; public ProviderLastRefreshDto? LastRefresh { get; set; } public List LatestSnapshots { get; set; } = []; @@ -84,7 +84,7 @@ public sealed class CreateProviderRequest public bool IncludeVod { get; set; } public bool IncludeSeries { get; set; } public bool ForceMpegTs { get; set; } - public string CleanRelayMode { get; set; } = "off"; + public string CleanRelayMode { get; set; } = "auto"; [Range(1, 1800)] public int TimeoutSeconds { get; set; } = 120; @@ -116,7 +116,7 @@ public sealed class UpdateProviderRequest public bool IncludeVod { get; set; } public bool IncludeSeries { get; set; } public bool ForceMpegTs { get; set; } - public string CleanRelayMode { get; set; } = "off"; + public string CleanRelayMode { get; set; } = "auto"; [Range(1, 1800)] public int TimeoutSeconds { get; set; } = 120; diff --git a/src/M3Undle.Web/Data/ApplicationDbContext.cs b/src/M3Undle.Web/Data/ApplicationDbContext.cs index 75ad6da..7e8df03 100644 --- a/src/M3Undle.Web/Data/ApplicationDbContext.cs +++ b/src/M3Undle.Web/Data/ApplicationDbContext.cs @@ -34,6 +34,7 @@ public class ApplicationDbContext(DbContextOptions options public DbSet ProfileEventInterestRules => Set(); public DbSet SystemEvents => Set(); public DbSet MetricsTokens => Set(); + public DbSet StreamChannelHealthEvents => Set(); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/src/M3Undle.Web/Data/Configurations/ProviderConfiguration.cs b/src/M3Undle.Web/Data/Configurations/ProviderConfiguration.cs index d13a117..ed8b221 100644 --- a/src/M3Undle.Web/Data/Configurations/ProviderConfiguration.cs +++ b/src/M3Undle.Web/Data/Configurations/ProviderConfiguration.cs @@ -28,7 +28,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.XtreamUsername).HasColumnName("xtream_username"); builder.Property(x => x.XtreamEncryptedPassword).HasColumnName("xtream_encrypted_password"); builder.Property(x => x.ForceMpegTs).HasColumnName("force_mpegts").IsRequired().HasDefaultValue(false); - builder.Property(x => x.CleanRelayMode).HasColumnName("clean_relay_mode").IsRequired().HasDefaultValue("off"); + builder.Property(x => x.CleanRelayMode).HasColumnName("clean_relay_mode").IsRequired().HasDefaultValue("auto"); builder.Property(x => x.XtreamIncludeXmltv).HasColumnName("xtream_include_xmltv").IsRequired().HasDefaultValue(false); builder.Property(x => x.XtreamDetectedCapable).HasColumnName("xtream_detected_capable").IsRequired().HasDefaultValue(false); builder.Property(x => x.PlaylistExpiresUtc).HasColumnName("playlist_expires_utc"); diff --git a/src/M3Undle.Web/Data/Configurations/SiteSettingsConfiguration.cs b/src/M3Undle.Web/Data/Configurations/SiteSettingsConfiguration.cs index 1fd401e..e100ab2 100644 --- a/src/M3Undle.Web/Data/Configurations/SiteSettingsConfiguration.cs +++ b/src/M3Undle.Web/Data/Configurations/SiteSettingsConfiguration.cs @@ -100,6 +100,11 @@ public void Configure(EntityTypeBuilder builder) .HasDefaultValue(false); builder.Property(s => s.ObservabilityMetricsLocalAllowedCidrs) .HasColumnName("observability_metrics_local_allowed_cidrs"); + builder.Property(s => s.XtreamCompatibilityEnabled) + .HasColumnName("xtream_compatibility_enabled") + .HasDefaultValue(true); + builder.Property(s => s.HdhrAllowedNetworks) + .HasColumnName("hdhr_allowed_networks"); builder.HasData(new SiteSettings { @@ -135,6 +140,8 @@ public void Configure(EntityTypeBuilder builder) ObservabilityMetricsMode = "LocalOnly", ObservabilityMetricsEnableChannelLabels = false, ObservabilityMetricsLocalAllowedCidrs = null, + XtreamCompatibilityEnabled = true, + HdhrAllowedNetworks = null, }); } } diff --git a/src/M3Undle.Web/Data/Configurations/StreamChannelHealthEventConfiguration.cs b/src/M3Undle.Web/Data/Configurations/StreamChannelHealthEventConfiguration.cs new file mode 100644 index 0000000..6b52168 --- /dev/null +++ b/src/M3Undle.Web/Data/Configurations/StreamChannelHealthEventConfiguration.cs @@ -0,0 +1,44 @@ +using M3Undle.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace M3Undle.Web.Data.Configurations; + +public sealed class StreamChannelHealthEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("stream_channel_health_events"); + builder.HasKey(e => e.StreamChannelHealthEventId); + builder.Property(e => e.StreamChannelHealthEventId).HasColumnName("id"); + builder.Property(e => e.ProviderId).HasColumnName("provider_id").IsRequired(); + builder.Property(e => e.ProviderChannelId).HasColumnName("provider_channel_id").IsRequired(); + builder.Property(e => e.DisplayName).HasColumnName("display_name").IsRequired(); + builder.Property(e => e.EventKind).HasColumnName("event_kind").IsRequired(); + builder.Property(e => e.EventUtc).HasColumnName("event_utc"); + builder.Property(e => e.SessionId).HasColumnName("session_id"); + builder.Property(e => e.RelayMode).HasColumnName("relay_mode"); + builder.Property(e => e.RouteClassification).HasColumnName("route_classification"); + builder.Property(e => e.UpstreamFailureKind).HasColumnName("upstream_failure_kind"); + builder.Property(e => e.ReconnectAttempt).HasColumnName("reconnect_attempt"); + builder.Property(e => e.StallDurationMs).HasColumnName("stall_duration_ms"); + builder.Property(e => e.RecoveryDurationMs).HasColumnName("recovery_duration_ms"); + builder.Property(e => e.SafeStartWaitMs).HasColumnName("safe_start_wait_ms"); + builder.Property(e => e.OutputHeldMs).HasColumnName("output_held_ms"); + builder.Property(e => e.SafeStartKind).HasColumnName("safe_start_kind"); + builder.Property(e => e.ClientDisconnectReason).HasColumnName("client_disconnect_reason"); + builder.Property(e => e.ClientAbortAfterRecovery).HasColumnName("client_abort_after_recovery"); + builder.Property(e => e.ClientAbortAfterRecoveryDelayMs).HasColumnName("client_abort_after_recovery_delay_ms"); + builder.Property(e => e.ForcedRetune).HasColumnName("forced_retune"); + builder.Property(e => e.TsSyncLoss).HasColumnName("ts_sync_loss"); + builder.Property(e => e.BytesSuppressed).HasColumnName("bytes_suppressed"); + builder.Property(e => e.CleanWatchDurationMs).HasColumnName("clean_watch_duration_ms"); + + builder.HasIndex(e => new { e.ProviderId, e.ProviderChannelId, e.EventUtc }) + .HasDatabaseName("ix_stream_channel_health_events_provider_channel_event_utc"); + builder.HasIndex(e => new { e.EventKind, e.EventUtc }) + .HasDatabaseName("ix_stream_channel_health_events_event_kind_event_utc"); + builder.HasIndex(e => e.SessionId) + .HasDatabaseName("ix_stream_channel_health_events_session_id"); + } +} diff --git a/src/M3Undle.Web/Data/Entities/Provider.cs b/src/M3Undle.Web/Data/Entities/Provider.cs index 1f9b804..e7dc729 100644 --- a/src/M3Undle.Web/Data/Entities/Provider.cs +++ b/src/M3Undle.Web/Data/Entities/Provider.cs @@ -19,7 +19,7 @@ public sealed class Provider public bool ForceMpegTs { get; set; } - public string CleanRelayMode { get; set; } = "off"; + public string CleanRelayMode { get; set; } = "auto"; // Xtream Codes API provider fields public string? XtreamBaseUrl { get; set; } diff --git a/src/M3Undle.Web/Data/Entities/SiteSettings.cs b/src/M3Undle.Web/Data/Entities/SiteSettings.cs index 081c084..96a034f 100644 --- a/src/M3Undle.Web/Data/Entities/SiteSettings.cs +++ b/src/M3Undle.Web/Data/Entities/SiteSettings.cs @@ -38,4 +38,7 @@ public sealed class SiteSettings public string ObservabilityMetricsMode { get; set; } = "LocalOnly"; public bool ObservabilityMetricsEnableChannelLabels { get; set; } public string? ObservabilityMetricsLocalAllowedCidrs { get; set; } + + public bool XtreamCompatibilityEnabled { get; set; } = true; + public string? HdhrAllowedNetworks { get; set; } } diff --git a/src/M3Undle.Web/Data/Entities/StreamChannelHealthEvent.cs b/src/M3Undle.Web/Data/Entities/StreamChannelHealthEvent.cs new file mode 100644 index 0000000..c000198 --- /dev/null +++ b/src/M3Undle.Web/Data/Entities/StreamChannelHealthEvent.cs @@ -0,0 +1,28 @@ +namespace M3Undle.Web.Data.Entities; + +public sealed class StreamChannelHealthEvent +{ + public string StreamChannelHealthEventId { get; set; } = null!; + public string ProviderId { get; set; } = null!; + public string ProviderChannelId { get; set; } = null!; + public string DisplayName { get; set; } = null!; + public string EventKind { get; set; } = null!; + public DateTime EventUtc { get; set; } + public string? SessionId { get; set; } + public string? RelayMode { get; set; } + public string? RouteClassification { get; set; } + public string? UpstreamFailureKind { get; set; } + public int? ReconnectAttempt { get; set; } + public double? StallDurationMs { get; set; } + public double? RecoveryDurationMs { get; set; } + public double? SafeStartWaitMs { get; set; } + public double? OutputHeldMs { get; set; } + public string? SafeStartKind { get; set; } + public string? ClientDisconnectReason { get; set; } + public bool ClientAbortAfterRecovery { get; set; } + public double? ClientAbortAfterRecoveryDelayMs { get; set; } + public bool ForcedRetune { get; set; } + public bool TsSyncLoss { get; set; } + public long? BytesSuppressed { get; set; } + public double? CleanWatchDurationMs { get; set; } +} diff --git a/src/M3Undle.Web/Data/Migrations/20260224144400_Alpha1_InitialSchema.Designer.cs b/src/M3Undle.Web/Data/Migrations/20260224144400_Alpha1_InitialSchema.Designer.cs deleted file mode 100644 index 6f92972..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260224144400_Alpha1_InitialSchema.Designer.cs +++ /dev/null @@ -1,1302 +0,0 @@ -// -using System; -using M3Undle.Web.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260224144400_Alpha1_InitialSchema")] - partial class Alpha1_InitialSchema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - - modelBuilder.Entity("M3Undle.Web.Data.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnd") - .HasColumnType("TEXT"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .HasColumnType("TEXT"); - - b.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); - - b.Property("SecurityStamp") - .HasColumnType("TEXT"); - - b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Property("ChannelId") - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EventPolicy") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("event_policy"); - - b.Property("GroupName") - .HasColumnType("TEXT") - .HasColumnName("group_name"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("Notes") - .HasColumnType("TEXT") - .HasColumnName("notes"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelId"); - - b.HasIndex("ProfileId", "ChannelNumber") - .IsUnique() - .HasDatabaseName("idx_canonical_channels_profile_number"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_canonical_channels_profile_enabled"); - - b.ToTable("canonical_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.Property("RuleId") - .HasColumnType("TEXT") - .HasColumnName("rule_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultPriority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1) - .HasColumnName("default_priority"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IsEventRule") - .HasColumnType("INTEGER") - .HasColumnName("is_event_rule"); - - b.Property("MatchType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_type"); - - b.Property("MatchValue") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_value"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("TargetChannelId") - .HasColumnType("TEXT") - .HasColumnName("target_channel_id"); - - b.Property("TargetGroupName") - .HasColumnType("TEXT") - .HasColumnName("target_group_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("RuleId"); - - b.HasIndex("TargetChannelId"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_match_rules_profile"); - - b.ToTable("channel_match_rules", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.Property("ChannelSourceId") - .HasColumnType("TEXT") - .HasColumnName("channel_source_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("FailureCountRolling") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0) - .HasColumnName("failure_count_rolling"); - - b.Property("HealthState") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("health_state"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("OverrideStreamUrl") - .HasColumnType("TEXT") - .HasColumnName("override_stream_url"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ChannelId", "Priority") - .IsUnique() - .HasDatabaseName("idx_channel_sources_channel"); - - b.HasIndex("HealthState", "LastFailureUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_channel_sources_health"); - - b.ToTable("channel_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.Property("EpgMapId") - .HasColumnType("TEXT") - .HasColumnName("epg_map_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Source") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("source"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgMapId"); - - b.HasIndex("ChannelId"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_map_profile"); - - b.ToTable("epg_channel_map", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Property("FetchRunId") - .HasColumnType("TEXT") - .HasColumnName("fetch_run_id"); - - b.Property("ChannelCountSeen") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_seen"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("PlaylistBytes") - .HasColumnType("INTEGER") - .HasColumnName("playlist_bytes"); - - b.Property("PlaylistEtag") - .HasColumnType("TEXT") - .HasColumnName("playlist_etag"); - - b.Property("PlaylistLastModified") - .HasColumnType("TEXT") - .HasColumnName("playlist_last_modified"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("Type") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("snapshot") - .HasColumnName("type"); - - b.Property("XmltvBytes") - .HasColumnType("INTEGER") - .HasColumnName("xmltv_bytes"); - - b.Property("XmltvEtag") - .HasColumnType("TEXT") - .HasColumnName("xmltv_etag"); - - b.Property("XmltvLastModified") - .HasColumnType("TEXT") - .HasColumnName("xmltv_last_modified"); - - b.HasKey("FetchRunId"); - - b.HasIndex("ProviderId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_provider_time"); - - b.HasIndex("Status", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_status"); - - b.ToTable("fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("MergeMode") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("merge_mode"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("OutputName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileId"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("profiles", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.HasKey("ProfileId", "ProviderId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ProfileId", "Priority") - .HasDatabaseName("idx_profile_providers_profile"); - - b.ToTable("profile_providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("ConfigSourcePath") - .HasColumnType("TEXT") - .HasColumnName("config_source_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_active"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("NeedsEnvVarSubstitution") - .HasColumnType("INTEGER") - .HasColumnName("needs_env_var_substitution"); - - b.Property("PlaylistUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_url"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(20) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.Property("XmltvUrl") - .HasColumnType("TEXT") - .HasColumnName("xmltv_url"); - - b.HasKey("ProviderId"); - - b.HasIndex("Enabled") - .HasDatabaseName("idx_providers_enabled"); - - b.HasIndex("IsActive") - .IsUnique() - .HasDatabaseName("idx_providers_is_active") - .HasFilter("is_active = 1"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Property("ProviderChannelId") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EventEndUtc") - .HasColumnType("TEXT") - .HasColumnName("event_end_utc"); - - b.Property("EventStartUtc") - .HasColumnType("TEXT") - .HasColumnName("event_start_utc"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("GroupTitle") - .HasColumnType("TEXT") - .HasColumnName("group_title"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LastFetchRunId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("last_fetch_run_id"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("ProviderChannelKey") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_key"); - - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StreamUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("stream_url"); - - b.Property("TvgId") - .HasColumnType("TEXT") - .HasColumnName("tvg_id"); - - b.Property("TvgName") - .HasColumnType("TEXT") - .HasColumnName("tvg_name"); - - b.HasKey("ProviderChannelId"); - - b.HasIndex("LastFetchRunId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_channels_provider_active"); - - b.HasIndex("ProviderId", "LastSeenUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_provider_channels_seen"); - - b.HasIndex("ProviderId", "ProviderChannelKey") - .IsUnique() - .HasFilter("provider_channel_key IS NOT NULL"); - - b.HasIndex("ProviderId", "IsEvent", "EventStartUtc") - .HasDatabaseName("idx_provider_channels_is_event"); - - b.ToTable("provider_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("NormalizedName") - .HasColumnType("TEXT") - .HasColumnName("normalized_name"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("RawName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("raw_name"); - - b.HasKey("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_groups_provider_active"); - - b.HasIndex("ProviderId", "RawName") - .IsUnique(); - - b.ToTable("provider_groups", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.SiteSettings", b => - { - b.Property("Id") - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("AuthenticationEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("authentication_enabled"); - - b.HasKey("Id"); - - b.ToTable("site_settings", (string)null); - - b.HasData( - new - { - Id = 1, - AuthenticationEnabled = false - }); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.Property("SnapshotId") - .HasColumnType("TEXT") - .HasColumnName("snapshot_id"); - - b.Property("ChannelCountPublished") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_published"); - - b.Property("ChannelIndexPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_index_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("PlaylistPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_path"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("StatusJsonPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status_json_path"); - - b.Property("XmltvPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_path"); - - b.HasKey("SnapshotId"); - - b.HasIndex("ProfileId", "Status", "CreatedUtc") - .IsDescending(false, false, true) - .HasDatabaseName("idx_snapshots_profile_status"); - - b.ToTable("snapshots", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.Property("Value") - .HasColumnType("TEXT") - .HasColumnName("stream_key"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("LastUsedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_used_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Revoked") - .HasColumnType("INTEGER") - .HasColumnName("revoked"); - - b.HasKey("Value"); - - b.HasIndex("ChannelId") - .HasDatabaseName("idx_stream_keys_channel"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "Revoked") - .HasDatabaseName("idx_stream_keys_profile"); - - b.ToTable("stream_keys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderKey") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.Property("CredentialId") - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CredentialId"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserPasskeys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("CanonicalChannels") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ChannelMatchRules") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "TargetChannel") - .WithMany("TargetingMatchRules") - .HasForeignKey("TargetChannelId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - - b.Navigation("TargetChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("ChannelSources") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelSources") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ChannelSources") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Provider"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("EpgChannelMaps") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("EpgChannelMaps") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("FetchRuns") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileProviders") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProfileProviders") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.FetchRun", "LastFetchRun") - .WithMany("ProviderChannels") - .HasForeignKey("LastFetchRunId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("LastFetchRun"); - - b.Navigation("Provider"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderGroups") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("Snapshots") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("StreamKeys") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("StreamKeys") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => - { - b1.Property("IdentityUserPasskeyCredentialId"); - - b1.Property("AttestationObject") - .IsRequired(); - - b1.Property("ClientDataJson") - .IsRequired(); - - b1.Property("CreatedAt"); - - b1.Property("IsBackedUp"); - - b1.Property("IsBackupEligible"); - - b1.Property("IsUserVerified"); - - b1.Property("Name"); - - b1.Property("PublicKey") - .IsRequired(); - - b1.Property("SignCount"); - - b1.PrimitiveCollection("Transports"); - - b1.HasKey("IdentityUserPasskeyCredentialId"); - - b1.ToTable("AspNetUserPasskeys"); - - b1.ToJson("Data"); - - b1.WithOwner() - .HasForeignKey("IdentityUserPasskeyCredentialId"); - }); - - b.Navigation("Data") - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("StreamKeys"); - - b.Navigation("TargetingMatchRules"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Navigation("ProviderChannels"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Navigation("CanonicalChannels"); - - b.Navigation("ChannelMatchRules"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("ProfileProviders"); - - b.Navigation("Snapshots"); - - b.Navigation("StreamKeys"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("FetchRuns"); - - b.Navigation("ProfileProviders"); - - b.Navigation("ProviderChannels"); - - b.Navigation("ProviderGroups"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Navigation("ChannelSources"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Navigation("ProviderChannels"); - }); -#pragma warning restore 612, 618 - } - } -} - - diff --git a/src/M3Undle.Web/Data/Migrations/20260224144400_Alpha1_InitialSchema.cs b/src/M3Undle.Web/Data/Migrations/20260224144400_Alpha1_InitialSchema.cs deleted file mode 100644 index 590794c..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260224144400_Alpha1_InitialSchema.cs +++ /dev/null @@ -1,827 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - /// - public partial class Alpha1_InitialSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "AspNetRoles", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoles", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AspNetUsers", - columns: table => new - { - Id = table.Column(type: "TEXT", nullable: false), - UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), - Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "INTEGER", nullable: false), - PasswordHash = table.Column(type: "TEXT", nullable: true), - SecurityStamp = table.Column(type: "TEXT", nullable: true), - ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), - PhoneNumber = table.Column(type: "TEXT", maxLength: 256, nullable: true), - PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), - TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), - LockoutEnd = table.Column(type: "TEXT", nullable: true), - LockoutEnabled = table.Column(type: "INTEGER", nullable: false), - AccessFailedCount = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUsers", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "profiles", - columns: table => new - { - profile_id = table.Column(type: "TEXT", nullable: false), - name = table.Column(type: "TEXT", nullable: false), - enabled = table.Column(type: "INTEGER", nullable: false), - output_name = table.Column(type: "TEXT", nullable: false), - merge_mode = table.Column(type: "TEXT", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_profiles", x => x.profile_id); - }); - - migrationBuilder.CreateTable( - name: "providers", - columns: table => new - { - provider_id = table.Column(type: "TEXT", nullable: false), - name = table.Column(type: "TEXT", nullable: false), - enabled = table.Column(type: "INTEGER", nullable: false), - is_active = table.Column(type: "INTEGER", nullable: false, defaultValue: false), - playlist_url = table.Column(type: "TEXT", nullable: false), - xmltv_url = table.Column(type: "TEXT", nullable: true), - headers_json = table.Column(type: "TEXT", nullable: true), - user_agent = table.Column(type: "TEXT", nullable: true), - timeout_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 20), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false), - config_source_path = table.Column(type: "TEXT", nullable: true), - needs_env_var_substitution = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_providers", x => x.provider_id); - }); - - migrationBuilder.CreateTable( - name: "site_settings", - columns: table => new - { - id = table.Column(type: "INTEGER", nullable: false), - authentication_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true) - }, - constraints: table => - { - table.PrimaryKey("PK_site_settings", x => x.id); - }); - - migrationBuilder.CreateTable( - name: "AspNetRoleClaims", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - RoleId = table.Column(type: "TEXT", nullable: false), - ClaimType = table.Column(type: "TEXT", nullable: true), - ClaimValue = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserClaims", - columns: table => new - { - Id = table.Column(type: "INTEGER", nullable: false) - .Annotation("Sqlite:Autoincrement", true), - UserId = table.Column(type: "TEXT", nullable: false), - ClaimType = table.Column(type: "TEXT", nullable: true), - ClaimValue = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); - table.ForeignKey( - name: "FK_AspNetUserClaims_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserLogins", - columns: table => new - { - LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), - ProviderKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), - ProviderDisplayName = table.Column(type: "TEXT", nullable: true), - UserId = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AspNetUserLogins_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserPasskeys", - columns: table => new - { - CredentialId = table.Column(type: "BLOB", maxLength: 1024, nullable: false), - UserId = table.Column(type: "TEXT", nullable: false), - Data = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId); - table.ForeignKey( - name: "FK_AspNetUserPasskeys_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserRoles", - columns: table => new - { - UserId = table.Column(type: "TEXT", nullable: false), - RoleId = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetRoles_RoleId", - column: x => x.RoleId, - principalTable: "AspNetRoles", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_AspNetUserRoles_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AspNetUserTokens", - columns: table => new - { - UserId = table.Column(type: "TEXT", nullable: false), - LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), - Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), - Value = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AspNetUserTokens_AspNetUsers_UserId", - column: x => x.UserId, - principalTable: "AspNetUsers", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "canonical_channels", - columns: table => new - { - channel_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - display_name = table.Column(type: "TEXT", nullable: false), - channel_number = table.Column(type: "INTEGER", nullable: false), - group_name = table.Column(type: "TEXT", nullable: true), - logo_url = table.Column(type: "TEXT", nullable: true), - enabled = table.Column(type: "INTEGER", nullable: false), - is_event = table.Column(type: "INTEGER", nullable: false), - event_policy = table.Column(type: "TEXT", nullable: false), - notes = table.Column(type: "TEXT", nullable: true), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_canonical_channels", x => x.channel_id); - table.ForeignKey( - name: "FK_canonical_channels_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "snapshots", - columns: table => new - { - snapshot_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false), - status = table.Column(type: "TEXT", nullable: false), - playlist_path = table.Column(type: "TEXT", nullable: false), - xmltv_path = table.Column(type: "TEXT", nullable: false), - channel_index_path = table.Column(type: "TEXT", nullable: false), - status_json_path = table.Column(type: "TEXT", nullable: false), - channel_count_published = table.Column(type: "INTEGER", nullable: false), - error_summary = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_snapshots", x => x.snapshot_id); - table.ForeignKey( - name: "FK_snapshots_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "fetch_runs", - columns: table => new - { - fetch_run_id = table.Column(type: "TEXT", nullable: false), - provider_id = table.Column(type: "TEXT", nullable: false), - started_utc = table.Column(type: "TEXT", nullable: false), - finished_utc = table.Column(type: "TEXT", nullable: true), - status = table.Column(type: "TEXT", nullable: false), - type = table.Column(type: "TEXT", nullable: false, defaultValue: "snapshot"), - error_summary = table.Column(type: "TEXT", nullable: true), - playlist_etag = table.Column(type: "TEXT", nullable: true), - playlist_last_modified = table.Column(type: "TEXT", nullable: true), - xmltv_etag = table.Column(type: "TEXT", nullable: true), - xmltv_last_modified = table.Column(type: "TEXT", nullable: true), - playlist_bytes = table.Column(type: "INTEGER", nullable: true), - xmltv_bytes = table.Column(type: "INTEGER", nullable: true), - channel_count_seen = table.Column(type: "INTEGER", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_fetch_runs", x => x.fetch_run_id); - table.ForeignKey( - name: "FK_fetch_runs_providers_provider_id", - column: x => x.provider_id, - principalTable: "providers", - principalColumn: "provider_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "profile_providers", - columns: table => new - { - profile_id = table.Column(type: "TEXT", nullable: false), - provider_id = table.Column(type: "TEXT", nullable: false), - priority = table.Column(type: "INTEGER", nullable: false), - enabled = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_profile_providers", x => new { x.profile_id, x.provider_id }); - table.ForeignKey( - name: "FK_profile_providers_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_profile_providers_providers_provider_id", - column: x => x.provider_id, - principalTable: "providers", - principalColumn: "provider_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "provider_groups", - columns: table => new - { - provider_group_id = table.Column(type: "TEXT", nullable: false), - provider_id = table.Column(type: "TEXT", nullable: false), - raw_name = table.Column(type: "TEXT", nullable: false), - normalized_name = table.Column(type: "TEXT", nullable: true), - first_seen_utc = table.Column(type: "TEXT", nullable: false), - last_seen_utc = table.Column(type: "TEXT", nullable: false), - active = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_provider_groups", x => x.provider_group_id); - table.ForeignKey( - name: "FK_provider_groups_providers_provider_id", - column: x => x.provider_id, - principalTable: "providers", - principalColumn: "provider_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "channel_match_rules", - columns: table => new - { - rule_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - enabled = table.Column(type: "INTEGER", nullable: false), - match_type = table.Column(type: "TEXT", nullable: false), - match_value = table.Column(type: "TEXT", nullable: false), - target_channel_id = table.Column(type: "TEXT", nullable: true), - target_group_name = table.Column(type: "TEXT", nullable: true), - default_priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), - is_event_rule = table.Column(type: "INTEGER", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_channel_match_rules", x => x.rule_id); - table.ForeignKey( - name: "FK_channel_match_rules_canonical_channels_target_channel_id", - column: x => x.target_channel_id, - principalTable: "canonical_channels", - principalColumn: "channel_id", - onDelete: ReferentialAction.SetNull); - table.ForeignKey( - name: "FK_channel_match_rules_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "epg_channel_map", - columns: table => new - { - epg_map_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - channel_id = table.Column(type: "TEXT", nullable: false), - xmltv_channel_id = table.Column(type: "TEXT", nullable: false), - source = table.Column(type: "TEXT", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_epg_channel_map", x => x.epg_map_id); - table.ForeignKey( - name: "FK_epg_channel_map_canonical_channels_channel_id", - column: x => x.channel_id, - principalTable: "canonical_channels", - principalColumn: "channel_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_epg_channel_map_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "stream_keys", - columns: table => new - { - stream_key = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - channel_id = table.Column(type: "TEXT", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false), - last_used_utc = table.Column(type: "TEXT", nullable: true), - revoked = table.Column(type: "INTEGER", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_stream_keys", x => x.stream_key); - table.ForeignKey( - name: "FK_stream_keys_canonical_channels_channel_id", - column: x => x.channel_id, - principalTable: "canonical_channels", - principalColumn: "channel_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_stream_keys_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "provider_channels", - columns: table => new - { - provider_channel_id = table.Column(type: "TEXT", nullable: false), - provider_id = table.Column(type: "TEXT", nullable: false), - provider_channel_key = table.Column(type: "TEXT", nullable: true), - display_name = table.Column(type: "TEXT", nullable: false), - tvg_id = table.Column(type: "TEXT", nullable: true), - tvg_name = table.Column(type: "TEXT", nullable: true), - logo_url = table.Column(type: "TEXT", nullable: true), - stream_url = table.Column(type: "TEXT", nullable: false), - group_title = table.Column(type: "TEXT", nullable: true), - provider_group_id = table.Column(type: "TEXT", nullable: true), - is_event = table.Column(type: "INTEGER", nullable: false), - event_start_utc = table.Column(type: "TEXT", nullable: true), - event_end_utc = table.Column(type: "TEXT", nullable: true), - first_seen_utc = table.Column(type: "TEXT", nullable: false), - last_seen_utc = table.Column(type: "TEXT", nullable: false), - active = table.Column(type: "INTEGER", nullable: false), - last_fetch_run_id = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_provider_channels", x => x.provider_channel_id); - table.ForeignKey( - name: "FK_provider_channels_fetch_runs_last_fetch_run_id", - column: x => x.last_fetch_run_id, - principalTable: "fetch_runs", - principalColumn: "fetch_run_id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_provider_channels_provider_groups_provider_group_id", - column: x => x.provider_group_id, - principalTable: "provider_groups", - principalColumn: "provider_group_id", - onDelete: ReferentialAction.SetNull); - table.ForeignKey( - name: "FK_provider_channels_providers_provider_id", - column: x => x.provider_id, - principalTable: "providers", - principalColumn: "provider_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.CreateTable( - name: "channel_sources", - columns: table => new - { - channel_source_id = table.Column(type: "TEXT", nullable: false), - channel_id = table.Column(type: "TEXT", nullable: false), - provider_id = table.Column(type: "TEXT", nullable: false), - provider_channel_id = table.Column(type: "TEXT", nullable: false), - priority = table.Column(type: "INTEGER", nullable: false), - enabled = table.Column(type: "INTEGER", nullable: false), - override_stream_url = table.Column(type: "TEXT", nullable: true), - last_success_utc = table.Column(type: "TEXT", nullable: true), - last_failure_utc = table.Column(type: "TEXT", nullable: true), - failure_count_rolling = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), - health_state = table.Column(type: "TEXT", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_channel_sources", x => x.channel_source_id); - table.ForeignKey( - name: "FK_channel_sources_canonical_channels_channel_id", - column: x => x.channel_id, - principalTable: "canonical_channels", - principalColumn: "channel_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_channel_sources_provider_channels_provider_channel_id", - column: x => x.provider_channel_id, - principalTable: "provider_channels", - principalColumn: "provider_channel_id", - onDelete: ReferentialAction.Restrict); - table.ForeignKey( - name: "FK_channel_sources_providers_provider_id", - column: x => x.provider_id, - principalTable: "providers", - principalColumn: "provider_id", - onDelete: ReferentialAction.Restrict); - }); - - migrationBuilder.InsertData( - table: "site_settings", - columns: new[] { "id", "authentication_enabled" }, - values: new object[] { 1, false }); - - migrationBuilder.CreateIndex( - name: "IX_AspNetRoleClaims_RoleId", - table: "AspNetRoleClaims", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - table: "AspNetRoles", - column: "NormalizedName", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserClaims_UserId", - table: "AspNetUserClaims", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserLogins_UserId", - table: "AspNetUserLogins", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserPasskeys_UserId", - table: "AspNetUserPasskeys", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AspNetUserRoles_RoleId", - table: "AspNetUserRoles", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - table: "AspNetUsers", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - table: "AspNetUsers", - column: "NormalizedUserName", - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_canonical_channels_profile_enabled", - table: "canonical_channels", - columns: new[] { "profile_id", "enabled" }); - - migrationBuilder.CreateIndex( - name: "idx_canonical_channels_profile_number", - table: "canonical_channels", - columns: new[] { "profile_id", "channel_number" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_match_rules_profile", - table: "channel_match_rules", - columns: new[] { "profile_id", "enabled" }); - - migrationBuilder.CreateIndex( - name: "IX_channel_match_rules_target_channel_id", - table: "channel_match_rules", - column: "target_channel_id"); - - migrationBuilder.CreateIndex( - name: "idx_channel_sources_channel", - table: "channel_sources", - columns: new[] { "channel_id", "priority" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_channel_sources_health", - table: "channel_sources", - columns: new[] { "health_state", "last_failure_utc" }, - descending: new[] { false, true }); - - migrationBuilder.CreateIndex( - name: "IX_channel_sources_provider_channel_id", - table: "channel_sources", - column: "provider_channel_id"); - - migrationBuilder.CreateIndex( - name: "IX_channel_sources_provider_id", - table: "channel_sources", - column: "provider_id"); - - migrationBuilder.CreateIndex( - name: "idx_epg_map_profile", - table: "epg_channel_map", - columns: new[] { "profile_id", "xmltv_channel_id" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_epg_channel_map_channel_id", - table: "epg_channel_map", - column: "channel_id"); - - migrationBuilder.CreateIndex( - name: "IX_epg_channel_map_profile_id_channel_id", - table: "epg_channel_map", - columns: new[] { "profile_id", "channel_id" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_fetch_runs_provider_time", - table: "fetch_runs", - columns: new[] { "provider_id", "started_utc" }, - descending: new[] { false, true }); - - migrationBuilder.CreateIndex( - name: "idx_fetch_runs_status", - table: "fetch_runs", - columns: new[] { "status", "started_utc" }, - descending: new[] { false, true }); - - migrationBuilder.CreateIndex( - name: "idx_profile_providers_profile", - table: "profile_providers", - columns: new[] { "profile_id", "priority" }); - - migrationBuilder.CreateIndex( - name: "IX_profile_providers_provider_id", - table: "profile_providers", - column: "provider_id"); - - migrationBuilder.CreateIndex( - name: "IX_profiles_name", - table: "profiles", - column: "name", - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_provider_channels_is_event", - table: "provider_channels", - columns: new[] { "provider_id", "is_event", "event_start_utc" }); - - migrationBuilder.CreateIndex( - name: "idx_provider_channels_provider_active", - table: "provider_channels", - columns: new[] { "provider_id", "active" }); - - migrationBuilder.CreateIndex( - name: "idx_provider_channels_seen", - table: "provider_channels", - columns: new[] { "provider_id", "last_seen_utc" }, - descending: new[] { false, true }); - - migrationBuilder.CreateIndex( - name: "IX_provider_channels_last_fetch_run_id", - table: "provider_channels", - column: "last_fetch_run_id"); - - migrationBuilder.CreateIndex( - name: "IX_provider_channels_provider_group_id", - table: "provider_channels", - column: "provider_group_id"); - - migrationBuilder.CreateIndex( - name: "IX_provider_channels_provider_id_provider_channel_key", - table: "provider_channels", - columns: new[] { "provider_id", "provider_channel_key" }, - unique: true, - filter: "provider_channel_key IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "idx_provider_groups_provider_active", - table: "provider_groups", - columns: new[] { "provider_id", "active" }); - - migrationBuilder.CreateIndex( - name: "IX_provider_groups_provider_id_raw_name", - table: "provider_groups", - columns: new[] { "provider_id", "raw_name" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_providers_enabled", - table: "providers", - column: "enabled"); - - migrationBuilder.CreateIndex( - name: "idx_providers_is_active", - table: "providers", - column: "is_active", - unique: true, - filter: "is_active = 1"); - - migrationBuilder.CreateIndex( - name: "IX_providers_name", - table: "providers", - column: "name", - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_snapshots_profile_status", - table: "snapshots", - columns: new[] { "profile_id", "status", "created_utc" }, - descending: new[] { false, false, true }); - - migrationBuilder.CreateIndex( - name: "idx_stream_keys_channel", - table: "stream_keys", - column: "channel_id"); - - migrationBuilder.CreateIndex( - name: "idx_stream_keys_profile", - table: "stream_keys", - columns: new[] { "profile_id", "revoked" }); - - migrationBuilder.CreateIndex( - name: "IX_stream_keys_profile_id_channel_id", - table: "stream_keys", - columns: new[] { "profile_id", "channel_id" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AspNetRoleClaims"); - - migrationBuilder.DropTable( - name: "AspNetUserClaims"); - - migrationBuilder.DropTable( - name: "AspNetUserLogins"); - - migrationBuilder.DropTable( - name: "AspNetUserPasskeys"); - - migrationBuilder.DropTable( - name: "AspNetUserRoles"); - - migrationBuilder.DropTable( - name: "AspNetUserTokens"); - - migrationBuilder.DropTable( - name: "channel_match_rules"); - - migrationBuilder.DropTable( - name: "channel_sources"); - - migrationBuilder.DropTable( - name: "epg_channel_map"); - - migrationBuilder.DropTable( - name: "profile_providers"); - - migrationBuilder.DropTable( - name: "site_settings"); - - migrationBuilder.DropTable( - name: "snapshots"); - - migrationBuilder.DropTable( - name: "stream_keys"); - - migrationBuilder.DropTable( - name: "AspNetRoles"); - - migrationBuilder.DropTable( - name: "AspNetUsers"); - - migrationBuilder.DropTable( - name: "provider_channels"); - - migrationBuilder.DropTable( - name: "canonical_channels"); - - migrationBuilder.DropTable( - name: "fetch_runs"); - - migrationBuilder.DropTable( - name: "provider_groups"); - - migrationBuilder.DropTable( - name: "profiles"); - - migrationBuilder.DropTable( - name: "providers"); - } - } -} - diff --git a/src/M3Undle.Web/Data/Migrations/20260309000000_Alpha2_Schema.Designer.cs b/src/M3Undle.Web/Data/Migrations/20260309000000_Alpha2_Schema.Designer.cs deleted file mode 100644 index 253c83a..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260309000000_Alpha2_Schema.Designer.cs +++ /dev/null @@ -1,1528 +0,0 @@ -// -using System; -using M3Undle.Web.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260309000000_Alpha2_Schema")] - partial class Alpha2_Schema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - - modelBuilder.Entity("M3Undle.Web.Data.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnd") - .HasColumnType("TEXT"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .HasColumnType("TEXT"); - - b.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); - - b.Property("SecurityStamp") - .HasColumnType("TEXT"); - - b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Property("ChannelId") - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EventPolicy") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("event_policy"); - - b.Property("GroupName") - .HasColumnType("TEXT") - .HasColumnName("group_name"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("Notes") - .HasColumnType("TEXT") - .HasColumnName("notes"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelId"); - - b.HasIndex("ProfileId", "ChannelNumber") - .IsUnique() - .HasDatabaseName("idx_canonical_channels_profile_number"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_canonical_channels_profile_enabled"); - - b.ToTable("canonical_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.Property("RuleId") - .HasColumnType("TEXT") - .HasColumnName("rule_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultPriority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1) - .HasColumnName("default_priority"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IsEventRule") - .HasColumnType("INTEGER") - .HasColumnName("is_event_rule"); - - b.Property("MatchType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_type"); - - b.Property("MatchValue") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_value"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("TargetChannelId") - .HasColumnType("TEXT") - .HasColumnName("target_channel_id"); - - b.Property("TargetGroupName") - .HasColumnType("TEXT") - .HasColumnName("target_group_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("RuleId"); - - b.HasIndex("TargetChannelId"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_match_rules_profile"); - - b.ToTable("channel_match_rules", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.Property("ChannelSourceId") - .HasColumnType("TEXT") - .HasColumnName("channel_source_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("FailureCountRolling") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0) - .HasColumnName("failure_count_rolling"); - - b.Property("HealthState") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("health_state"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("OverrideStreamUrl") - .HasColumnType("TEXT") - .HasColumnName("override_stream_url"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ChannelId", "Priority") - .IsUnique() - .HasDatabaseName("idx_channel_sources_channel"); - - b.HasIndex("HealthState", "LastFailureUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_channel_sources_health"); - - b.ToTable("channel_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.Property("EpgMapId") - .HasColumnType("TEXT") - .HasColumnName("epg_map_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Source") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("source"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgMapId"); - - b.HasIndex("ChannelId"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_map_profile"); - - b.ToTable("epg_channel_map", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Property("FetchRunId") - .HasColumnType("TEXT") - .HasColumnName("fetch_run_id"); - - b.Property("ChannelCountSeen") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_seen"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("PlaylistBytes") - .HasColumnType("INTEGER") - .HasColumnName("playlist_bytes"); - - b.Property("PlaylistEtag") - .HasColumnType("TEXT") - .HasColumnName("playlist_etag"); - - b.Property("PlaylistLastModified") - .HasColumnType("TEXT") - .HasColumnName("playlist_last_modified"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("Type") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("snapshot") - .HasColumnName("type"); - - b.Property("XmltvBytes") - .HasColumnType("INTEGER") - .HasColumnName("xmltv_bytes"); - - b.Property("XmltvEtag") - .HasColumnType("TEXT") - .HasColumnName("xmltv_etag"); - - b.Property("XmltvLastModified") - .HasColumnType("TEXT") - .HasColumnName("xmltv_last_modified"); - - b.HasKey("FetchRunId"); - - b.HasIndex("ProviderId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_provider_time"); - - b.HasIndex("Status", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_status"); - - b.ToTable("fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("MergeMode") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("merge_mode"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("OutputName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileId"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("profiles", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.Property("ProfileGroupChannelFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_channel_filter_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("OutputGroupName") - .HasColumnType("TEXT") - .HasColumnName("output_group_name"); - - b.Property("ProfileGroupFilterId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.HasKey("ProfileGroupChannelFilterId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProfileGroupFilterId", "ProviderChannelId") - .IsUnique() - .HasDatabaseName("idx_pgcf_filter_channel_unique"); - - b.ToTable("profile_group_channel_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Property("ProfileGroupFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("AutoNumEnd") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_end"); - - b.Property("AutoNumStart") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_start"); - - b.Property("ChannelMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("all") - .HasColumnName("channel_mode"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Decision") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("hold") - .HasColumnName("decision"); - - b.Property("IsNew") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_new"); - - b.Property("OutputName") - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("SortOverride") - .HasColumnType("INTEGER") - .HasColumnName("sort_override"); - - b.Property("TrackNewChannels") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("track_new_channels"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileGroupFilterId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProfileId", "Decision") - .HasDatabaseName("idx_pgf_profile_decision"); - - b.HasIndex("ProfileId", "ProviderGroupId") - .IsUnique() - .HasDatabaseName("idx_pgf_profile_group_unique"); - - b.ToTable("profile_group_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.HasKey("ProfileId", "ProviderId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ProfileId", "Priority") - .HasDatabaseName("idx_profile_providers_profile"); - - b.ToTable("profile_providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("ConfigSourcePath") - .HasColumnType("TEXT") - .HasColumnName("config_source_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("IncludeSeries") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_series"); - - b.Property("IncludeVod") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_vod"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_active"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("NeedsEnvVarSubstitution") - .HasColumnType("INTEGER") - .HasColumnName("needs_env_var_substitution"); - - b.Property("PlaylistUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_url"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(20) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.Property("XmltvUrl") - .HasColumnType("TEXT") - .HasColumnName("xmltv_url"); - - b.Property("XtreamBaseUrl") - .HasColumnType("TEXT") - .HasColumnName("xtream_base_url"); - - b.Property("XtreamEncryptedPassword") - .HasColumnType("TEXT") - .HasColumnName("xtream_encrypted_password"); - - b.Property("XtreamIncludeXmltv") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("xtream_include_xmltv"); - - b.Property("XtreamUsername") - .HasColumnType("TEXT") - .HasColumnName("xtream_username"); - - b.HasKey("ProviderId"); - - b.HasIndex("Enabled") - .HasDatabaseName("idx_providers_enabled"); - - b.HasIndex("IsActive") - .IsUnique() - .HasDatabaseName("idx_providers_is_active") - .HasFilter("is_active = 1"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Property("ProviderChannelId") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EventEndUtc") - .HasColumnType("TEXT") - .HasColumnName("event_end_utc"); - - b.Property("EventStartUtc") - .HasColumnType("TEXT") - .HasColumnName("event_start_utc"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("GroupTitle") - .HasColumnType("TEXT") - .HasColumnName("group_title"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LastFetchRunId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("last_fetch_run_id"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("ProviderChannelKey") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_key"); - - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StreamUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("stream_url"); - - b.Property("TvgId") - .HasColumnType("TEXT") - .HasColumnName("tvg_id"); - - b.Property("TvgName") - .HasColumnType("TEXT") - .HasColumnName("tvg_name"); - - b.HasKey("ProviderChannelId"); - - b.HasIndex("LastFetchRunId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_channels_provider_active"); - - b.HasIndex("ProviderId", "LastSeenUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_provider_channels_seen"); - - b.HasIndex("ProviderId", "ProviderChannelKey") - .IsUnique() - .HasFilter("provider_channel_key IS NOT NULL"); - - b.HasIndex("ProviderId", "IsEvent", "EventStartUtc") - .HasDatabaseName("idx_provider_channels_is_event"); - - b.ToTable("provider_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("channel_count"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("NormalizedName") - .HasColumnType("TEXT") - .HasColumnName("normalized_name"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("RawName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("raw_name"); - - b.HasKey("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_groups_provider_active"); - - b.HasIndex("ProviderId", "RawName") - .IsUnique(); - - b.ToTable("provider_groups", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.SiteSettings", b => - { - b.Property("Id") - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("AuthenticationEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("authentication_enabled"); - - b.HasKey("Id"); - - b.ToTable("site_settings", (string)null); - - b.HasData( - new - { - Id = 1, - AuthenticationEnabled = false - }); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.Property("SnapshotId") - .HasColumnType("TEXT") - .HasColumnName("snapshot_id"); - - b.Property("ChannelCountPublished") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_published"); - - b.Property("ChannelIndexPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_index_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("LiveChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("live_channel_count"); - - b.Property("PlaylistPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_path"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("SeriesChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("series_channel_count"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("StatusJsonPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status_json_path"); - - b.Property("VodChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("vod_channel_count"); - - b.Property("XmltvPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_path"); - - b.HasKey("SnapshotId"); - - b.HasIndex("ProfileId", "Status", "CreatedUtc") - .IsDescending(false, false, true) - .HasDatabaseName("idx_snapshots_profile_status"); - - b.ToTable("snapshots", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.Property("Value") - .HasColumnType("TEXT") - .HasColumnName("stream_key"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("LastUsedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_used_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Revoked") - .HasColumnType("INTEGER") - .HasColumnName("revoked"); - - b.HasKey("Value"); - - b.HasIndex("ChannelId") - .HasDatabaseName("idx_stream_keys_channel"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "Revoked") - .HasDatabaseName("idx_stream_keys_profile"); - - b.ToTable("stream_keys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderKey") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.Property("CredentialId") - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CredentialId"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserPasskeys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("CanonicalChannels") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ChannelMatchRules") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "TargetChannel") - .WithMany("TargetingMatchRules") - .HasForeignKey("TargetChannelId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - - b.Navigation("TargetChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("ChannelSources") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelSources") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ChannelSources") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Provider"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("EpgChannelMaps") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("EpgChannelMaps") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("FetchRuns") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileGroupFilter", "ProfileGroupFilter") - .WithMany("ChannelFilters") - .HasForeignKey("ProfileGroupFilterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelFilters") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ProfileGroupFilter"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileProviders") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProfileProviders") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.FetchRun", "LastFetchRun") - .WithMany("ProviderChannels") - .HasForeignKey("LastFetchRunId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("LastFetchRun"); - - b.Navigation("Provider"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderGroups") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("Snapshots") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("StreamKeys") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("StreamKeys") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => - { - b1.Property("IdentityUserPasskeyCredentialId"); - - b1.Property("AttestationObject") - .IsRequired(); - - b1.Property("ClientDataJson") - .IsRequired(); - - b1.Property("CreatedAt"); - - b1.Property("IsBackedUp"); - - b1.Property("IsBackupEligible"); - - b1.Property("IsUserVerified"); - - b1.Property("Name"); - - b1.Property("PublicKey") - .IsRequired(); - - b1.Property("SignCount"); - - b1.PrimitiveCollection("Transports"); - - b1.HasKey("IdentityUserPasskeyCredentialId"); - - b1.ToTable("AspNetUserPasskeys"); - - b1.ToJson("Data"); - - b1.WithOwner() - .HasForeignKey("IdentityUserPasskeyCredentialId"); - }); - - b.Navigation("Data") - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("StreamKeys"); - - b.Navigation("TargetingMatchRules"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Navigation("ProviderChannels"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Navigation("CanonicalChannels"); - - b.Navigation("ChannelMatchRules"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProfileProviders"); - - b.Navigation("Snapshots"); - - b.Navigation("StreamKeys"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Navigation("ChannelFilters"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("FetchRuns"); - - b.Navigation("ProfileProviders"); - - b.Navigation("ProviderChannels"); - - b.Navigation("ProviderGroups"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Navigation("ChannelFilters"); - - b.Navigation("ChannelSources"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProviderChannels"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260309000000_Alpha2_Schema.cs b/src/M3Undle.Web/Data/Migrations/20260309000000_Alpha2_Schema.cs deleted file mode 100644 index 4c1c4d8..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260309000000_Alpha2_Schema.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - /// - public partial class Alpha2_Schema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // provider_groups: add channel_count - migrationBuilder.AddColumn( - name: "channel_count", - table: "provider_groups", - type: "INTEGER", - nullable: true); - - // provider_groups: add content_type - migrationBuilder.AddColumn( - name: "content_type", - table: "provider_groups", - type: "TEXT", - nullable: false, - defaultValue: "live"); - - // provider_channels: add content_type - migrationBuilder.AddColumn( - name: "content_type", - table: "provider_channels", - type: "TEXT", - nullable: false, - defaultValue: "live"); - - // providers: add content type flags - migrationBuilder.AddColumn( - name: "include_series", - table: "providers", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "include_vod", - table: "providers", - type: "INTEGER", - nullable: false, - defaultValue: false); - - // providers: add Xtream credentials - migrationBuilder.AddColumn( - name: "xtream_base_url", - table: "providers", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "xtream_encrypted_password", - table: "providers", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "xtream_include_xmltv", - table: "providers", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "xtream_username", - table: "providers", - type: "TEXT", - nullable: true); - - // snapshots: add per-content-type channel counts - migrationBuilder.AddColumn( - name: "live_channel_count", - table: "snapshots", - type: "INTEGER", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "series_channel_count", - table: "snapshots", - type: "INTEGER", - nullable: false, - defaultValue: 0); - - migrationBuilder.AddColumn( - name: "vod_channel_count", - table: "snapshots", - type: "INTEGER", - nullable: false, - defaultValue: 0); - - // profile_group_filters: new table (includes all Alpha2 columns) - migrationBuilder.CreateTable( - name: "profile_group_filters", - columns: table => new - { - profile_group_filter_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - provider_group_id = table.Column(type: "TEXT", nullable: false), - decision = table.Column(type: "TEXT", nullable: false, defaultValue: "hold"), - output_name = table.Column(type: "TEXT", nullable: true), - auto_num_start = table.Column(type: "INTEGER", nullable: true), - auto_num_end = table.Column(type: "INTEGER", nullable: true), - track_new_channels = table.Column(type: "INTEGER", nullable: false, defaultValue: false), - sort_override = table.Column(type: "INTEGER", nullable: true), - channel_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "all"), - is_new = table.Column(type: "INTEGER", nullable: false, defaultValue: false), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_profile_group_filters", x => x.profile_group_filter_id); - table.ForeignKey( - name: "FK_profile_group_filters_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_profile_group_filters_provider_groups_provider_group_id", - column: x => x.provider_group_id, - principalTable: "provider_groups", - principalColumn: "provider_group_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "idx_pgf_profile_decision", - table: "profile_group_filters", - columns: new[] { "profile_id", "decision" }); - - migrationBuilder.CreateIndex( - name: "idx_pgf_profile_group_unique", - table: "profile_group_filters", - columns: new[] { "profile_id", "provider_group_id" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_profile_group_filters_provider_group_id", - table: "profile_group_filters", - column: "provider_group_id"); - - // profile_group_channel_filters: new table (includes all Alpha2 columns) - migrationBuilder.CreateTable( - name: "profile_group_channel_filters", - columns: table => new - { - profile_group_channel_filter_id = table.Column(type: "TEXT", nullable: false), - profile_group_filter_id = table.Column(type: "TEXT", nullable: false), - provider_channel_id = table.Column(type: "TEXT", nullable: false), - channel_number = table.Column(type: "INTEGER", nullable: true), - output_group_name = table.Column(type: "TEXT", nullable: true), - created_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_profile_group_channel_filters", x => x.profile_group_channel_filter_id); - table.ForeignKey( - name: "FK_profile_group_channel_filters_profile_group_filters_profile_group_filter_id", - column: x => x.profile_group_filter_id, - principalTable: "profile_group_filters", - principalColumn: "profile_group_filter_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_profile_group_channel_filters_provider_channels_provider_channel_id", - column: x => x.provider_channel_id, - principalTable: "provider_channels", - principalColumn: "provider_channel_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "idx_pgcf_filter_channel_unique", - table: "profile_group_channel_filters", - columns: new[] { "profile_group_filter_id", "provider_channel_id" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_profile_group_channel_filters_provider_channel_id", - table: "profile_group_channel_filters", - column: "provider_channel_id"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable(name: "profile_group_channel_filters"); - migrationBuilder.DropTable(name: "profile_group_filters"); - - migrationBuilder.DropColumn(name: "vod_channel_count", table: "snapshots"); - migrationBuilder.DropColumn(name: "series_channel_count", table: "snapshots"); - migrationBuilder.DropColumn(name: "live_channel_count", table: "snapshots"); - - migrationBuilder.DropColumn(name: "xtream_username", table: "providers"); - migrationBuilder.DropColumn(name: "xtream_include_xmltv", table: "providers"); - migrationBuilder.DropColumn(name: "xtream_encrypted_password", table: "providers"); - migrationBuilder.DropColumn(name: "xtream_base_url", table: "providers"); - migrationBuilder.DropColumn(name: "include_vod", table: "providers"); - migrationBuilder.DropColumn(name: "include_series", table: "providers"); - - migrationBuilder.DropColumn(name: "content_type", table: "provider_channels"); - migrationBuilder.DropColumn(name: "content_type", table: "provider_groups"); - migrationBuilder.DropColumn(name: "channel_count", table: "provider_groups"); - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260312183103_Alpha3_Schema.Designer.cs b/src/M3Undle.Web/Data/Migrations/20260312183103_Alpha3_Schema.Designer.cs deleted file mode 100644 index 4f51843..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260312183103_Alpha3_Schema.Designer.cs +++ /dev/null @@ -1,1667 +0,0 @@ -// -using System; -using M3Undle.Web.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260312183103_Alpha3_Schema")] - partial class Alpha3_Schema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - - modelBuilder.Entity("M3Undle.Web.Data.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnd") - .HasColumnType("TEXT"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .HasColumnType("TEXT"); - - b.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); - - b.Property("SecurityStamp") - .HasColumnType("TEXT"); - - b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Property("ChannelId") - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EventPolicy") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("event_policy"); - - b.Property("GroupName") - .HasColumnType("TEXT") - .HasColumnName("group_name"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("Notes") - .HasColumnType("TEXT") - .HasColumnName("notes"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelId"); - - b.HasIndex("ProfileId", "ChannelNumber") - .IsUnique() - .HasDatabaseName("idx_canonical_channels_profile_number"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_canonical_channels_profile_enabled"); - - b.ToTable("canonical_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.Property("RuleId") - .HasColumnType("TEXT") - .HasColumnName("rule_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultPriority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1) - .HasColumnName("default_priority"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IsEventRule") - .HasColumnType("INTEGER") - .HasColumnName("is_event_rule"); - - b.Property("MatchType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_type"); - - b.Property("MatchValue") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_value"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("TargetChannelId") - .HasColumnType("TEXT") - .HasColumnName("target_channel_id"); - - b.Property("TargetGroupName") - .HasColumnType("TEXT") - .HasColumnName("target_group_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("RuleId"); - - b.HasIndex("TargetChannelId"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_match_rules_profile"); - - b.ToTable("channel_match_rules", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.Property("ChannelSourceId") - .HasColumnType("TEXT") - .HasColumnName("channel_source_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("FailureCountRolling") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0) - .HasColumnName("failure_count_rolling"); - - b.Property("HealthState") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("health_state"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("OverrideStreamUrl") - .HasColumnType("TEXT") - .HasColumnName("override_stream_url"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ChannelId", "Priority") - .IsUnique() - .HasDatabaseName("idx_channel_sources_channel"); - - b.HasIndex("HealthState", "LastFailureUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_channel_sources_health"); - - b.ToTable("channel_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointAccessBinding", b => - { - b.Property("EndpointAccessBindingId") - .HasColumnType("TEXT") - .HasColumnName("endpoint_access_binding_id"); - - b.Property("ActiveProfileId") - .HasColumnType("TEXT") - .HasColumnName("active_profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultProfileId") - .HasColumnType("TEXT") - .HasColumnName("default_profile_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EndpointCredentialId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("endpoint_credential_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("VirtualTunerId") - .HasColumnType("TEXT") - .HasColumnName("virtual_tuner_id"); - - b.HasKey("EndpointAccessBindingId"); - - b.HasIndex("ActiveProfileId") - .HasDatabaseName("idx_endpoint_access_bindings_active_profile"); - - b.HasIndex("DefaultProfileId"); - - b.HasIndex("EndpointCredentialId") - .IsUnique() - .HasDatabaseName("idx_endpoint_access_bindings_credential"); - - b.ToTable("endpoint_access_bindings", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointCredential", b => - { - b.Property("EndpointCredentialId") - .HasColumnType("TEXT") - .HasColumnName("endpoint_credential_id"); - - b.Property("AuthType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("auth_type"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("normalized_username"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("password_hash"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("Username") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("username"); - - b.HasKey("EndpointCredentialId"); - - b.HasIndex("NormalizedUsername") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("endpoint_credentials", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.Property("EpgMapId") - .HasColumnType("TEXT") - .HasColumnName("epg_map_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Source") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("source"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgMapId"); - - b.HasIndex("ChannelId"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_map_profile"); - - b.ToTable("epg_channel_map", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Property("FetchRunId") - .HasColumnType("TEXT") - .HasColumnName("fetch_run_id"); - - b.Property("ChannelCountSeen") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_seen"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("PlaylistBytes") - .HasColumnType("INTEGER") - .HasColumnName("playlist_bytes"); - - b.Property("PlaylistEtag") - .HasColumnType("TEXT") - .HasColumnName("playlist_etag"); - - b.Property("PlaylistLastModified") - .HasColumnType("TEXT") - .HasColumnName("playlist_last_modified"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("Type") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("snapshot") - .HasColumnName("type"); - - b.Property("XmltvBytes") - .HasColumnType("INTEGER") - .HasColumnName("xmltv_bytes"); - - b.Property("XmltvEtag") - .HasColumnType("TEXT") - .HasColumnName("xmltv_etag"); - - b.Property("XmltvLastModified") - .HasColumnType("TEXT") - .HasColumnName("xmltv_last_modified"); - - b.HasKey("FetchRunId"); - - b.HasIndex("ProviderId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_provider_time"); - - b.HasIndex("Status", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_status"); - - b.ToTable("fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("MergeMode") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("merge_mode"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("OutputName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileId"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("profiles", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.Property("ProfileGroupChannelFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_channel_filter_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("OutputGroupName") - .HasColumnType("TEXT") - .HasColumnName("output_group_name"); - - b.Property("ProfileGroupFilterId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.HasKey("ProfileGroupChannelFilterId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProfileGroupFilterId", "ProviderChannelId") - .IsUnique() - .HasDatabaseName("idx_pgcf_filter_channel_unique"); - - b.ToTable("profile_group_channel_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Property("ProfileGroupFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("AutoNumEnd") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_end"); - - b.Property("AutoNumStart") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_start"); - - b.Property("ChannelMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("all") - .HasColumnName("channel_mode"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Decision") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("hold") - .HasColumnName("decision"); - - b.Property("IsNew") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_new"); - - b.Property("OutputName") - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("SortOverride") - .HasColumnType("INTEGER") - .HasColumnName("sort_override"); - - b.Property("TrackNewChannels") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("track_new_channels"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileGroupFilterId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProfileId", "Decision") - .HasDatabaseName("idx_pgf_profile_decision"); - - b.HasIndex("ProfileId", "ProviderGroupId") - .IsUnique() - .HasDatabaseName("idx_pgf_profile_group_unique"); - - b.ToTable("profile_group_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.HasKey("ProfileId", "ProviderId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ProfileId", "Priority") - .HasDatabaseName("idx_profile_providers_profile"); - - b.ToTable("profile_providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("ConfigSourcePath") - .HasColumnType("TEXT") - .HasColumnName("config_source_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("IncludeSeries") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_series"); - - b.Property("IncludeVod") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_vod"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_active"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("NeedsEnvVarSubstitution") - .HasColumnType("INTEGER") - .HasColumnName("needs_env_var_substitution"); - - b.Property("PlaylistUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_url"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(20) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.Property("XmltvUrl") - .HasColumnType("TEXT") - .HasColumnName("xmltv_url"); - - b.Property("XtreamBaseUrl") - .HasColumnType("TEXT") - .HasColumnName("xtream_base_url"); - - b.Property("XtreamEncryptedPassword") - .HasColumnType("TEXT") - .HasColumnName("xtream_encrypted_password"); - - b.Property("XtreamIncludeXmltv") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("xtream_include_xmltv"); - - b.Property("XtreamUsername") - .HasColumnType("TEXT") - .HasColumnName("xtream_username"); - - b.HasKey("ProviderId"); - - b.HasIndex("Enabled") - .HasDatabaseName("idx_providers_enabled"); - - b.HasIndex("IsActive") - .IsUnique() - .HasDatabaseName("idx_providers_is_active") - .HasFilter("is_active = 1"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Property("ProviderChannelId") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EventEndUtc") - .HasColumnType("TEXT") - .HasColumnName("event_end_utc"); - - b.Property("EventStartUtc") - .HasColumnType("TEXT") - .HasColumnName("event_start_utc"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("GroupTitle") - .HasColumnType("TEXT") - .HasColumnName("group_title"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LastFetchRunId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("last_fetch_run_id"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("ProviderChannelKey") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_key"); - - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StreamUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("stream_url"); - - b.Property("TvgId") - .HasColumnType("TEXT") - .HasColumnName("tvg_id"); - - b.Property("TvgName") - .HasColumnType("TEXT") - .HasColumnName("tvg_name"); - - b.HasKey("ProviderChannelId"); - - b.HasIndex("LastFetchRunId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_channels_provider_active"); - - b.HasIndex("ProviderId", "LastSeenUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_provider_channels_seen"); - - b.HasIndex("ProviderId", "ProviderChannelKey") - .IsUnique() - .HasFilter("provider_channel_key IS NOT NULL"); - - b.HasIndex("ProviderId", "IsEvent", "EventStartUtc") - .HasDatabaseName("idx_provider_channels_is_event"); - - b.ToTable("provider_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("channel_count"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("NormalizedName") - .HasColumnType("TEXT") - .HasColumnName("normalized_name"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("RawName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("raw_name"); - - b.HasKey("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_groups_provider_active"); - - b.HasIndex("ProviderId", "RawName") - .IsUnique(); - - b.ToTable("provider_groups", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.SiteSettings", b => - { - b.Property("Id") - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("AuthenticationEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("authentication_enabled"); - - b.Property("EndpointSecurityEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("endpoint_security_enabled"); - - b.HasKey("Id"); - - b.ToTable("site_settings", (string)null); - - b.HasData( - new - { - Id = 1, - AuthenticationEnabled = false, - EndpointSecurityEnabled = false - }); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.Property("SnapshotId") - .HasColumnType("TEXT") - .HasColumnName("snapshot_id"); - - b.Property("ChannelCountPublished") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_published"); - - b.Property("ChannelIndexPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_index_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("LiveChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("live_channel_count"); - - b.Property("PlaylistPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_path"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("SeriesChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("series_channel_count"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("StatusJsonPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status_json_path"); - - b.Property("VodChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("vod_channel_count"); - - b.Property("XmltvPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_path"); - - b.HasKey("SnapshotId"); - - b.HasIndex("ProfileId", "Status", "CreatedUtc") - .IsDescending(false, false, true) - .HasDatabaseName("idx_snapshots_profile_status"); - - b.ToTable("snapshots", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.Property("Value") - .HasColumnType("TEXT") - .HasColumnName("stream_key"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("LastUsedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_used_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Revoked") - .HasColumnType("INTEGER") - .HasColumnName("revoked"); - - b.HasKey("Value"); - - b.HasIndex("ChannelId") - .HasDatabaseName("idx_stream_keys_channel"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "Revoked") - .HasDatabaseName("idx_stream_keys_profile"); - - b.ToTable("stream_keys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderKey") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.Property("CredentialId") - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CredentialId"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserPasskeys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("CanonicalChannels") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ChannelMatchRules") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "TargetChannel") - .WithMany("TargetingMatchRules") - .HasForeignKey("TargetChannelId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - - b.Navigation("TargetChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("ChannelSources") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelSources") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ChannelSources") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Provider"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointAccessBinding", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "ActiveProfile") - .WithMany("ActiveEndpointAccessBindings") - .HasForeignKey("ActiveProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "DefaultProfile") - .WithMany("DefaultEndpointAccessBindings") - .HasForeignKey("DefaultProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.EndpointCredential", "Credential") - .WithMany("Bindings") - .HasForeignKey("EndpointCredentialId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ActiveProfile"); - - b.Navigation("Credential"); - - b.Navigation("DefaultProfile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("EpgChannelMaps") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("EpgChannelMaps") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("FetchRuns") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileGroupFilter", "ProfileGroupFilter") - .WithMany("ChannelFilters") - .HasForeignKey("ProfileGroupFilterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelFilters") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ProfileGroupFilter"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileProviders") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProfileProviders") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.FetchRun", "LastFetchRun") - .WithMany("ProviderChannels") - .HasForeignKey("LastFetchRunId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("LastFetchRun"); - - b.Navigation("Provider"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderGroups") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("Snapshots") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("StreamKeys") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("StreamKeys") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => - { - b1.Property("IdentityUserPasskeyCredentialId"); - - b1.Property("AttestationObject") - .IsRequired(); - - b1.Property("ClientDataJson") - .IsRequired(); - - b1.Property("CreatedAt"); - - b1.Property("IsBackedUp"); - - b1.Property("IsBackupEligible"); - - b1.Property("IsUserVerified"); - - b1.Property("Name"); - - b1.Property("PublicKey") - .IsRequired(); - - b1.Property("SignCount"); - - b1.PrimitiveCollection("Transports"); - - b1.HasKey("IdentityUserPasskeyCredentialId"); - - b1.ToTable("AspNetUserPasskeys"); - - b1.ToJson("Data"); - - b1.WithOwner() - .HasForeignKey("IdentityUserPasskeyCredentialId"); - }); - - b.Navigation("Data") - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("StreamKeys"); - - b.Navigation("TargetingMatchRules"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointCredential", b => - { - b.Navigation("Bindings"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Navigation("ProviderChannels"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Navigation("ActiveEndpointAccessBindings"); - - b.Navigation("CanonicalChannels"); - - b.Navigation("ChannelMatchRules"); - - b.Navigation("DefaultEndpointAccessBindings"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProfileProviders"); - - b.Navigation("Snapshots"); - - b.Navigation("StreamKeys"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Navigation("ChannelFilters"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("FetchRuns"); - - b.Navigation("ProfileProviders"); - - b.Navigation("ProviderChannels"); - - b.Navigation("ProviderGroups"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Navigation("ChannelFilters"); - - b.Navigation("ChannelSources"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProviderChannels"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260312183103_Alpha3_Schema.cs b/src/M3Undle.Web/Data/Migrations/20260312183103_Alpha3_Schema.cs deleted file mode 100644 index 9747e5f..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260312183103_Alpha3_Schema.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - /// - public partial class Alpha3_Schema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "endpoint_security_enabled", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.CreateTable( - name: "endpoint_credentials", - columns: table => new - { - endpoint_credential_id = table.Column(type: "TEXT", nullable: false), - username = table.Column(type: "TEXT", nullable: false), - normalized_username = table.Column(type: "TEXT", nullable: false), - password_hash = table.Column(type: "TEXT", nullable: false), - enabled = table.Column(type: "INTEGER", nullable: false), - auth_type = table.Column(type: "TEXT", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_endpoint_credentials", x => x.endpoint_credential_id); - }); - - migrationBuilder.CreateTable( - name: "endpoint_access_bindings", - columns: table => new - { - endpoint_access_binding_id = table.Column(type: "TEXT", nullable: false), - endpoint_credential_id = table.Column(type: "TEXT", nullable: false), - active_profile_id = table.Column(type: "TEXT", nullable: true), - default_profile_id = table.Column(type: "TEXT", nullable: true), - virtual_tuner_id = table.Column(type: "TEXT", nullable: true), - enabled = table.Column(type: "INTEGER", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_endpoint_access_bindings", x => x.endpoint_access_binding_id); - table.ForeignKey( - name: "FK_endpoint_access_bindings_endpoint_credentials_endpoint_credential_id", - column: x => x.endpoint_credential_id, - principalTable: "endpoint_credentials", - principalColumn: "endpoint_credential_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_endpoint_access_bindings_profiles_active_profile_id", - column: x => x.active_profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.SetNull); - table.ForeignKey( - name: "FK_endpoint_access_bindings_profiles_default_profile_id", - column: x => x.default_profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateIndex( - name: "idx_endpoint_access_bindings_active_profile", - table: "endpoint_access_bindings", - column: "active_profile_id"); - - migrationBuilder.CreateIndex( - name: "idx_endpoint_access_bindings_credential", - table: "endpoint_access_bindings", - column: "endpoint_credential_id", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_endpoint_access_bindings_default_profile_id", - table: "endpoint_access_bindings", - column: "default_profile_id"); - - migrationBuilder.CreateIndex( - name: "IX_endpoint_credentials_normalized_username", - table: "endpoint_credentials", - column: "normalized_username", - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_endpoint_credentials_username", - table: "endpoint_credentials", - column: "username", - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "endpoint_access_bindings"); - - migrationBuilder.DropTable( - name: "endpoint_credentials"); - - migrationBuilder.DropColumn( - name: "endpoint_security_enabled", - table: "site_settings"); - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260314145015_Alpha4_Schema.Designer.cs b/src/M3Undle.Web/Data/Migrations/20260314145015_Alpha4_Schema.Designer.cs deleted file mode 100644 index f64df93..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260314145015_Alpha4_Schema.Designer.cs +++ /dev/null @@ -1,2049 +0,0 @@ -// -using System; -using M3Undle.Web.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260314145015_Alpha4_Schema")] - partial class Alpha4_Schema - { - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); - - modelBuilder.Entity("M3Undle.Web.Data.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnd") - .HasColumnType("TEXT"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .HasColumnType("TEXT"); - - b.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); - - b.Property("SecurityStamp") - .HasColumnType("TEXT"); - - b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Property("ChannelId") - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EventPolicy") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("event_policy"); - - b.Property("GroupName") - .HasColumnType("TEXT") - .HasColumnName("group_name"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("Notes") - .HasColumnType("TEXT") - .HasColumnName("notes"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelId"); - - b.HasIndex("ProfileId", "ChannelNumber") - .IsUnique() - .HasDatabaseName("idx_canonical_channels_profile_number"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_canonical_channels_profile_enabled"); - - b.ToTable("canonical_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.Property("RuleId") - .HasColumnType("TEXT") - .HasColumnName("rule_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultPriority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1) - .HasColumnName("default_priority"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IsEventRule") - .HasColumnType("INTEGER") - .HasColumnName("is_event_rule"); - - b.Property("MatchType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_type"); - - b.Property("MatchValue") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_value"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("TargetChannelId") - .HasColumnType("TEXT") - .HasColumnName("target_channel_id"); - - b.Property("TargetGroupName") - .HasColumnType("TEXT") - .HasColumnName("target_group_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("RuleId"); - - b.HasIndex("TargetChannelId"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_match_rules_profile"); - - b.ToTable("channel_match_rules", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.Property("ChannelSourceId") - .HasColumnType("TEXT") - .HasColumnName("channel_source_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("FailureCountRolling") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0) - .HasColumnName("failure_count_rolling"); - - b.Property("HealthState") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("health_state"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("OverrideStreamUrl") - .HasColumnType("TEXT") - .HasColumnName("override_stream_url"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ChannelId", "Priority") - .IsUnique() - .HasDatabaseName("idx_channel_sources_channel"); - - b.HasIndex("HealthState", "LastFailureUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_channel_sources_health"); - - b.ToTable("channel_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointAccessBinding", b => - { - b.Property("EndpointAccessBindingId") - .HasColumnType("TEXT") - .HasColumnName("endpoint_access_binding_id"); - - b.Property("ActiveProfileId") - .HasColumnType("TEXT") - .HasColumnName("active_profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultProfileId") - .HasColumnType("TEXT") - .HasColumnName("default_profile_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EndpointCredentialId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("endpoint_credential_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("VirtualTunerId") - .HasColumnType("TEXT") - .HasColumnName("virtual_tuner_id"); - - b.HasKey("EndpointAccessBindingId"); - - b.HasIndex("ActiveProfileId") - .HasDatabaseName("idx_endpoint_access_bindings_active_profile"); - - b.HasIndex("DefaultProfileId"); - - b.HasIndex("EndpointCredentialId") - .IsUnique() - .HasDatabaseName("idx_endpoint_access_bindings_credential"); - - b.ToTable("endpoint_access_bindings", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointCredential", b => - { - b.Property("EndpointCredentialId") - .HasColumnType("TEXT") - .HasColumnName("endpoint_credential_id"); - - b.Property("AuthType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("auth_type"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("normalized_username"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("password_hash"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("Username") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("username"); - - b.HasKey("EndpointCredentialId"); - - b.HasIndex("NormalizedUsername") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("endpoint_credentials", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.Property("EpgMapId") - .HasColumnType("TEXT") - .HasColumnName("epg_map_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Source") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("source"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgMapId"); - - b.HasIndex("ChannelId"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_map_profile"); - - b.ToTable("epg_channel_map", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMapping", b => - { - b.Property("EpgChannelMappingId") - .HasColumnType("TEXT") - .HasColumnName("epg_channel_mapping_id"); - - b.Property("Confidence") - .ValueGeneratedOnAdd() - .HasColumnType("REAL") - .HasDefaultValue(1f) - .HasColumnName("confidence"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("MappingMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("auto_id") - .HasColumnName("mapping_mode"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgChannelMappingId"); - - b.HasIndex("EpgSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProfileId", "ProviderChannelId", "EpgSourceId") - .IsUnique() - .HasDatabaseName("idx_epg_channel_mappings_profile_channel_source"); - - b.ToTable("epg_channel_mappings", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgFetchRun", b => - { - b.Property("EpgFetchRunId") - .HasColumnType("TEXT") - .HasColumnName("epg_fetch_run_id"); - - b.Property("Bytes") - .HasColumnType("INTEGER") - .HasColumnName("bytes"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("channel_count"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("ProgrammeCount") - .HasColumnType("INTEGER") - .HasColumnName("programme_count"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.HasKey("EpgFetchRunId"); - - b.HasIndex("EpgSourceId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_epg_fetch_runs_source_time"); - - b.ToTable("epg_fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.Property("EpgSourceId") - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ETag") - .HasColumnType("TEXT") - .HasColumnName("etag"); - - b.Property("Enabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("enabled"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("Kind") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("xmltv_url") - .HasColumnName("kind"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastModifiedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_modified_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(10) - .HasColumnName("priority"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(30) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UrlOrPath") - .HasColumnType("TEXT") - .HasColumnName("url_or_path"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.HasKey("EpgSourceId"); - - b.HasIndex("ProviderId") - .HasDatabaseName("idx_epg_sources_provider"); - - b.HasIndex("ProviderId", "Priority") - .HasDatabaseName("idx_epg_sources_provider_priority"); - - b.ToTable("epg_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSourceChannel", b => - { - b.Property("EpgSourceChannelId") - .HasColumnType("TEXT") - .HasColumnName("epg_source_channel_id"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("IconUrl") - .HasColumnType("TEXT") - .HasColumnName("icon_url"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgSourceChannelId"); - - b.HasIndex("EpgSourceId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_source_channels_source_channel"); - - b.ToTable("epg_source_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Property("FetchRunId") - .HasColumnType("TEXT") - .HasColumnName("fetch_run_id"); - - b.Property("ChannelCountSeen") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_seen"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("PlaylistBytes") - .HasColumnType("INTEGER") - .HasColumnName("playlist_bytes"); - - b.Property("PlaylistEtag") - .HasColumnType("TEXT") - .HasColumnName("playlist_etag"); - - b.Property("PlaylistLastModified") - .HasColumnType("TEXT") - .HasColumnName("playlist_last_modified"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("Type") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("snapshot") - .HasColumnName("type"); - - b.Property("XmltvBytes") - .HasColumnType("INTEGER") - .HasColumnName("xmltv_bytes"); - - b.Property("XmltvEtag") - .HasColumnType("TEXT") - .HasColumnName("xmltv_etag"); - - b.Property("XmltvLastModified") - .HasColumnType("TEXT") - .HasColumnName("xmltv_last_modified"); - - b.HasKey("FetchRunId"); - - b.HasIndex("ProviderId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_provider_time"); - - b.HasIndex("Status", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_status"); - - b.ToTable("fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("MergeMode") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("merge_mode"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("OutputName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileId"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("profiles", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.Property("ProfileGroupChannelFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_channel_filter_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("OutputGroupName") - .HasColumnType("TEXT") - .HasColumnName("output_group_name"); - - b.Property("ProfileGroupFilterId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.HasKey("ProfileGroupChannelFilterId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProfileGroupFilterId", "ProviderChannelId") - .IsUnique() - .HasDatabaseName("idx_pgcf_filter_channel_unique"); - - b.ToTable("profile_group_channel_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Property("ProfileGroupFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("AutoNumEnd") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_end"); - - b.Property("AutoNumStart") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_start"); - - b.Property("ChannelMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("all") - .HasColumnName("channel_mode"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Decision") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("hold") - .HasColumnName("decision"); - - b.Property("IsNew") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_new"); - - b.Property("OutputName") - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("SortOverride") - .HasColumnType("INTEGER") - .HasColumnName("sort_override"); - - b.Property("TrackNewChannels") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("track_new_channels"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileGroupFilterId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProfileId", "Decision") - .HasDatabaseName("idx_pgf_profile_decision"); - - b.HasIndex("ProfileId", "ProviderGroupId") - .IsUnique() - .HasDatabaseName("idx_pgf_profile_group_unique"); - - b.ToTable("profile_group_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.HasKey("ProfileId", "ProviderId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ProfileId", "Priority") - .HasDatabaseName("idx_profile_providers_profile"); - - b.ToTable("profile_providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("ConfigSourcePath") - .HasColumnType("TEXT") - .HasColumnName("config_source_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("IncludeSeries") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_series"); - - b.Property("IncludeVod") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_vod"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_active"); - - b.Property("MaxConcurrentStreams") - .HasColumnType("INTEGER") - .HasColumnName("max_concurrent_streams"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("NeedsEnvVarSubstitution") - .HasColumnType("INTEGER") - .HasColumnName("needs_env_var_substitution"); - - b.Property("PlaylistUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_url"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(20) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.Property("XmltvUrl") - .HasColumnType("TEXT") - .HasColumnName("xmltv_url"); - - b.Property("XtreamBaseUrl") - .HasColumnType("TEXT") - .HasColumnName("xtream_base_url"); - - b.Property("XtreamEncryptedPassword") - .HasColumnType("TEXT") - .HasColumnName("xtream_encrypted_password"); - - b.Property("XtreamIncludeXmltv") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("xtream_include_xmltv"); - - b.Property("XtreamUsername") - .HasColumnType("TEXT") - .HasColumnName("xtream_username"); - - b.HasKey("ProviderId"); - - b.HasIndex("Enabled") - .HasDatabaseName("idx_providers_enabled"); - - b.HasIndex("IsActive") - .IsUnique() - .HasDatabaseName("idx_providers_is_active") - .HasFilter("is_active = 1"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Property("ProviderChannelId") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EventEndUtc") - .HasColumnType("TEXT") - .HasColumnName("event_end_utc"); - - b.Property("EventStartUtc") - .HasColumnType("TEXT") - .HasColumnName("event_start_utc"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("GroupTitle") - .HasColumnType("TEXT") - .HasColumnName("group_title"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LastFetchRunId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("last_fetch_run_id"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("ProviderChannelKey") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_key"); - - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StreamUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("stream_url"); - - b.Property("TvgId") - .HasColumnType("TEXT") - .HasColumnName("tvg_id"); - - b.Property("TvgName") - .HasColumnType("TEXT") - .HasColumnName("tvg_name"); - - b.HasKey("ProviderChannelId"); - - b.HasIndex("LastFetchRunId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_channels_provider_active"); - - b.HasIndex("ProviderId", "LastSeenUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_provider_channels_seen"); - - b.HasIndex("ProviderId", "ProviderChannelKey") - .IsUnique() - .HasFilter("provider_channel_key IS NOT NULL"); - - b.HasIndex("ProviderId", "IsEvent", "EventStartUtc") - .HasDatabaseName("idx_provider_channels_is_event"); - - b.ToTable("provider_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("channel_count"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("NormalizedName") - .HasColumnType("TEXT") - .HasColumnName("normalized_name"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("RawName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("raw_name"); - - b.HasKey("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_groups_provider_active"); - - b.HasIndex("ProviderId", "RawName") - .IsUnique(); - - b.ToTable("provider_groups", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.SiteSettings", b => - { - b.Property("Id") - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("AuthenticationEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("authentication_enabled"); - - b.Property("EndpointSecurityEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("endpoint_security_enabled"); - - b.Property("StreamBufferMaxBytesHardCap") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(33554432) - .HasColumnName("stream_buffer_max_bytes_hard_cap"); - - b.Property("StreamBufferMaxBytesPerSession") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(4194304) - .HasColumnName("stream_buffer_max_bytes_per_session"); - - b.Property("StreamBufferReadChunkSizeBytes") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(32768) - .HasColumnName("stream_buffer_read_chunk_size_bytes"); - - b.Property("StreamIdleGraceHardCapSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(120) - .HasColumnName("stream_idle_grace_hard_cap_seconds"); - - b.Property("StreamIdleGraceSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(15) - .HasColumnName("stream_idle_grace_seconds"); - - b.Property("StreamMaxConcurrentSessions") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(50) - .HasColumnName("stream_max_concurrent_sessions"); - - b.Property("StreamReconnectConnectTimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(15) - .HasColumnName("stream_reconnect_connect_timeout_seconds"); - - b.Property("StreamReconnectOutageWindowSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(75) - .HasColumnName("stream_reconnect_outage_window_seconds"); - - b.Property("StreamReconnectReadStallTimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(30) - .HasColumnName("stream_reconnect_read_stall_timeout_seconds"); - - b.Property("StreamingEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("streaming_enabled"); - - b.Property("StreamingSettingsRestartRequired") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("streaming_settings_restart_required"); - - b.HasKey("Id"); - - b.ToTable("site_settings", (string)null); - - b.HasData( - new - { - Id = 1, - AuthenticationEnabled = false, - EndpointSecurityEnabled = false, - StreamBufferMaxBytesHardCap = 33554432, - StreamBufferMaxBytesPerSession = 4194304, - StreamBufferReadChunkSizeBytes = 32768, - StreamIdleGraceHardCapSeconds = 120, - StreamIdleGraceSeconds = 15, - StreamMaxConcurrentSessions = 50, - StreamReconnectConnectTimeoutSeconds = 15, - StreamReconnectOutageWindowSeconds = 75, - StreamReconnectReadStallTimeoutSeconds = 30, - StreamingEnabled = true, - StreamingSettingsRestartRequired = false - }); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.Property("SnapshotId") - .HasColumnType("TEXT") - .HasColumnName("snapshot_id"); - - b.Property("ChannelCountPublished") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_published"); - - b.Property("ChannelIndexPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_index_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("LiveChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("live_channel_count"); - - b.Property("PlaylistPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_path"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("SeriesChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("series_channel_count"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("StatusJsonPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status_json_path"); - - b.Property("VodChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("vod_channel_count"); - - b.Property("XmltvPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_path"); - - b.HasKey("SnapshotId"); - - b.HasIndex("ProfileId", "Status", "CreatedUtc") - .IsDescending(false, false, true) - .HasDatabaseName("idx_snapshots_profile_status"); - - b.ToTable("snapshots", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.Property("Value") - .HasColumnType("TEXT") - .HasColumnName("stream_key"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("LastUsedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_used_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Revoked") - .HasColumnType("INTEGER") - .HasColumnName("revoked"); - - b.HasKey("Value"); - - b.HasIndex("ChannelId") - .HasDatabaseName("idx_stream_keys_channel"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "Revoked") - .HasDatabaseName("idx_stream_keys_profile"); - - b.ToTable("stream_keys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderKey") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.Property("CredentialId") - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CredentialId"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserPasskeys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("CanonicalChannels") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ChannelMatchRules") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "TargetChannel") - .WithMany("TargetingMatchRules") - .HasForeignKey("TargetChannelId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - - b.Navigation("TargetChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("ChannelSources") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelSources") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ChannelSources") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Provider"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointAccessBinding", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "ActiveProfile") - .WithMany("ActiveEndpointAccessBindings") - .HasForeignKey("ActiveProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "DefaultProfile") - .WithMany("DefaultEndpointAccessBindings") - .HasForeignKey("DefaultProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.EndpointCredential", "Credential") - .WithMany("Bindings") - .HasForeignKey("EndpointCredentialId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ActiveProfile"); - - b.Navigation("Credential"); - - b.Navigation("DefaultProfile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("EpgChannelMaps") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("EpgChannelMaps") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMapping", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("ChannelMappings") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany() - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany() - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - - b.Navigation("Profile"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgFetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("FetchRuns") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany() - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Cascade); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSourceChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("Channels") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("FetchRuns") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileGroupFilter", "ProfileGroupFilter") - .WithMany("ChannelFilters") - .HasForeignKey("ProfileGroupFilterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelFilters") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ProfileGroupFilter"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileProviders") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProfileProviders") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.FetchRun", "LastFetchRun") - .WithMany("ProviderChannels") - .HasForeignKey("LastFetchRunId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("LastFetchRun"); - - b.Navigation("Provider"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderGroups") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("Snapshots") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("StreamKeys") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("StreamKeys") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => - { - b1.Property("IdentityUserPasskeyCredentialId"); - - b1.Property("AttestationObject") - .IsRequired(); - - b1.Property("ClientDataJson") - .IsRequired(); - - b1.Property("CreatedAt"); - - b1.Property("IsBackedUp"); - - b1.Property("IsBackupEligible"); - - b1.Property("IsUserVerified"); - - b1.Property("Name"); - - b1.Property("PublicKey") - .IsRequired(); - - b1.Property("SignCount"); - - b1.PrimitiveCollection("Transports"); - - b1.HasKey("IdentityUserPasskeyCredentialId"); - - b1.ToTable("AspNetUserPasskeys"); - - b1.ToJson("Data"); - - b1.WithOwner() - .HasForeignKey("IdentityUserPasskeyCredentialId"); - }); - - b.Navigation("Data") - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("StreamKeys"); - - b.Navigation("TargetingMatchRules"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointCredential", b => - { - b.Navigation("Bindings"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.Navigation("ChannelMappings"); - - b.Navigation("Channels"); - - b.Navigation("FetchRuns"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Navigation("ProviderChannels"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Navigation("ActiveEndpointAccessBindings"); - - b.Navigation("CanonicalChannels"); - - b.Navigation("ChannelMatchRules"); - - b.Navigation("DefaultEndpointAccessBindings"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProfileProviders"); - - b.Navigation("Snapshots"); - - b.Navigation("StreamKeys"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Navigation("ChannelFilters"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("FetchRuns"); - - b.Navigation("ProfileProviders"); - - b.Navigation("ProviderChannels"); - - b.Navigation("ProviderGroups"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Navigation("ChannelFilters"); - - b.Navigation("ChannelSources"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProviderChannels"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260314145015_Alpha4_Schema.cs b/src/M3Undle.Web/Data/Migrations/20260314145015_Alpha4_Schema.cs deleted file mode 100644 index 646c2ab..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260314145015_Alpha4_Schema.cs +++ /dev/null @@ -1,356 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - /// - public partial class Alpha4_Schema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - // From Alpha4_StreamingSettings - migrationBuilder.AddColumn( - name: "streaming_enabled", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: true); - - migrationBuilder.UpdateData( - table: "site_settings", - keyColumn: "id", - keyValue: 1, - column: "streaming_enabled", - value: true); - - // From Alpha4_ProviderStreamLimits - migrationBuilder.AddColumn( - name: "max_concurrent_streams", - table: "providers", - type: "INTEGER", - nullable: true); - - // From Alpha4_EpgSources - migrationBuilder.CreateTable( - name: "epg_sources", - columns: table => new - { - epg_source_id = table.Column(type: "TEXT", nullable: false), - provider_id = table.Column(type: "TEXT", nullable: true), - name = table.Column(type: "TEXT", nullable: false), - kind = table.Column(type: "TEXT", nullable: false, defaultValue: "xmltv_url"), - url_or_path = table.Column(type: "TEXT", nullable: true), - priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 10), - enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), - headers_json = table.Column(type: "TEXT", nullable: true), - user_agent = table.Column(type: "TEXT", nullable: true), - timeout_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 30), - etag = table.Column(type: "TEXT", nullable: true), - last_modified_utc = table.Column(type: "TEXT", nullable: true), - last_success_utc = table.Column(type: "TEXT", nullable: true), - last_failure_utc = table.Column(type: "TEXT", nullable: true), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_epg_sources", x => x.epg_source_id); - table.ForeignKey( - name: "FK_epg_sources_providers_provider_id", - column: x => x.provider_id, - principalTable: "providers", - principalColumn: "provider_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "epg_channel_mappings", - columns: table => new - { - epg_channel_mapping_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - provider_channel_id = table.Column(type: "TEXT", nullable: false), - epg_source_id = table.Column(type: "TEXT", nullable: false), - xmltv_channel_id = table.Column(type: "TEXT", nullable: false), - mapping_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "auto_id"), - confidence = table.Column(type: "REAL", nullable: false, defaultValue: 1f), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_epg_channel_mappings", x => x.epg_channel_mapping_id); - table.ForeignKey( - name: "FK_epg_channel_mappings_epg_sources_epg_source_id", - column: x => x.epg_source_id, - principalTable: "epg_sources", - principalColumn: "epg_source_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_epg_channel_mappings_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_epg_channel_mappings_provider_channels_provider_channel_id", - column: x => x.provider_channel_id, - principalTable: "provider_channels", - principalColumn: "provider_channel_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "epg_fetch_runs", - columns: table => new - { - epg_fetch_run_id = table.Column(type: "TEXT", nullable: false), - epg_source_id = table.Column(type: "TEXT", nullable: false), - started_utc = table.Column(type: "TEXT", nullable: false), - finished_utc = table.Column(type: "TEXT", nullable: true), - status = table.Column(type: "TEXT", nullable: false), - bytes = table.Column(type: "INTEGER", nullable: true), - channel_count = table.Column(type: "INTEGER", nullable: true), - programme_count = table.Column(type: "INTEGER", nullable: true), - error_summary = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_epg_fetch_runs", x => x.epg_fetch_run_id); - table.ForeignKey( - name: "FK_epg_fetch_runs_epg_sources_epg_source_id", - column: x => x.epg_source_id, - principalTable: "epg_sources", - principalColumn: "epg_source_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "epg_source_channels", - columns: table => new - { - epg_source_channel_id = table.Column(type: "TEXT", nullable: false), - epg_source_id = table.Column(type: "TEXT", nullable: false), - xmltv_channel_id = table.Column(type: "TEXT", nullable: false), - display_name = table.Column(type: "TEXT", nullable: false), - icon_url = table.Column(type: "TEXT", nullable: true), - last_seen_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_epg_source_channels", x => x.epg_source_channel_id); - table.ForeignKey( - name: "FK_epg_source_channels_epg_sources_epg_source_id", - column: x => x.epg_source_id, - principalTable: "epg_sources", - principalColumn: "epg_source_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "idx_epg_channel_mappings_profile_channel_source", - table: "epg_channel_mappings", - columns: new[] { "profile_id", "provider_channel_id", "epg_source_id" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_epg_channel_mappings_epg_source_id", - table: "epg_channel_mappings", - column: "epg_source_id"); - - migrationBuilder.CreateIndex( - name: "IX_epg_channel_mappings_provider_channel_id", - table: "epg_channel_mappings", - column: "provider_channel_id"); - - migrationBuilder.CreateIndex( - name: "idx_epg_fetch_runs_source_time", - table: "epg_fetch_runs", - columns: new[] { "epg_source_id", "started_utc" }, - descending: new[] { false, true }); - - migrationBuilder.CreateIndex( - name: "idx_epg_source_channels_source_channel", - table: "epg_source_channels", - columns: new[] { "epg_source_id", "xmltv_channel_id" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_epg_sources_provider", - table: "epg_sources", - column: "provider_id"); - - migrationBuilder.CreateIndex( - name: "idx_epg_sources_provider_priority", - table: "epg_sources", - columns: new[] { "provider_id", "priority" }); - - // Backfill: create one epg_sources row for each provider that has an xmltv_url configured. - // Xtream providers with XtreamIncludeXmltv get kind=provider_xmltv (URL resolved at fetch time). - // Regular providers with xmltv_url get kind=xmltv_url with the URL stored directly. - migrationBuilder.Sql(@" -INSERT INTO epg_sources ( - epg_source_id, provider_id, name, kind, url_or_path, priority, enabled, - timeout_seconds, created_utc, updated_utc) -SELECT - lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' || - lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' || - lower(hex(randomblob(6))), - provider_id, - 'Provider XMLTV', - CASE WHEN xtream_base_url IS NOT NULL THEN 'provider_xmltv' ELSE 'xmltv_url' END, - CASE WHEN xtream_base_url IS NULL THEN xmltv_url ELSE NULL END, - 1, - 1, - 30, - datetime('now'), - datetime('now') -FROM providers -WHERE xmltv_url IS NOT NULL - OR (xtream_base_url IS NOT NULL AND xtream_include_xmltv = 1); -"); - - // From Alpha4_StreamSettingsUi - migrationBuilder.AddColumn( - name: "stream_buffer_max_bytes_hard_cap", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 33554432); - - migrationBuilder.AddColumn( - name: "stream_buffer_max_bytes_per_session", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 4194304); - - migrationBuilder.AddColumn( - name: "stream_buffer_read_chunk_size_bytes", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 32768); - - migrationBuilder.AddColumn( - name: "stream_idle_grace_hard_cap_seconds", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 120); - - migrationBuilder.AddColumn( - name: "stream_idle_grace_seconds", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 15); - - migrationBuilder.AddColumn( - name: "stream_max_concurrent_sessions", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 50); - - migrationBuilder.AddColumn( - name: "stream_reconnect_connect_timeout_seconds", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 15); - - migrationBuilder.AddColumn( - name: "stream_reconnect_outage_window_seconds", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 75); - - migrationBuilder.AddColumn( - name: "stream_reconnect_read_stall_timeout_seconds", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 30); - - migrationBuilder.AddColumn( - name: "streaming_settings_restart_required", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: false); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - // Reverse Alpha4_StreamSettingsUi - migrationBuilder.DropColumn( - name: "stream_buffer_max_bytes_hard_cap", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "stream_buffer_max_bytes_per_session", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "stream_buffer_read_chunk_size_bytes", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "stream_idle_grace_hard_cap_seconds", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "stream_idle_grace_seconds", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "stream_max_concurrent_sessions", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "stream_reconnect_connect_timeout_seconds", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "stream_reconnect_outage_window_seconds", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "stream_reconnect_read_stall_timeout_seconds", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "streaming_settings_restart_required", - table: "site_settings"); - - // Reverse Alpha4_EpgSources - migrationBuilder.DropTable( - name: "epg_channel_mappings"); - - migrationBuilder.DropTable( - name: "epg_fetch_runs"); - - migrationBuilder.DropTable( - name: "epg_source_channels"); - - migrationBuilder.DropTable( - name: "epg_sources"); - - // Reverse Alpha4_ProviderStreamLimits - migrationBuilder.DropColumn( - name: "max_concurrent_streams", - table: "providers"); - - // Reverse Alpha4_StreamingSettings - migrationBuilder.DropColumn( - name: "streaming_enabled", - table: "site_settings"); - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260322000000_Alpha5_Schema.Designer.cs b/src/M3Undle.Web/Data/Migrations/20260322000000_Alpha5_Schema.Designer.cs deleted file mode 100644 index 045887f..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260322000000_Alpha5_Schema.Designer.cs +++ /dev/null @@ -1,2626 +0,0 @@ -// -using System; -using M3Undle.Web.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260322000000_Alpha5_Schema")] - partial class Alpha5_Schema - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("M3Undle.Web.Data.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); - - b.Property("AdaptiveLockoutEscalated") - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnd") - .HasColumnType("TEXT"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .HasColumnType("TEXT"); - - b.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); - - b.Property("SecurityStamp") - .HasColumnType("TEXT"); - - b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Property("ChannelId") - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EventPolicy") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("event_policy"); - - b.Property("GroupName") - .HasColumnType("TEXT") - .HasColumnName("group_name"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("Notes") - .HasColumnType("TEXT") - .HasColumnName("notes"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelId"); - - b.HasIndex("ProfileId", "ChannelNumber") - .IsUnique() - .HasDatabaseName("idx_canonical_channels_profile_number"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_canonical_channels_profile_enabled"); - - b.ToTable("canonical_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.Property("RuleId") - .HasColumnType("TEXT") - .HasColumnName("rule_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultPriority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1) - .HasColumnName("default_priority"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IsEventRule") - .HasColumnType("INTEGER") - .HasColumnName("is_event_rule"); - - b.Property("MatchType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_type"); - - b.Property("MatchValue") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_value"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("TargetChannelId") - .HasColumnType("TEXT") - .HasColumnName("target_channel_id"); - - b.Property("TargetGroupName") - .HasColumnType("TEXT") - .HasColumnName("target_group_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("RuleId"); - - b.HasIndex("TargetChannelId"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_match_rules_profile"); - - b.ToTable("channel_match_rules", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.Property("ChannelSourceId") - .HasColumnType("TEXT") - .HasColumnName("channel_source_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("FailureCountRolling") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0) - .HasColumnName("failure_count_rolling"); - - b.Property("HealthState") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("health_state"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("OverrideStreamUrl") - .HasColumnType("TEXT") - .HasColumnName("override_stream_url"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ChannelId", "Priority") - .IsUnique() - .HasDatabaseName("idx_channel_sources_channel"); - - b.HasIndex("HealthState", "LastFailureUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_channel_sources_health"); - - b.ToTable("channel_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.DownstreamIntegration", b => - { - b.Property("DownstreamIntegrationId") - .HasColumnType("TEXT") - .HasColumnName("downstream_integration_id"); - - b.Property("ApiKeyEncrypted") - .HasColumnType("TEXT") - .HasColumnName("api_key_encrypted"); - - b.Property("BaseUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("base_url"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("enabled"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("kind"); - - b.Property("LastNotifiedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_notified_utc"); - - b.Property("LastNotifyError") - .HasColumnType("TEXT") - .HasColumnName("last_notify_error"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("TriggerOnGuideUpdate") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("trigger_on_guide_update"); - - b.Property("TriggerOnLineupUpdate") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("trigger_on_lineup_update"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("WebhookHeadersJson") - .HasColumnType("TEXT") - .HasColumnName("webhook_headers_json"); - - b.HasKey("DownstreamIntegrationId"); - - b.HasIndex("ProfileId") - .HasDatabaseName("idx_downstream_integrations_profile"); - - b.ToTable("downstream_integrations", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointAccessBinding", b => - { - b.Property("EndpointAccessBindingId") - .HasColumnType("TEXT") - .HasColumnName("endpoint_access_binding_id"); - - b.Property("ActiveProfileId") - .HasColumnType("TEXT") - .HasColumnName("active_profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultProfileId") - .HasColumnType("TEXT") - .HasColumnName("default_profile_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EndpointCredentialId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("endpoint_credential_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("VirtualTunerId") - .HasColumnType("TEXT") - .HasColumnName("virtual_tuner_id"); - - b.HasKey("EndpointAccessBindingId"); - - b.HasIndex("ActiveProfileId") - .HasDatabaseName("idx_endpoint_access_bindings_active_profile"); - - b.HasIndex("DefaultProfileId"); - - b.HasIndex("EndpointCredentialId") - .IsUnique() - .HasDatabaseName("idx_endpoint_access_bindings_credential"); - - b.ToTable("endpoint_access_bindings", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointCredential", b => - { - b.Property("EndpointCredentialId") - .HasColumnType("TEXT") - .HasColumnName("endpoint_credential_id"); - - b.Property("AuthType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("auth_type"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("normalized_username"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("password_hash"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("Username") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("username"); - - b.HasKey("EndpointCredentialId"); - - b.HasIndex("NormalizedUsername") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("endpoint_credentials", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.Property("EpgMapId") - .HasColumnType("TEXT") - .HasColumnName("epg_map_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Source") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("source"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgMapId"); - - b.HasIndex("ChannelId"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_map_profile"); - - b.ToTable("epg_channel_map", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMapping", b => - { - b.Property("EpgChannelMappingId") - .HasColumnType("TEXT") - .HasColumnName("epg_channel_mapping_id"); - - b.Property("Confidence") - .ValueGeneratedOnAdd() - .HasColumnType("REAL") - .HasDefaultValue(1f) - .HasColumnName("confidence"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("MappingMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("auto_id") - .HasColumnName("mapping_mode"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgChannelMappingId"); - - b.HasIndex("EpgSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProfileId", "ProviderChannelId", "EpgSourceId") - .IsUnique() - .HasDatabaseName("idx_epg_channel_mappings_profile_channel_source"); - - b.ToTable("epg_channel_mappings", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgFetchRun", b => - { - b.Property("EpgFetchRunId") - .HasColumnType("TEXT") - .HasColumnName("epg_fetch_run_id"); - - b.Property("Bytes") - .HasColumnType("INTEGER") - .HasColumnName("bytes"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("channel_count"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("ProgrammeCount") - .HasColumnType("INTEGER") - .HasColumnName("programme_count"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.HasKey("EpgFetchRunId"); - - b.HasIndex("EpgSourceId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_epg_fetch_runs_source_time"); - - b.ToTable("epg_fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.Property("EpgSourceId") - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ETag") - .HasColumnType("TEXT") - .HasColumnName("etag"); - - b.Property("Enabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("enabled"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("Kind") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("xmltv_url") - .HasColumnName("kind"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastModifiedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_modified_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(10) - .HasColumnName("priority"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("RefreshIntervalHours") - .HasColumnType("INTEGER") - .HasColumnName("refresh_interval_hours"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(30) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UrlOrPath") - .HasColumnType("TEXT") - .HasColumnName("url_or_path"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.HasKey("EpgSourceId"); - - b.HasIndex("ProviderId") - .HasDatabaseName("idx_epg_sources_provider"); - - b.HasIndex("ProviderId", "Priority") - .HasDatabaseName("idx_epg_sources_provider_priority"); - - b.ToTable("epg_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSourceChannel", b => - { - b.Property("EpgSourceChannelId") - .HasColumnType("TEXT") - .HasColumnName("epg_source_channel_id"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("IconUrl") - .HasColumnType("TEXT") - .HasColumnName("icon_url"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgSourceChannelId"); - - b.HasIndex("EpgSourceId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_source_channels_source_channel"); - - b.ToTable("epg_source_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Property("FetchRunId") - .HasColumnType("TEXT") - .HasColumnName("fetch_run_id"); - - b.Property("ChannelCountSeen") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_seen"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("PlaylistBytes") - .HasColumnType("INTEGER") - .HasColumnName("playlist_bytes"); - - b.Property("PlaylistEtag") - .HasColumnType("TEXT") - .HasColumnName("playlist_etag"); - - b.Property("PlaylistLastModified") - .HasColumnType("TEXT") - .HasColumnName("playlist_last_modified"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("Type") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("snapshot") - .HasColumnName("type"); - - b.Property("XmltvBytes") - .HasColumnType("INTEGER") - .HasColumnName("xmltv_bytes"); - - b.Property("XmltvEtag") - .HasColumnType("TEXT") - .HasColumnName("xmltv_etag"); - - b.Property("XmltvLastModified") - .HasColumnType("TEXT") - .HasColumnName("xmltv_last_modified"); - - b.HasKey("FetchRunId"); - - b.HasIndex("ProviderId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_provider_time"); - - b.HasIndex("Status", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_status"); - - b.ToTable("fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_active"); - - b.Property("MergeMode") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("merge_mode"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("OutputName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileId"); - - b.HasIndex("IsActive") - .IsUnique() - .HasDatabaseName("idx_profiles_is_active") - .HasFilter("is_active = 1"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("profiles", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroup", b => - { - b.Property("CustomGroupId") - .HasColumnType("TEXT") - .HasColumnName("custom_group_id"); - - b.Property("AutoNumEnd") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_end"); - - b.Property("AutoNumStart") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_start"); - - b.Property("ChannelMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("select") - .HasColumnName("channel_mode"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Decision") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("include") - .HasColumnName("decision"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("SortOverride") - .HasColumnType("INTEGER") - .HasColumnName("sort_override"); - - b.Property("TrackNewChannels") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("track_new_channels"); - - b.Property("TrackingKeywords") - .HasColumnType("TEXT") - .HasColumnName("tracking_keywords"); - - b.Property("TrackingPolicy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("review") - .HasColumnName("tracking_policy"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("CustomGroupId"); - - b.HasIndex("ProfileId") - .HasDatabaseName("idx_pcg_profile_id"); - - b.HasIndex("ProfileId", "Name") - .IsUnique() - .HasDatabaseName("idx_pcg_profile_name_unique"); - - b.ToTable("profile_custom_groups", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroupChannel", b => - { - b.Property("CustomGroupChannelId") - .HasColumnType("TEXT") - .HasColumnName("custom_group_channel_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("CustomGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("custom_group_id"); - - b.Property("DisplayNameOverride") - .HasColumnType("TEXT") - .HasColumnName("display_name_override"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("State") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("included") - .HasColumnName("state"); - - b.Property("TvgIdOverride") - .HasColumnType("TEXT") - .HasColumnName("tvg_id_override"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("CustomGroupChannelId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("CustomGroupId", "ProviderChannelId") - .IsUnique() - .HasDatabaseName("idx_pcgc_group_channel_unique"); - - b.HasIndex("CustomGroupId", "State") - .HasDatabaseName("idx_pcgc_group_state"); - - b.ToTable("profile_custom_group_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroupProviderLink", b => - { - b.Property("LinkId") - .HasColumnType("TEXT") - .HasColumnName("link_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("CustomGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("custom_group_id"); - - b.Property("ProviderGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.HasKey("LinkId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("CustomGroupId", "ProviderGroupId") - .IsUnique() - .HasDatabaseName("idx_pcgpl_group_provider_unique"); - - b.ToTable("profile_custom_group_provider_links", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileEventInterestRule", b => - { - b.Property("RuleId") - .HasColumnType("TEXT") - .HasColumnName("rule_id"); - - b.Property("Action") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("action"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("enabled"); - - b.Property("MatchType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_type"); - - b.Property("MatchValue") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_value"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(100) - .HasColumnName("priority"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("RuleId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ProfileId", "Enabled", "Priority") - .HasDatabaseName("idx_peir_profile_enabled_priority"); - - b.ToTable("profile_event_interest_rules", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.Property("ProfileGroupChannelFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_channel_filter_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DisplayNameOverride") - .HasColumnType("TEXT") - .HasColumnName("display_name_override"); - - b.Property("OutputGroupName") - .HasColumnType("TEXT") - .HasColumnName("output_group_name"); - - b.Property("ProfileGroupFilterId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("State") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("included") - .HasColumnName("state"); - - b.Property("TvgIdOverride") - .HasColumnType("TEXT") - .HasColumnName("tvg_id_override"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileGroupChannelFilterId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProfileGroupFilterId", "ProviderChannelId") - .IsUnique() - .HasDatabaseName("idx_pgcf_filter_channel_unique"); - - b.ToTable("profile_group_channel_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Property("ProfileGroupFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("AutoNumEnd") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_end"); - - b.Property("AutoNumStart") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_start"); - - b.Property("ChannelMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("all") - .HasColumnName("channel_mode"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Decision") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("include") - .HasColumnName("decision"); - - b.Property("IsNew") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_new"); - - b.Property("OutputName") - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("SortOverride") - .HasColumnType("INTEGER") - .HasColumnName("sort_override"); - - b.Property("TrackNewChannels") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("track_new_channels"); - - b.Property("TrackingKeywords") - .HasColumnType("TEXT") - .HasColumnName("tracking_keywords"); - - b.Property("TrackingPolicy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("review") - .HasColumnName("tracking_policy"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileGroupFilterId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProfileId", "Decision") - .HasDatabaseName("idx_pgf_profile_decision"); - - b.HasIndex("ProfileId", "ProviderGroupId") - .IsUnique() - .HasDatabaseName("idx_pgf_profile_group_unique"); - - b.HasIndex("ProfileId", "TrackingPolicy") - .HasDatabaseName("idx_pgf_profile_tracking_policy"); - - b.ToTable("profile_group_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.HasKey("ProfileId", "ProviderId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ProfileId", "Priority") - .HasDatabaseName("idx_profile_providers_profile"); - - b.ToTable("profile_providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("ConfigSourcePath") - .HasColumnType("TEXT") - .HasColumnName("config_source_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("ForceMpegTs") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("force_mpegts"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("IncludeSeries") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_series"); - - b.Property("IncludeVod") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_vod"); - - b.Property("MaxConcurrentStreams") - .HasColumnType("INTEGER") - .HasColumnName("max_concurrent_streams"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("NeedsEnvVarSubstitution") - .HasColumnType("INTEGER") - .HasColumnName("needs_env_var_substitution"); - - b.Property("PlaylistUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_url"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(20) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.Property("XmltvUrl") - .HasColumnType("TEXT") - .HasColumnName("xmltv_url"); - - b.Property("XtreamBaseUrl") - .HasColumnType("TEXT") - .HasColumnName("xtream_base_url"); - - b.Property("XtreamEncryptedPassword") - .HasColumnType("TEXT") - .HasColumnName("xtream_encrypted_password"); - - b.Property("XtreamIncludeXmltv") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("xtream_include_xmltv"); - - b.Property("XtreamUsername") - .HasColumnType("TEXT") - .HasColumnName("xtream_username"); - - b.HasKey("ProviderId"); - - b.HasIndex("Enabled") - .HasDatabaseName("idx_providers_enabled"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Property("ProviderChannelId") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EventContentKey") - .HasColumnType("TEXT") - .HasColumnName("event_content_key"); - - b.Property("EventEndUtc") - .HasColumnType("TEXT") - .HasColumnName("event_end_utc"); - - b.Property("EventLeague") - .HasColumnType("TEXT") - .HasColumnName("event_league"); - - b.Property("EventParticipantsJson") - .HasColumnType("TEXT") - .HasColumnName("event_participants_json"); - - b.Property("EventSlotKey") - .HasColumnType("TEXT") - .HasColumnName("event_slot_key"); - - b.Property("EventSport") - .HasColumnType("TEXT") - .HasColumnName("event_sport"); - - b.Property("EventStartUtc") - .HasColumnType("TEXT") - .HasColumnName("event_start_utc"); - - b.Property("EventTitle") - .HasColumnType("TEXT") - .HasColumnName("event_title"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("GroupTitle") - .HasColumnType("TEXT") - .HasColumnName("group_title"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("IsPlaceholder") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_placeholder"); - - b.Property("LastFetchRunId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("last_fetch_run_id"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("ProviderChannelKey") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_key"); - - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StreamUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("stream_url"); - - b.Property("TvgId") - .HasColumnType("TEXT") - .HasColumnName("tvg_id"); - - b.Property("TvgName") - .HasColumnType("TEXT") - .HasColumnName("tvg_name"); - - b.HasKey("ProviderChannelId"); - - b.HasIndex("LastFetchRunId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_channels_provider_active"); - - b.HasIndex("ProviderId", "EventContentKey") - .HasDatabaseName("idx_provider_channels_event_content") - .HasFilter("event_content_key IS NOT NULL"); - - b.HasIndex("ProviderId", "LastSeenUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_provider_channels_seen"); - - b.HasIndex("ProviderId", "ProviderChannelKey") - .IsUnique() - .HasFilter("provider_channel_key IS NOT NULL"); - - b.HasIndex("ProviderId", "IsEvent", "EventStartUtc") - .HasDatabaseName("idx_provider_channels_is_event"); - - b.HasIndex("ProviderId", "IsPlaceholder", "Active") - .HasDatabaseName("idx_provider_channels_placeholder_active"); - - b.ToTable("provider_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("channel_count"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("NormalizedName") - .HasColumnType("TEXT") - .HasColumnName("normalized_name"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("RawName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("raw_name"); - - b.HasKey("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_groups_provider_active"); - - b.HasIndex("ProviderId", "RawName") - .IsUnique(); - - b.ToTable("provider_groups", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.SiteSettings", b => - { - b.Property("Id") - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("AuthenticationEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("authentication_enabled"); - - b.Property("EndpointSecurityEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("endpoint_security_enabled"); - - b.Property("GeneratedHlsEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("generated_hls_enabled"); - - b.Property("GeneratedHlsFfmpegPath") - .HasColumnType("TEXT") - .HasColumnName("generated_hls_ffmpeg_path"); - - b.Property("GeneratedHlsSettingsRestartRequired") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("generated_hls_settings_restart_required"); - - b.Property("HdhrAdvertisedBaseUrl") - .HasColumnType("TEXT") - .HasColumnName("hdhr_advertised_base_url"); - - b.Property("HdhrDiscoveryEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("hdhr_discovery_enabled"); - - b.Property("HdhrEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("hdhr_enabled"); - - b.Property("HdhrFriendlyName") - .HasColumnType("TEXT") - .HasColumnName("hdhr_friendly_name"); - - b.Property("HdhrSettingsRestartRequired") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("hdhr_settings_restart_required"); - - b.Property("HdhrSiliconDustDiscoveryEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("hdhr_silicondust_discovery_enabled"); - - b.Property("HdhrSsdpEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("hdhr_ssdp_enabled"); - - b.Property("HdhrTunerCountOverride") - .HasColumnType("INTEGER") - .HasColumnName("hdhr_tuner_count_override"); - - b.Property("RefreshScheduleKind") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("6h") - .HasColumnName("refresh_schedule_kind"); - - b.Property("RefreshStartupCatchup") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("refresh_startup_catchup"); - - b.Property("StreamBufferMaxBytesHardCap") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(33554432) - .HasColumnName("stream_buffer_max_bytes_hard_cap"); - - b.Property("StreamBufferMaxBytesPerSession") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(4194304) - .HasColumnName("stream_buffer_max_bytes_per_session"); - - b.Property("StreamBufferReadChunkSizeBytes") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(32768) - .HasColumnName("stream_buffer_read_chunk_size_bytes"); - - b.Property("StreamIdleGraceHardCapSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(120) - .HasColumnName("stream_idle_grace_hard_cap_seconds"); - - b.Property("StreamIdleGraceSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(15) - .HasColumnName("stream_idle_grace_seconds"); - - b.Property("StreamMaxConcurrentSessions") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(50) - .HasColumnName("stream_max_concurrent_sessions"); - - b.Property("StreamReconnectConnectTimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(15) - .HasColumnName("stream_reconnect_connect_timeout_seconds"); - - b.Property("StreamReconnectOutageWindowSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(75) - .HasColumnName("stream_reconnect_outage_window_seconds"); - - b.Property("StreamReconnectReadStallTimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(30) - .HasColumnName("stream_reconnect_read_stall_timeout_seconds"); - - b.Property("StreamingEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("streaming_enabled"); - - b.Property("StreamingSettingsRestartRequired") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("streaming_settings_restart_required"); - - b.HasKey("Id"); - - b.ToTable("site_settings", (string)null); - - b.HasData( - new - { - Id = 1, - AuthenticationEnabled = false, - EndpointSecurityEnabled = false, - GeneratedHlsEnabled = true, - GeneratedHlsSettingsRestartRequired = false, - HdhrDiscoveryEnabled = true, - HdhrEnabled = true, - HdhrSettingsRestartRequired = false, - HdhrSiliconDustDiscoveryEnabled = true, - HdhrSsdpEnabled = true, - RefreshScheduleKind = "6h", - RefreshStartupCatchup = true, - StreamBufferMaxBytesHardCap = 33554432, - StreamBufferMaxBytesPerSession = 4194304, - StreamBufferReadChunkSizeBytes = 32768, - StreamIdleGraceHardCapSeconds = 120, - StreamIdleGraceSeconds = 15, - StreamMaxConcurrentSessions = 50, - StreamReconnectConnectTimeoutSeconds = 15, - StreamReconnectOutageWindowSeconds = 75, - StreamReconnectReadStallTimeoutSeconds = 30, - StreamingEnabled = true, - StreamingSettingsRestartRequired = false - }); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.Property("SnapshotId") - .HasColumnType("TEXT") - .HasColumnName("snapshot_id"); - - b.Property("ChangeClass") - .HasColumnType("TEXT") - .HasColumnName("change_class"); - - b.Property("ChannelCountPublished") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_published"); - - b.Property("ChannelIndexPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_index_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("LiveChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("live_channel_count"); - - b.Property("PlaylistPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_path"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("SeriesChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("series_channel_count"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("StatusJsonPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status_json_path"); - - b.Property("VodChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("vod_channel_count"); - - b.Property("XmltvPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_path"); - - b.HasKey("SnapshotId"); - - b.HasIndex("ProfileId", "Status", "CreatedUtc") - .IsDescending(false, false, true) - .HasDatabaseName("idx_snapshots_profile_status"); - - b.ToTable("snapshots", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.Property("Value") - .HasColumnType("TEXT") - .HasColumnName("stream_key"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("LastUsedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_used_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Revoked") - .HasColumnType("INTEGER") - .HasColumnName("revoked"); - - b.HasKey("Value"); - - b.HasIndex("ChannelId") - .HasDatabaseName("idx_stream_keys_channel"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "Revoked") - .HasDatabaseName("idx_stream_keys_profile"); - - b.ToTable("stream_keys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderKey") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.Property("CredentialId") - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CredentialId"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserPasskeys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("CanonicalChannels") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ChannelMatchRules") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "TargetChannel") - .WithMany("TargetingMatchRules") - .HasForeignKey("TargetChannelId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - - b.Navigation("TargetChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("ChannelSources") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelSources") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ChannelSources") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Provider"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.DownstreamIntegration", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany() - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointAccessBinding", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "ActiveProfile") - .WithMany("ActiveEndpointAccessBindings") - .HasForeignKey("ActiveProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "DefaultProfile") - .WithMany("DefaultEndpointAccessBindings") - .HasForeignKey("DefaultProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.EndpointCredential", "Credential") - .WithMany("Bindings") - .HasForeignKey("EndpointCredentialId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ActiveProfile"); - - b.Navigation("Credential"); - - b.Navigation("DefaultProfile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("EpgChannelMaps") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("EpgChannelMaps") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMapping", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("ChannelMappings") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany() - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany() - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - - b.Navigation("Profile"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgFetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("FetchRuns") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany() - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Cascade); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSourceChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("Channels") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("FetchRuns") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroup", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("CustomGroups") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroupChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileCustomGroup", "CustomGroup") - .WithMany("Channels") - .HasForeignKey("CustomGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("CustomGroupChannels") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("CustomGroup"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroupProviderLink", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileCustomGroup", "CustomGroup") - .WithMany("ProviderLinks") - .HasForeignKey("CustomGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("CustomGroupProviderLinks") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("CustomGroup"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileEventInterestRule", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("EventInterestRules") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany() - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany() - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - - b.Navigation("Provider"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileGroupFilter", "ProfileGroupFilter") - .WithMany("ChannelFilters") - .HasForeignKey("ProfileGroupFilterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelFilters") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ProfileGroupFilter"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileProviders") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProfileProviders") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.FetchRun", "LastFetchRun") - .WithMany("ProviderChannels") - .HasForeignKey("LastFetchRunId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("LastFetchRun"); - - b.Navigation("Provider"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderGroups") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("Snapshots") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("StreamKeys") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("StreamKeys") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => - { - b1.Property("IdentityUserPasskeyCredentialId"); - - b1.Property("AttestationObject") - .IsRequired(); - - b1.Property("ClientDataJson") - .IsRequired(); - - b1.Property("CreatedAt"); - - b1.Property("IsBackedUp"); - - b1.Property("IsBackupEligible"); - - b1.Property("IsUserVerified"); - - b1.Property("Name"); - - b1.Property("PublicKey") - .IsRequired(); - - b1.Property("SignCount"); - - b1.PrimitiveCollection("Transports"); - - b1.HasKey("IdentityUserPasskeyCredentialId"); - - b1.ToTable("AspNetUserPasskeys"); - - b1 - .ToJson("Data") - .HasColumnType("TEXT"); - - b1.WithOwner() - .HasForeignKey("IdentityUserPasskeyCredentialId"); - }); - - b.Navigation("Data") - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("StreamKeys"); - - b.Navigation("TargetingMatchRules"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointCredential", b => - { - b.Navigation("Bindings"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.Navigation("ChannelMappings"); - - b.Navigation("Channels"); - - b.Navigation("FetchRuns"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Navigation("ProviderChannels"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Navigation("ActiveEndpointAccessBindings"); - - b.Navigation("CanonicalChannels"); - - b.Navigation("ChannelMatchRules"); - - b.Navigation("CustomGroups"); - - b.Navigation("DefaultEndpointAccessBindings"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("EventInterestRules"); - - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProfileProviders"); - - b.Navigation("Snapshots"); - - b.Navigation("StreamKeys"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroup", b => - { - b.Navigation("Channels"); - - b.Navigation("ProviderLinks"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Navigation("ChannelFilters"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("FetchRuns"); - - b.Navigation("ProfileProviders"); - - b.Navigation("ProviderChannels"); - - b.Navigation("ProviderGroups"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Navigation("ChannelFilters"); - - b.Navigation("ChannelSources"); - - b.Navigation("CustomGroupChannels"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Navigation("CustomGroupProviderLinks"); - - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProviderChannels"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260322000000_Alpha5_Schema.cs b/src/M3Undle.Web/Data/Migrations/20260322000000_Alpha5_Schema.cs deleted file mode 100644 index ca14846..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260322000000_Alpha5_Schema.cs +++ /dev/null @@ -1,749 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - /// - public partial class Alpha5_Schema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "idx_providers_is_active", - table: "providers"); - - migrationBuilder.AddColumn( - name: "force_mpegts", - table: "providers", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "change_class", - table: "snapshots", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "generated_hls_enabled", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: true); - - migrationBuilder.AddColumn( - name: "generated_hls_ffmpeg_path", - table: "site_settings", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "generated_hls_settings_restart_required", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "hdhr_advertised_base_url", - table: "site_settings", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "hdhr_discovery_enabled", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: true); - - migrationBuilder.AddColumn( - name: "hdhr_enabled", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: true); - - migrationBuilder.AddColumn( - name: "hdhr_friendly_name", - table: "site_settings", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "hdhr_settings_restart_required", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "hdhr_silicondust_discovery_enabled", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: true); - - migrationBuilder.AddColumn( - name: "hdhr_ssdp_enabled", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: true); - - migrationBuilder.AddColumn( - name: "hdhr_tuner_count_override", - table: "site_settings", - type: "INTEGER", - nullable: true); - - migrationBuilder.AddColumn( - name: "refresh_schedule_kind", - table: "site_settings", - type: "TEXT", - nullable: false, - defaultValue: "6h"); - - migrationBuilder.AddColumn( - name: "refresh_startup_catchup", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: true); - - migrationBuilder.AddColumn( - name: "event_content_key", - table: "provider_channels", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "event_league", - table: "provider_channels", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "event_participants_json", - table: "provider_channels", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "event_slot_key", - table: "provider_channels", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "event_sport", - table: "provider_channels", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "event_title", - table: "provider_channels", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "is_placeholder", - table: "provider_channels", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "is_active", - table: "profiles", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AlterColumn( - name: "decision", - table: "profile_group_filters", - type: "TEXT", - nullable: false, - defaultValue: "include", - oldClrType: typeof(string), - oldType: "TEXT", - oldDefaultValue: "hold"); - - migrationBuilder.AddColumn( - name: "tracking_keywords", - table: "profile_group_filters", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "tracking_policy", - table: "profile_group_filters", - type: "TEXT", - nullable: false, - defaultValue: "review"); - - migrationBuilder.AddColumn( - name: "display_name_override", - table: "profile_group_channel_filters", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "state", - table: "profile_group_channel_filters", - type: "TEXT", - nullable: false, - defaultValue: "included"); - - migrationBuilder.AddColumn( - name: "tvg_id_override", - table: "profile_group_channel_filters", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "updated_utc", - table: "profile_group_channel_filters", - type: "TEXT", - nullable: false, - defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); - - migrationBuilder.Sql( - """ - UPDATE profile_group_filters - SET decision = CASE - WHEN LOWER(TRIM(decision)) = 'hold' AND is_new = 1 THEN 'pending' - WHEN LOWER(TRIM(decision)) = 'hold' THEN 'include' - WHEN LOWER(TRIM(decision)) = 'exclude' THEN 'exclude' - WHEN LOWER(TRIM(decision)) = 'pending' THEN 'pending' - WHEN LOWER(TRIM(decision)) = 'include' THEN 'include' - ELSE 'include' - END; - """); - - migrationBuilder.Sql( - """ - UPDATE profile_group_filters - SET is_new = CASE WHEN decision = 'pending' THEN 1 ELSE 0 END; - """); - - migrationBuilder.Sql( - """ - UPDATE profile_group_filters - SET decision = 'include' - WHERE decision IN ('pending', 'hold'); - """); - - migrationBuilder.Sql( - """ - UPDATE profile_group_channel_filters - SET state = CASE - WHEN state IS NULL OR TRIM(state) = '' THEN 'included' - WHEN LOWER(TRIM(state)) IN ('pending', 'included', 'excluded') THEN LOWER(TRIM(state)) - ELSE 'included' - END; - """); - - migrationBuilder.Sql( - """ - UPDATE profile_group_channel_filters - SET updated_utc = COALESCE(created_utc, CURRENT_TIMESTAMP) - WHERE updated_utc IS NULL - OR updated_utc = '0001-01-01 00:00:00' - OR updated_utc = '0001-01-01 00:00:00.0000000' - OR updated_utc = '0001-01-01T00:00:00' - OR updated_utc = '0001-01-01T00:00:00.0000000'; - """); - - migrationBuilder.Sql( - """ - UPDATE profiles - SET is_active = 1 - WHERE profile_id = ( - SELECT pp.profile_id - FROM profile_providers pp - INNER JOIN providers p ON p.provider_id = pp.provider_id - WHERE p.is_active = 1 - ORDER BY pp.priority ASC - LIMIT 1 - ); - """); - - migrationBuilder.DropColumn( - name: "is_active", - table: "providers"); - - migrationBuilder.AddColumn( - name: "refresh_interval_hours", - table: "epg_sources", - type: "INTEGER", - nullable: true); - - migrationBuilder.AddColumn( - name: "AdaptiveLockoutEscalated", - table: "AspNetUsers", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.CreateTable( - name: "downstream_integrations", - columns: table => new - { - downstream_integration_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: true), - name = table.Column(type: "TEXT", nullable: false), - kind = table.Column(type: "TEXT", nullable: false), - base_url = table.Column(type: "TEXT", nullable: false), - api_key_encrypted = table.Column(type: "TEXT", nullable: true), - webhook_headers_json = table.Column(type: "TEXT", nullable: true), - trigger_on_lineup_update = table.Column(type: "INTEGER", nullable: false, defaultValue: true), - trigger_on_guide_update = table.Column(type: "INTEGER", nullable: false, defaultValue: true), - enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), - last_notified_utc = table.Column(type: "TEXT", nullable: true), - last_notify_error = table.Column(type: "TEXT", nullable: true), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_downstream_integrations", x => x.downstream_integration_id); - table.ForeignKey( - name: "FK_downstream_integrations_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateTable( - name: "profile_custom_groups", - columns: table => new - { - custom_group_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - name = table.Column(type: "TEXT", nullable: false), - decision = table.Column(type: "TEXT", nullable: false, defaultValue: "include"), - channel_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "select"), - tracking_policy = table.Column(type: "TEXT", nullable: false, defaultValue: "review"), - tracking_keywords = table.Column(type: "TEXT", nullable: true), - auto_num_start = table.Column(type: "INTEGER", nullable: true), - auto_num_end = table.Column(type: "INTEGER", nullable: true), - track_new_channels = table.Column(type: "INTEGER", nullable: false, defaultValue: false), - sort_override = table.Column(type: "INTEGER", nullable: true), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_profile_custom_groups", x => x.custom_group_id); - table.ForeignKey( - name: "FK_profile_custom_groups_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "profile_event_interest_rules", - columns: table => new - { - rule_id = table.Column(type: "TEXT", nullable: false), - profile_id = table.Column(type: "TEXT", nullable: false), - provider_id = table.Column(type: "TEXT", nullable: true), - provider_group_id = table.Column(type: "TEXT", nullable: true), - enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), - match_type = table.Column(type: "TEXT", nullable: false), - match_value = table.Column(type: "TEXT", nullable: false), - action = table.Column(type: "TEXT", nullable: false), - priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 100), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_profile_event_interest_rules", x => x.rule_id); - table.ForeignKey( - name: "FK_profile_event_interest_rules_profiles_profile_id", - column: x => x.profile_id, - principalTable: "profiles", - principalColumn: "profile_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_profile_event_interest_rules_provider_groups_provider_group_id", - column: x => x.provider_group_id, - principalTable: "provider_groups", - principalColumn: "provider_group_id", - onDelete: ReferentialAction.SetNull); - table.ForeignKey( - name: "FK_profile_event_interest_rules_providers_provider_id", - column: x => x.provider_id, - principalTable: "providers", - principalColumn: "provider_id", - onDelete: ReferentialAction.SetNull); - }); - - migrationBuilder.CreateTable( - name: "profile_custom_group_channels", - columns: table => new - { - custom_group_channel_id = table.Column(type: "TEXT", nullable: false), - custom_group_id = table.Column(type: "TEXT", nullable: false), - provider_channel_id = table.Column(type: "TEXT", nullable: false), - state = table.Column(type: "TEXT", nullable: false, defaultValue: "included"), - channel_number = table.Column(type: "INTEGER", nullable: true), - display_name_override = table.Column(type: "TEXT", nullable: true), - tvg_id_override = table.Column(type: "TEXT", nullable: true), - created_utc = table.Column(type: "TEXT", nullable: false), - updated_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_profile_custom_group_channels", x => x.custom_group_channel_id); - table.ForeignKey( - name: "FK_profile_custom_group_channels_profile_custom_groups_custom_group_id", - column: x => x.custom_group_id, - principalTable: "profile_custom_groups", - principalColumn: "custom_group_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_profile_custom_group_channels_provider_channels_provider_channel_id", - column: x => x.provider_channel_id, - principalTable: "provider_channels", - principalColumn: "provider_channel_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "profile_custom_group_provider_links", - columns: table => new - { - link_id = table.Column(type: "TEXT", nullable: false), - custom_group_id = table.Column(type: "TEXT", nullable: false), - provider_group_id = table.Column(type: "TEXT", nullable: false), - created_utc = table.Column(type: "TEXT", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_profile_custom_group_provider_links", x => x.link_id); - table.ForeignKey( - name: "FK_profile_custom_group_provider_links_profile_custom_groups_custom_group_id", - column: x => x.custom_group_id, - principalTable: "profile_custom_groups", - principalColumn: "custom_group_id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_profile_custom_group_provider_links_provider_groups_provider_group_id", - column: x => x.provider_group_id, - principalTable: "provider_groups", - principalColumn: "provider_group_id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.UpdateData( - table: "site_settings", - keyColumn: "id", - keyValue: 1, - columns: new[] { "generated_hls_enabled", "generated_hls_ffmpeg_path", "hdhr_advertised_base_url", "hdhr_discovery_enabled", "hdhr_enabled", "hdhr_friendly_name", "hdhr_silicondust_discovery_enabled", "hdhr_ssdp_enabled", "hdhr_tuner_count_override", "refresh_schedule_kind", "refresh_startup_catchup" }, - values: new object[] { true, null, null, true, true, null, true, true, null, "6h", true }); - - migrationBuilder.CreateIndex( - name: "idx_provider_channels_event_content", - table: "provider_channels", - columns: new[] { "provider_id", "event_content_key" }, - filter: "event_content_key IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "idx_provider_channels_placeholder_active", - table: "provider_channels", - columns: new[] { "provider_id", "is_placeholder", "active" }); - - migrationBuilder.CreateIndex( - name: "idx_profiles_is_active", - table: "profiles", - column: "is_active", - unique: true, - filter: "is_active = 1"); - - migrationBuilder.CreateIndex( - name: "idx_pgf_profile_tracking_policy", - table: "profile_group_filters", - columns: new[] { "profile_id", "tracking_policy" }); - - migrationBuilder.CreateIndex( - name: "idx_downstream_integrations_profile", - table: "downstream_integrations", - column: "profile_id"); - - migrationBuilder.CreateIndex( - name: "idx_pcgc_group_channel_unique", - table: "profile_custom_group_channels", - columns: new[] { "custom_group_id", "provider_channel_id" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_pcgc_group_state", - table: "profile_custom_group_channels", - columns: new[] { "custom_group_id", "state" }); - - migrationBuilder.CreateIndex( - name: "IX_profile_custom_group_channels_provider_channel_id", - table: "profile_custom_group_channels", - column: "provider_channel_id"); - - migrationBuilder.CreateIndex( - name: "idx_pcgpl_group_provider_unique", - table: "profile_custom_group_provider_links", - columns: new[] { "custom_group_id", "provider_group_id" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "IX_profile_custom_group_provider_links_provider_group_id", - table: "profile_custom_group_provider_links", - column: "provider_group_id"); - - migrationBuilder.CreateIndex( - name: "idx_pcg_profile_id", - table: "profile_custom_groups", - column: "profile_id"); - - migrationBuilder.CreateIndex( - name: "idx_pcg_profile_name_unique", - table: "profile_custom_groups", - columns: new[] { "profile_id", "name" }, - unique: true); - - migrationBuilder.CreateIndex( - name: "idx_peir_profile_enabled_priority", - table: "profile_event_interest_rules", - columns: new[] { "profile_id", "enabled", "priority" }); - - migrationBuilder.CreateIndex( - name: "IX_profile_event_interest_rules_provider_group_id", - table: "profile_event_interest_rules", - column: "provider_group_id"); - - migrationBuilder.CreateIndex( - name: "IX_profile_event_interest_rules_provider_id", - table: "profile_event_interest_rules", - column: "provider_id"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "downstream_integrations"); - - migrationBuilder.DropTable( - name: "profile_custom_group_channels"); - - migrationBuilder.DropTable( - name: "profile_custom_group_provider_links"); - - migrationBuilder.DropTable( - name: "profile_event_interest_rules"); - - migrationBuilder.DropTable( - name: "profile_custom_groups"); - - migrationBuilder.DropIndex( - name: "idx_provider_channels_event_content", - table: "provider_channels"); - - migrationBuilder.DropIndex( - name: "idx_provider_channels_placeholder_active", - table: "provider_channels"); - - migrationBuilder.DropIndex( - name: "idx_profiles_is_active", - table: "profiles"); - - migrationBuilder.DropIndex( - name: "idx_pgf_profile_tracking_policy", - table: "profile_group_filters"); - - migrationBuilder.DropColumn( - name: "change_class", - table: "snapshots"); - - migrationBuilder.DropColumn( - name: "generated_hls_enabled", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "generated_hls_ffmpeg_path", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "generated_hls_settings_restart_required", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "hdhr_advertised_base_url", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "hdhr_discovery_enabled", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "hdhr_enabled", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "hdhr_friendly_name", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "hdhr_settings_restart_required", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "hdhr_silicondust_discovery_enabled", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "hdhr_ssdp_enabled", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "hdhr_tuner_count_override", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "refresh_schedule_kind", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "refresh_startup_catchup", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "event_content_key", - table: "provider_channels"); - - migrationBuilder.DropColumn( - name: "event_league", - table: "provider_channels"); - - migrationBuilder.DropColumn( - name: "event_participants_json", - table: "provider_channels"); - - migrationBuilder.DropColumn( - name: "event_slot_key", - table: "provider_channels"); - - migrationBuilder.DropColumn( - name: "event_sport", - table: "provider_channels"); - - migrationBuilder.DropColumn( - name: "event_title", - table: "provider_channels"); - - migrationBuilder.DropColumn( - name: "is_placeholder", - table: "provider_channels"); - - migrationBuilder.DropColumn( - name: "is_active", - table: "profiles"); - - migrationBuilder.DropColumn( - name: "tracking_keywords", - table: "profile_group_filters"); - - migrationBuilder.DropColumn( - name: "tracking_policy", - table: "profile_group_filters"); - - migrationBuilder.DropColumn( - name: "display_name_override", - table: "profile_group_channel_filters"); - - migrationBuilder.DropColumn( - name: "state", - table: "profile_group_channel_filters"); - - migrationBuilder.DropColumn( - name: "tvg_id_override", - table: "profile_group_channel_filters"); - - migrationBuilder.DropColumn( - name: "updated_utc", - table: "profile_group_channel_filters"); - - migrationBuilder.DropColumn( - name: "refresh_interval_hours", - table: "epg_sources"); - - migrationBuilder.DropColumn( - name: "AdaptiveLockoutEscalated", - table: "AspNetUsers"); - - migrationBuilder.AddColumn( - name: "is_active", - table: "providers", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.CreateIndex( - name: "idx_providers_is_active", - table: "providers", - column: "is_active", - unique: true, - filter: "is_active = 1"); - - migrationBuilder.Sql( - """ - UPDATE providers - SET is_active = 1 - WHERE provider_id = ( - SELECT pp.provider_id - FROM profile_providers pp - INNER JOIN profiles pr ON pr.profile_id = pp.profile_id - WHERE pr.is_active = 1 - ORDER BY pp.priority ASC - LIMIT 1 - ); - """); - - migrationBuilder.DropColumn( - name: "force_mpegts", - table: "providers"); - - migrationBuilder.Sql( - """ - UPDATE profile_group_filters - SET decision = CASE - WHEN LOWER(TRIM(decision)) = 'exclude' THEN 'exclude' - ELSE 'hold' - END; - """); - - migrationBuilder.DropColumn( - name: "is_active", - table: "profiles"); - - migrationBuilder.AlterColumn( - name: "decision", - table: "profile_group_filters", - type: "TEXT", - nullable: false, - defaultValue: "hold", - oldClrType: typeof(string), - oldType: "TEXT", - oldDefaultValue: "include"); - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260421103458_Alpha5_PendingModelFix.Designer.cs b/src/M3Undle.Web/Data/Migrations/20260421103458_Alpha5_PendingModelFix.Designer.cs deleted file mode 100644 index 55dc517..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260421103458_Alpha5_PendingModelFix.Designer.cs +++ /dev/null @@ -1,2626 +0,0 @@ -// -using System; -using M3Undle.Web.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - [DbContext(typeof(ApplicationDbContext))] - [Migration("20260421103458_Alpha5_PendingModelFix")] - partial class Alpha5_PendingModelFix - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); - - modelBuilder.Entity("M3Undle.Web.Data.ApplicationUser", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("AccessFailedCount") - .HasColumnType("INTEGER"); - - b.Property("AdaptiveLockoutEscalated") - .HasColumnType("INTEGER"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("EmailConfirmed") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnabled") - .HasColumnType("INTEGER"); - - b.Property("LockoutEnd") - .HasColumnType("TEXT"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PasswordHash") - .HasColumnType("TEXT"); - - b.Property("PhoneNumber") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("INTEGER"); - - b.Property("SecurityStamp") - .HasColumnType("TEXT"); - - b.Property("TwoFactorEnabled") - .HasColumnType("INTEGER"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex"); - - b.ToTable("AspNetUsers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Property("ChannelId") - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EventPolicy") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("event_policy"); - - b.Property("GroupName") - .HasColumnType("TEXT") - .HasColumnName("group_name"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("Notes") - .HasColumnType("TEXT") - .HasColumnName("notes"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelId"); - - b.HasIndex("ProfileId", "ChannelNumber") - .IsUnique() - .HasDatabaseName("idx_canonical_channels_profile_number"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_canonical_channels_profile_enabled"); - - b.ToTable("canonical_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.Property("RuleId") - .HasColumnType("TEXT") - .HasColumnName("rule_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultPriority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(1) - .HasColumnName("default_priority"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IsEventRule") - .HasColumnType("INTEGER") - .HasColumnName("is_event_rule"); - - b.Property("MatchType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_type"); - - b.Property("MatchValue") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_value"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("TargetChannelId") - .HasColumnType("TEXT") - .HasColumnName("target_channel_id"); - - b.Property("TargetGroupName") - .HasColumnType("TEXT") - .HasColumnName("target_group_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("RuleId"); - - b.HasIndex("TargetChannelId"); - - b.HasIndex("ProfileId", "Enabled") - .HasDatabaseName("idx_match_rules_profile"); - - b.ToTable("channel_match_rules", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.Property("ChannelSourceId") - .HasColumnType("TEXT") - .HasColumnName("channel_source_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("FailureCountRolling") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(0) - .HasColumnName("failure_count_rolling"); - - b.Property("HealthState") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("health_state"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("OverrideStreamUrl") - .HasColumnType("TEXT") - .HasColumnName("override_stream_url"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ChannelSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ChannelId", "Priority") - .IsUnique() - .HasDatabaseName("idx_channel_sources_channel"); - - b.HasIndex("HealthState", "LastFailureUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_channel_sources_health"); - - b.ToTable("channel_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.DownstreamIntegration", b => - { - b.Property("DownstreamIntegrationId") - .HasColumnType("TEXT") - .HasColumnName("downstream_integration_id"); - - b.Property("ApiKeyEncrypted") - .HasColumnType("TEXT") - .HasColumnName("api_key_encrypted"); - - b.Property("BaseUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("base_url"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("enabled"); - - b.Property("Kind") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("kind"); - - b.Property("LastNotifiedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_notified_utc"); - - b.Property("LastNotifyError") - .HasColumnType("TEXT") - .HasColumnName("last_notify_error"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("TriggerOnGuideUpdate") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("trigger_on_guide_update"); - - b.Property("TriggerOnLineupUpdate") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("trigger_on_lineup_update"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("WebhookHeadersJson") - .HasColumnType("TEXT") - .HasColumnName("webhook_headers_json"); - - b.HasKey("DownstreamIntegrationId"); - - b.HasIndex("ProfileId") - .HasDatabaseName("idx_downstream_integrations_profile"); - - b.ToTable("downstream_integrations", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointAccessBinding", b => - { - b.Property("EndpointAccessBindingId") - .HasColumnType("TEXT") - .HasColumnName("endpoint_access_binding_id"); - - b.Property("ActiveProfileId") - .HasColumnType("TEXT") - .HasColumnName("active_profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DefaultProfileId") - .HasColumnType("TEXT") - .HasColumnName("default_profile_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("EndpointCredentialId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("endpoint_credential_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("VirtualTunerId") - .HasColumnType("TEXT") - .HasColumnName("virtual_tuner_id"); - - b.HasKey("EndpointAccessBindingId"); - - b.HasIndex("ActiveProfileId") - .HasDatabaseName("idx_endpoint_access_bindings_active_profile"); - - b.HasIndex("DefaultProfileId"); - - b.HasIndex("EndpointCredentialId") - .IsUnique() - .HasDatabaseName("idx_endpoint_access_bindings_credential"); - - b.ToTable("endpoint_access_bindings", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointCredential", b => - { - b.Property("EndpointCredentialId") - .HasColumnType("TEXT") - .HasColumnName("endpoint_credential_id"); - - b.Property("AuthType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("auth_type"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("normalized_username"); - - b.Property("PasswordHash") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("password_hash"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("Username") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("username"); - - b.HasKey("EndpointCredentialId"); - - b.HasIndex("NormalizedUsername") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("endpoint_credentials", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.Property("EpgMapId") - .HasColumnType("TEXT") - .HasColumnName("epg_map_id"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Source") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("source"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgMapId"); - - b.HasIndex("ChannelId"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_map_profile"); - - b.ToTable("epg_channel_map", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMapping", b => - { - b.Property("EpgChannelMappingId") - .HasColumnType("TEXT") - .HasColumnName("epg_channel_mapping_id"); - - b.Property("Confidence") - .ValueGeneratedOnAdd() - .HasColumnType("REAL") - .HasDefaultValue(1f) - .HasColumnName("confidence"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("MappingMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("auto_id") - .HasColumnName("mapping_mode"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgChannelMappingId"); - - b.HasIndex("EpgSourceId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProfileId", "ProviderChannelId", "EpgSourceId") - .IsUnique() - .HasDatabaseName("idx_epg_channel_mappings_profile_channel_source"); - - b.ToTable("epg_channel_mappings", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgFetchRun", b => - { - b.Property("EpgFetchRunId") - .HasColumnType("TEXT") - .HasColumnName("epg_fetch_run_id"); - - b.Property("Bytes") - .HasColumnType("INTEGER") - .HasColumnName("bytes"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("channel_count"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("ProgrammeCount") - .HasColumnType("INTEGER") - .HasColumnName("programme_count"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.HasKey("EpgFetchRunId"); - - b.HasIndex("EpgSourceId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_epg_fetch_runs_source_time"); - - b.ToTable("epg_fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.Property("EpgSourceId") - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ETag") - .HasColumnType("TEXT") - .HasColumnName("etag"); - - b.Property("Enabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("enabled"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("Kind") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("xmltv_url") - .HasColumnName("kind"); - - b.Property("LastFailureUtc") - .HasColumnType("TEXT") - .HasColumnName("last_failure_utc"); - - b.Property("LastModifiedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_modified_utc"); - - b.Property("LastSuccessUtc") - .HasColumnType("TEXT") - .HasColumnName("last_success_utc"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(10) - .HasColumnName("priority"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("RefreshIntervalHours") - .HasColumnType("INTEGER") - .HasColumnName("refresh_interval_hours"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(30) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UrlOrPath") - .HasColumnType("TEXT") - .HasColumnName("url_or_path"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.HasKey("EpgSourceId"); - - b.HasIndex("ProviderId") - .HasDatabaseName("idx_epg_sources_provider"); - - b.HasIndex("ProviderId", "Priority") - .HasDatabaseName("idx_epg_sources_provider_priority"); - - b.ToTable("epg_sources", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSourceChannel", b => - { - b.Property("EpgSourceChannelId") - .HasColumnType("TEXT") - .HasColumnName("epg_source_channel_id"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EpgSourceId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("epg_source_id"); - - b.Property("IconUrl") - .HasColumnType("TEXT") - .HasColumnName("icon_url"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("XmltvChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_channel_id"); - - b.HasKey("EpgSourceChannelId"); - - b.HasIndex("EpgSourceId", "XmltvChannelId") - .IsUnique() - .HasDatabaseName("idx_epg_source_channels_source_channel"); - - b.ToTable("epg_source_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Property("FetchRunId") - .HasColumnType("TEXT") - .HasColumnName("fetch_run_id"); - - b.Property("ChannelCountSeen") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_seen"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("FinishedUtc") - .HasColumnType("TEXT") - .HasColumnName("finished_utc"); - - b.Property("PlaylistBytes") - .HasColumnType("INTEGER") - .HasColumnName("playlist_bytes"); - - b.Property("PlaylistEtag") - .HasColumnType("TEXT") - .HasColumnName("playlist_etag"); - - b.Property("PlaylistLastModified") - .HasColumnType("TEXT") - .HasColumnName("playlist_last_modified"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StartedUtc") - .HasColumnType("TEXT") - .HasColumnName("started_utc"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("Type") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("snapshot") - .HasColumnName("type"); - - b.Property("XmltvBytes") - .HasColumnType("INTEGER") - .HasColumnName("xmltv_bytes"); - - b.Property("XmltvEtag") - .HasColumnType("TEXT") - .HasColumnName("xmltv_etag"); - - b.Property("XmltvLastModified") - .HasColumnType("TEXT") - .HasColumnName("xmltv_last_modified"); - - b.HasKey("FetchRunId"); - - b.HasIndex("ProviderId", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_provider_time"); - - b.HasIndex("Status", "StartedUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_fetch_runs_status"); - - b.ToTable("fetch_runs", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("IsActive") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_active"); - - b.Property("MergeMode") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("merge_mode"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("OutputName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileId"); - - b.HasIndex("IsActive") - .IsUnique() - .HasDatabaseName("idx_profiles_is_active") - .HasFilter("is_active = 1"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("profiles", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroup", b => - { - b.Property("CustomGroupId") - .HasColumnType("TEXT") - .HasColumnName("custom_group_id"); - - b.Property("AutoNumEnd") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_end"); - - b.Property("AutoNumStart") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_start"); - - b.Property("ChannelMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("select") - .HasColumnName("channel_mode"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Decision") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("include") - .HasColumnName("decision"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("SortOverride") - .HasColumnType("INTEGER") - .HasColumnName("sort_override"); - - b.Property("TrackNewChannels") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("track_new_channels"); - - b.Property("TrackingKeywords") - .HasColumnType("TEXT") - .HasColumnName("tracking_keywords"); - - b.Property("TrackingPolicy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("review") - .HasColumnName("tracking_policy"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("CustomGroupId"); - - b.HasIndex("ProfileId") - .HasDatabaseName("idx_pcg_profile_id"); - - b.HasIndex("ProfileId", "Name") - .IsUnique() - .HasDatabaseName("idx_pcg_profile_name_unique"); - - b.ToTable("profile_custom_groups", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroupChannel", b => - { - b.Property("CustomGroupChannelId") - .HasColumnType("TEXT") - .HasColumnName("custom_group_channel_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("CustomGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("custom_group_id"); - - b.Property("DisplayNameOverride") - .HasColumnType("TEXT") - .HasColumnName("display_name_override"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("State") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("included") - .HasColumnName("state"); - - b.Property("TvgIdOverride") - .HasColumnType("TEXT") - .HasColumnName("tvg_id_override"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("CustomGroupChannelId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("CustomGroupId", "ProviderChannelId") - .IsUnique() - .HasDatabaseName("idx_pcgc_group_channel_unique"); - - b.HasIndex("CustomGroupId", "State") - .HasDatabaseName("idx_pcgc_group_state"); - - b.ToTable("profile_custom_group_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroupProviderLink", b => - { - b.Property("LinkId") - .HasColumnType("TEXT") - .HasColumnName("link_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("CustomGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("custom_group_id"); - - b.Property("ProviderGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.HasKey("LinkId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("CustomGroupId", "ProviderGroupId") - .IsUnique() - .HasDatabaseName("idx_pcgpl_group_provider_unique"); - - b.ToTable("profile_custom_group_provider_links", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileEventInterestRule", b => - { - b.Property("RuleId") - .HasColumnType("TEXT") - .HasColumnName("rule_id"); - - b.Property("Action") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("action"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("enabled"); - - b.Property("MatchType") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_type"); - - b.Property("MatchValue") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("match_value"); - - b.Property("Priority") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(100) - .HasColumnName("priority"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("RuleId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ProfileId", "Enabled", "Priority") - .HasDatabaseName("idx_peir_profile_enabled_priority"); - - b.ToTable("profile_event_interest_rules", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.Property("ProfileGroupChannelFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_channel_filter_id"); - - b.Property("ChannelNumber") - .HasColumnType("INTEGER") - .HasColumnName("channel_number"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("DisplayNameOverride") - .HasColumnType("TEXT") - .HasColumnName("display_name_override"); - - b.Property("OutputGroupName") - .HasColumnType("TEXT") - .HasColumnName("output_group_name"); - - b.Property("ProfileGroupFilterId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("ProviderChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("State") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("included") - .HasColumnName("state"); - - b.Property("TvgIdOverride") - .HasColumnType("TEXT") - .HasColumnName("tvg_id_override"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileGroupChannelFilterId"); - - b.HasIndex("ProviderChannelId"); - - b.HasIndex("ProfileGroupFilterId", "ProviderChannelId") - .IsUnique() - .HasDatabaseName("idx_pgcf_filter_channel_unique"); - - b.ToTable("profile_group_channel_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Property("ProfileGroupFilterId") - .HasColumnType("TEXT") - .HasColumnName("profile_group_filter_id"); - - b.Property("AutoNumEnd") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_end"); - - b.Property("AutoNumStart") - .HasColumnType("INTEGER") - .HasColumnName("auto_num_start"); - - b.Property("ChannelMode") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("select") - .HasColumnName("channel_mode"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Decision") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("include") - .HasColumnName("decision"); - - b.Property("IsNew") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_new"); - - b.Property("OutputName") - .HasColumnType("TEXT") - .HasColumnName("output_name"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderGroupId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("SortOverride") - .HasColumnType("INTEGER") - .HasColumnName("sort_override"); - - b.Property("TrackNewChannels") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("track_new_channels"); - - b.Property("TrackingKeywords") - .HasColumnType("TEXT") - .HasColumnName("tracking_keywords"); - - b.Property("TrackingPolicy") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("review") - .HasColumnName("tracking_policy"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.HasKey("ProfileGroupFilterId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProfileId", "Decision") - .HasDatabaseName("idx_pgf_profile_decision"); - - b.HasIndex("ProfileId", "ProviderGroupId") - .IsUnique() - .HasDatabaseName("idx_pgf_profile_group_unique"); - - b.HasIndex("ProfileId", "TrackingPolicy") - .HasDatabaseName("idx_pgf_profile_tracking_policy"); - - b.ToTable("profile_group_filters", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.Property("ProfileId") - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("Priority") - .HasColumnType("INTEGER") - .HasColumnName("priority"); - - b.HasKey("ProfileId", "ProviderId"); - - b.HasIndex("ProviderId"); - - b.HasIndex("ProfileId", "Priority") - .HasDatabaseName("idx_profile_providers_profile"); - - b.ToTable("profile_providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Property("ProviderId") - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("ConfigSourcePath") - .HasColumnType("TEXT") - .HasColumnName("config_source_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("Enabled") - .HasColumnType("INTEGER") - .HasColumnName("enabled"); - - b.Property("ForceMpegTs") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("force_mpegts"); - - b.Property("HeadersJson") - .HasColumnType("TEXT") - .HasColumnName("headers_json"); - - b.Property("IncludeSeries") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_series"); - - b.Property("IncludeVod") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("include_vod"); - - b.Property("MaxConcurrentStreams") - .HasColumnType("INTEGER") - .HasColumnName("max_concurrent_streams"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("name"); - - b.Property("NeedsEnvVarSubstitution") - .HasColumnType("INTEGER") - .HasColumnName("needs_env_var_substitution"); - - b.Property("PlaylistUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_url"); - - b.Property("TimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(20) - .HasColumnName("timeout_seconds"); - - b.Property("UpdatedUtc") - .HasColumnType("TEXT") - .HasColumnName("updated_utc"); - - b.Property("UserAgent") - .HasColumnType("TEXT") - .HasColumnName("user_agent"); - - b.Property("XmltvUrl") - .HasColumnType("TEXT") - .HasColumnName("xmltv_url"); - - b.Property("XtreamBaseUrl") - .HasColumnType("TEXT") - .HasColumnName("xtream_base_url"); - - b.Property("XtreamEncryptedPassword") - .HasColumnType("TEXT") - .HasColumnName("xtream_encrypted_password"); - - b.Property("XtreamIncludeXmltv") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("xtream_include_xmltv"); - - b.Property("XtreamUsername") - .HasColumnType("TEXT") - .HasColumnName("xtream_username"); - - b.HasKey("ProviderId"); - - b.HasIndex("Enabled") - .HasDatabaseName("idx_providers_enabled"); - - b.HasIndex("Name") - .IsUnique(); - - b.ToTable("providers", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Property("ProviderChannelId") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("display_name"); - - b.Property("EventContentKey") - .HasColumnType("TEXT") - .HasColumnName("event_content_key"); - - b.Property("EventEndUtc") - .HasColumnType("TEXT") - .HasColumnName("event_end_utc"); - - b.Property("EventLeague") - .HasColumnType("TEXT") - .HasColumnName("event_league"); - - b.Property("EventParticipantsJson") - .HasColumnType("TEXT") - .HasColumnName("event_participants_json"); - - b.Property("EventSlotKey") - .HasColumnType("TEXT") - .HasColumnName("event_slot_key"); - - b.Property("EventSport") - .HasColumnType("TEXT") - .HasColumnName("event_sport"); - - b.Property("EventStartUtc") - .HasColumnType("TEXT") - .HasColumnName("event_start_utc"); - - b.Property("EventTitle") - .HasColumnType("TEXT") - .HasColumnName("event_title"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("GroupTitle") - .HasColumnType("TEXT") - .HasColumnName("group_title"); - - b.Property("IsEvent") - .HasColumnType("INTEGER") - .HasColumnName("is_event"); - - b.Property("IsPlaceholder") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("is_placeholder"); - - b.Property("LastFetchRunId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("last_fetch_run_id"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("LogoUrl") - .HasColumnType("TEXT") - .HasColumnName("logo_url"); - - b.Property("ProviderChannelKey") - .HasColumnType("TEXT") - .HasColumnName("provider_channel_key"); - - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("StreamUrl") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("stream_url"); - - b.Property("TvgId") - .HasColumnType("TEXT") - .HasColumnName("tvg_id"); - - b.Property("TvgName") - .HasColumnType("TEXT") - .HasColumnName("tvg_name"); - - b.HasKey("ProviderChannelId"); - - b.HasIndex("LastFetchRunId"); - - b.HasIndex("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_channels_provider_active"); - - b.HasIndex("ProviderId", "EventContentKey") - .HasDatabaseName("idx_provider_channels_event_content") - .HasFilter("event_content_key IS NOT NULL"); - - b.HasIndex("ProviderId", "LastSeenUtc") - .IsDescending(false, true) - .HasDatabaseName("idx_provider_channels_seen"); - - b.HasIndex("ProviderId", "ProviderChannelKey") - .IsUnique() - .HasFilter("provider_channel_key IS NOT NULL"); - - b.HasIndex("ProviderId", "IsEvent", "EventStartUtc") - .HasDatabaseName("idx_provider_channels_is_event"); - - b.HasIndex("ProviderId", "IsPlaceholder", "Active") - .HasDatabaseName("idx_provider_channels_placeholder_active"); - - b.ToTable("provider_channels", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Property("ProviderGroupId") - .HasColumnType("TEXT") - .HasColumnName("provider_group_id"); - - b.Property("Active") - .HasColumnType("INTEGER") - .HasColumnName("active"); - - b.Property("ChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("channel_count"); - - b.Property("ContentType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("live") - .HasColumnName("content_type"); - - b.Property("FirstSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("first_seen_utc"); - - b.Property("LastSeenUtc") - .HasColumnType("TEXT") - .HasColumnName("last_seen_utc"); - - b.Property("NormalizedName") - .HasColumnType("TEXT") - .HasColumnName("normalized_name"); - - b.Property("ProviderId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("provider_id"); - - b.Property("RawName") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("raw_name"); - - b.HasKey("ProviderGroupId"); - - b.HasIndex("ProviderId", "Active") - .HasDatabaseName("idx_provider_groups_provider_active"); - - b.HasIndex("ProviderId", "RawName") - .IsUnique(); - - b.ToTable("provider_groups", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.SiteSettings", b => - { - b.Property("Id") - .HasColumnType("INTEGER") - .HasColumnName("id"); - - b.Property("AuthenticationEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("authentication_enabled"); - - b.Property("EndpointSecurityEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("endpoint_security_enabled"); - - b.Property("GeneratedHlsEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("generated_hls_enabled"); - - b.Property("GeneratedHlsFfmpegPath") - .HasColumnType("TEXT") - .HasColumnName("generated_hls_ffmpeg_path"); - - b.Property("GeneratedHlsSettingsRestartRequired") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("generated_hls_settings_restart_required"); - - b.Property("HdhrAdvertisedBaseUrl") - .HasColumnType("TEXT") - .HasColumnName("hdhr_advertised_base_url"); - - b.Property("HdhrDiscoveryEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("hdhr_discovery_enabled"); - - b.Property("HdhrEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("hdhr_enabled"); - - b.Property("HdhrFriendlyName") - .HasColumnType("TEXT") - .HasColumnName("hdhr_friendly_name"); - - b.Property("HdhrSettingsRestartRequired") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("hdhr_settings_restart_required"); - - b.Property("HdhrSiliconDustDiscoveryEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("hdhr_silicondust_discovery_enabled"); - - b.Property("HdhrSsdpEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("hdhr_ssdp_enabled"); - - b.Property("HdhrTunerCountOverride") - .HasColumnType("INTEGER") - .HasColumnName("hdhr_tuner_count_override"); - - b.Property("RefreshScheduleKind") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("6h") - .HasColumnName("refresh_schedule_kind"); - - b.Property("RefreshStartupCatchup") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("refresh_startup_catchup"); - - b.Property("StreamBufferMaxBytesHardCap") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(33554432) - .HasColumnName("stream_buffer_max_bytes_hard_cap"); - - b.Property("StreamBufferMaxBytesPerSession") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(4194304) - .HasColumnName("stream_buffer_max_bytes_per_session"); - - b.Property("StreamBufferReadChunkSizeBytes") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(32768) - .HasColumnName("stream_buffer_read_chunk_size_bytes"); - - b.Property("StreamIdleGraceHardCapSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(120) - .HasColumnName("stream_idle_grace_hard_cap_seconds"); - - b.Property("StreamIdleGraceSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(15) - .HasColumnName("stream_idle_grace_seconds"); - - b.Property("StreamMaxConcurrentSessions") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(50) - .HasColumnName("stream_max_concurrent_sessions"); - - b.Property("StreamReconnectConnectTimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(15) - .HasColumnName("stream_reconnect_connect_timeout_seconds"); - - b.Property("StreamReconnectOutageWindowSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(75) - .HasColumnName("stream_reconnect_outage_window_seconds"); - - b.Property("StreamReconnectReadStallTimeoutSeconds") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(30) - .HasColumnName("stream_reconnect_read_stall_timeout_seconds"); - - b.Property("StreamingEnabled") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(true) - .HasColumnName("streaming_enabled"); - - b.Property("StreamingSettingsRestartRequired") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER") - .HasDefaultValue(false) - .HasColumnName("streaming_settings_restart_required"); - - b.HasKey("Id"); - - b.ToTable("site_settings", (string)null); - - b.HasData( - new - { - Id = 1, - AuthenticationEnabled = false, - EndpointSecurityEnabled = false, - GeneratedHlsEnabled = true, - GeneratedHlsSettingsRestartRequired = false, - HdhrDiscoveryEnabled = true, - HdhrEnabled = true, - HdhrSettingsRestartRequired = false, - HdhrSiliconDustDiscoveryEnabled = true, - HdhrSsdpEnabled = true, - RefreshScheduleKind = "6h", - RefreshStartupCatchup = true, - StreamBufferMaxBytesHardCap = 33554432, - StreamBufferMaxBytesPerSession = 4194304, - StreamBufferReadChunkSizeBytes = 32768, - StreamIdleGraceHardCapSeconds = 120, - StreamIdleGraceSeconds = 15, - StreamMaxConcurrentSessions = 50, - StreamReconnectConnectTimeoutSeconds = 15, - StreamReconnectOutageWindowSeconds = 75, - StreamReconnectReadStallTimeoutSeconds = 30, - StreamingEnabled = true, - StreamingSettingsRestartRequired = false - }); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.Property("SnapshotId") - .HasColumnType("TEXT") - .HasColumnName("snapshot_id"); - - b.Property("ChangeClass") - .HasColumnType("TEXT") - .HasColumnName("change_class"); - - b.Property("ChannelCountPublished") - .HasColumnType("INTEGER") - .HasColumnName("channel_count_published"); - - b.Property("ChannelIndexPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_index_path"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("ErrorSummary") - .HasColumnType("TEXT") - .HasColumnName("error_summary"); - - b.Property("LiveChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("live_channel_count"); - - b.Property("PlaylistPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("playlist_path"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("SeriesChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("series_channel_count"); - - b.Property("Status") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status"); - - b.Property("StatusJsonPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("status_json_path"); - - b.Property("VodChannelCount") - .HasColumnType("INTEGER") - .HasColumnName("vod_channel_count"); - - b.Property("XmltvPath") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("xmltv_path"); - - b.HasKey("SnapshotId"); - - b.HasIndex("ProfileId", "Status", "CreatedUtc") - .IsDescending(false, false, true) - .HasDatabaseName("idx_snapshots_profile_status"); - - b.ToTable("snapshots", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.Property("Value") - .HasColumnType("TEXT") - .HasColumnName("stream_key"); - - b.Property("ChannelId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("channel_id"); - - b.Property("CreatedUtc") - .HasColumnType("TEXT") - .HasColumnName("created_utc"); - - b.Property("LastUsedUtc") - .HasColumnType("TEXT") - .HasColumnName("last_used_utc"); - - b.Property("ProfileId") - .IsRequired() - .HasColumnType("TEXT") - .HasColumnName("profile_id"); - - b.Property("Revoked") - .HasColumnType("INTEGER") - .HasColumnName("revoked"); - - b.HasKey("Value"); - - b.HasIndex("ChannelId") - .HasDatabaseName("idx_stream_keys_channel"); - - b.HasIndex("ProfileId", "ChannelId") - .IsUnique(); - - b.HasIndex("ProfileId", "Revoked") - .HasDatabaseName("idx_stream_keys_profile"); - - b.ToTable("stream_keys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("TEXT"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex"); - - b.ToTable("AspNetRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetRoleClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ClaimType") - .HasColumnType("TEXT"); - - b.Property("ClaimValue") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserClaims", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderKey") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("ProviderDisplayName") - .HasColumnType("TEXT"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserLogins", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.Property("CredentialId") - .HasMaxLength(1024) - .HasColumnType("BLOB"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("TEXT"); - - b.HasKey("CredentialId"); - - b.HasIndex("UserId"); - - b.ToTable("AspNetUserPasskeys", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("RoleId") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AspNetUserRoles", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Name") - .HasMaxLength(128) - .HasColumnType("TEXT"); - - b.Property("Value") - .HasColumnType("TEXT"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AspNetUserTokens", (string)null); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("CanonicalChannels") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelMatchRule", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ChannelMatchRules") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "TargetChannel") - .WithMany("TargetingMatchRules") - .HasForeignKey("TargetChannelId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - - b.Navigation("TargetChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ChannelSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("ChannelSources") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelSources") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ChannelSources") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Provider"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.DownstreamIntegration", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany() - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointAccessBinding", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "ActiveProfile") - .WithMany("ActiveEndpointAccessBindings") - .HasForeignKey("ActiveProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "DefaultProfile") - .WithMany("DefaultEndpointAccessBindings") - .HasForeignKey("DefaultProfileId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.EndpointCredential", "Credential") - .WithMany("Bindings") - .HasForeignKey("EndpointCredentialId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ActiveProfile"); - - b.Navigation("Credential"); - - b.Navigation("DefaultProfile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMap", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("EpgChannelMaps") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("EpgChannelMaps") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgChannelMapping", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("ChannelMappings") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany() - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany() - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - - b.Navigation("Profile"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgFetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("FetchRuns") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany() - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Cascade); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSourceChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.EpgSource", "EpgSource") - .WithMany("Channels") - .HasForeignKey("EpgSourceId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("EpgSource"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("FetchRuns") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroup", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("CustomGroups") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroupChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileCustomGroup", "CustomGroup") - .WithMany("Channels") - .HasForeignKey("CustomGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("CustomGroupChannels") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("CustomGroup"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroupProviderLink", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileCustomGroup", "CustomGroup") - .WithMany("ProviderLinks") - .HasForeignKey("CustomGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("CustomGroupProviderLinks") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("CustomGroup"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileEventInterestRule", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("EventInterestRules") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany() - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany() - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Profile"); - - b.Navigation("Provider"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupChannelFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.ProfileGroupFilter", "ProfileGroupFilter") - .WithMany("ChannelFilters") - .HasForeignKey("ProfileGroupFilterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderChannel", "ProviderChannel") - .WithMany("ChannelFilters") - .HasForeignKey("ProviderChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("ProfileGroupFilter"); - - b.Navigation("ProviderChannel"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProfileGroupFilters") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileProvider", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("ProfileProviders") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProfileProviders") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.HasOne("M3Undle.Web.Data.Entities.FetchRun", "LastFetchRun") - .WithMany("ProviderChannels") - .HasForeignKey("LastFetchRunId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.ProviderGroup", "ProviderGroup") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderGroupId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderChannels") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("LastFetchRun"); - - b.Navigation("Provider"); - - b.Navigation("ProviderGroup"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Provider", "Provider") - .WithMany("ProviderGroups") - .HasForeignKey("ProviderId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Provider"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Snapshot", b => - { - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("Snapshots") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => - { - b.HasOne("M3Undle.Web.Data.Entities.CanonicalChannel", "Channel") - .WithMany("StreamKeys") - .HasForeignKey("ChannelId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.Entities.Profile", "Profile") - .WithMany("StreamKeys") - .HasForeignKey("ProfileId") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("Channel"); - - b.Navigation("Profile"); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserPasskey", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.OwnsOne("Microsoft.AspNetCore.Identity.IdentityPasskeyData", "Data", b1 => - { - b1.Property("IdentityUserPasskeyCredentialId"); - - b1.Property("AttestationObject") - .IsRequired(); - - b1.Property("ClientDataJson") - .IsRequired(); - - b1.Property("CreatedAt"); - - b1.Property("IsBackedUp"); - - b1.Property("IsBackupEligible"); - - b1.Property("IsUserVerified"); - - b1.Property("Name"); - - b1.Property("PublicKey") - .IsRequired(); - - b1.Property("SignCount"); - - b1.PrimitiveCollection("Transports"); - - b1.HasKey("IdentityUserPasskeyCredentialId"); - - b1.ToTable("AspNetUserPasskeys"); - - b1 - .ToJson("Data") - .HasColumnType("TEXT"); - - b1.WithOwner() - .HasForeignKey("IdentityUserPasskeyCredentialId"); - }); - - b.Navigation("Data") - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("M3Undle.Web.Data.ApplicationUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.CanonicalChannel", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("StreamKeys"); - - b.Navigation("TargetingMatchRules"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EndpointCredential", b => - { - b.Navigation("Bindings"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.EpgSource", b => - { - b.Navigation("ChannelMappings"); - - b.Navigation("Channels"); - - b.Navigation("FetchRuns"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.FetchRun", b => - { - b.Navigation("ProviderChannels"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Profile", b => - { - b.Navigation("ActiveEndpointAccessBindings"); - - b.Navigation("CanonicalChannels"); - - b.Navigation("ChannelMatchRules"); - - b.Navigation("CustomGroups"); - - b.Navigation("DefaultEndpointAccessBindings"); - - b.Navigation("EpgChannelMaps"); - - b.Navigation("EventInterestRules"); - - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProfileProviders"); - - b.Navigation("Snapshots"); - - b.Navigation("StreamKeys"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileCustomGroup", b => - { - b.Navigation("Channels"); - - b.Navigation("ProviderLinks"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProfileGroupFilter", b => - { - b.Navigation("ChannelFilters"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.Provider", b => - { - b.Navigation("ChannelSources"); - - b.Navigation("FetchRuns"); - - b.Navigation("ProfileProviders"); - - b.Navigation("ProviderChannels"); - - b.Navigation("ProviderGroups"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderChannel", b => - { - b.Navigation("ChannelFilters"); - - b.Navigation("ChannelSources"); - - b.Navigation("CustomGroupChannels"); - }); - - modelBuilder.Entity("M3Undle.Web.Data.Entities.ProviderGroup", b => - { - b.Navigation("CustomGroupProviderLinks"); - - b.Navigation("ProfileGroupFilters"); - - b.Navigation("ProviderChannels"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260421103458_Alpha5_PendingModelFix.cs b/src/M3Undle.Web/Data/Migrations/20260421103458_Alpha5_PendingModelFix.cs deleted file mode 100644 index ead22ca..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260421103458_Alpha5_PendingModelFix.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - /// - public partial class Alpha5_PendingModelFix : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "channel_mode", - table: "profile_group_filters", - type: "TEXT", - nullable: false, - defaultValue: "select", - oldClrType: typeof(string), - oldType: "TEXT", - oldDefaultValue: "all"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.AlterColumn( - name: "channel_mode", - table: "profile_group_filters", - type: "TEXT", - nullable: false, - defaultValue: "all", - oldClrType: typeof(string), - oldType: "TEXT", - oldDefaultValue: "select"); - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260426000000_Alpha6_Schema.cs b/src/M3Undle.Web/Data/Migrations/20260426000000_Alpha6_Schema.cs deleted file mode 100644 index 5099656..0000000 --- a/src/M3Undle.Web/Data/Migrations/20260426000000_Alpha6_Schema.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace M3Undle.Web.Data.Migrations -{ - public partial class Alpha6_Schema : Migration - { - protected override void Up(MigrationBuilder migrationBuilder) - { - // XtreamDetection - migrationBuilder.AddColumn( - name: "xtream_detected_capable", - table: "providers", - type: "INTEGER", - nullable: false, - defaultValue: false); - - // ProviderExpiry - migrationBuilder.AddColumn( - name: "playlist_expires_utc", - table: "providers", - type: "TEXT", - nullable: true); - - // RemoveConfigTracking - migrationBuilder.DropColumn( - name: "config_source_path", - table: "providers"); - - migrationBuilder.DropColumn( - name: "needs_env_var_substitution", - table: "providers"); - - // SystemEvents - migrationBuilder.AddColumn( - name: "event_retention_days", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: 7); - - migrationBuilder.CreateTable( - name: "system_events", - columns: table => new - { - id = table.Column(type: "TEXT", nullable: false), - event_type = table.Column(type: "TEXT", nullable: false), - severity = table.Column(type: "TEXT", nullable: false), - title = table.Column(type: "TEXT", nullable: false), - detail = table.Column(type: "TEXT", nullable: true), - provider_id = table.Column(type: "TEXT", nullable: true), - integration_id = table.Column(type: "TEXT", nullable: true), - occurred_at = table.Column(type: "TEXT", nullable: false), - occurrence_count = table.Column(type: "INTEGER", nullable: false, defaultValue: 1) - }, - constraints: table => - { - table.PrimaryKey("PK_system_events", x => x.id); - }); - - migrationBuilder.UpdateData( - table: "site_settings", - keyColumn: "id", - keyValue: 1, - column: "event_retention_days", - value: 7); - - migrationBuilder.CreateIndex( - name: "ix_system_events_occurred_at", - table: "system_events", - column: "occurred_at"); - - // CleanRelayMode - migrationBuilder.AddColumn( - name: "clean_relay_mode", - table: "providers", - type: "TEXT", - nullable: false, - defaultValue: "off"); - - // ObservabilityMetrics - migrationBuilder.AddColumn( - name: "observability_metrics_enable_channel_labels", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: false); - - migrationBuilder.AddColumn( - name: "observability_metrics_enabled", - table: "site_settings", - type: "INTEGER", - nullable: false, - defaultValue: true); - - migrationBuilder.AddColumn( - name: "observability_metrics_local_allowed_cidrs", - table: "site_settings", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "observability_metrics_mode", - table: "site_settings", - type: "TEXT", - nullable: false, - defaultValue: "LocalOnly"); - - migrationBuilder.CreateTable( - name: "metrics_tokens", - columns: table => new - { - metrics_token_id = table.Column(type: "TEXT", nullable: false), - name = table.Column(type: "TEXT", nullable: false), - token_hash = table.Column(type: "TEXT", nullable: false), - scope = table.Column(type: "TEXT", nullable: false, defaultValue: "metrics:read"), - created_utc = table.Column(type: "TEXT", nullable: false), - last_used_utc = table.Column(type: "TEXT", nullable: true), - expires_utc = table.Column(type: "TEXT", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_metrics_tokens", x => x.metrics_token_id); - }); - - migrationBuilder.UpdateData( - table: "site_settings", - keyColumn: "id", - keyValue: 1, - columns: new[] { "observability_metrics_enabled", "observability_metrics_local_allowed_cidrs", "observability_metrics_mode" }, - values: new object[] { true, null, "LocalOnly" }); - - migrationBuilder.CreateIndex( - name: "IX_metrics_tokens_name", - table: "metrics_tokens", - column: "name", - unique: true); - - // PerProfileRefreshSchedule - migrationBuilder.AddColumn( - name: "refresh_schedule_kind_override", - table: "profiles", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "refresh_startup_catchup_override", - table: "profiles", - type: "INTEGER", - nullable: true); - - // SystemEventIndexes - migrationBuilder.CreateIndex( - name: "ix_system_events_event_type_integration_id", - table: "system_events", - columns: new[] { "event_type", "integration_id" }, - filter: "\"integration_id\" IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "ix_system_events_event_type_provider_id", - table: "system_events", - columns: new[] { "event_type", "provider_id" }, - filter: "\"provider_id\" IS NOT NULL"); - } - - protected override void Down(MigrationBuilder migrationBuilder) - { - // SystemEventIndexes (reverse) - migrationBuilder.DropIndex( - name: "ix_system_events_event_type_integration_id", - table: "system_events"); - - migrationBuilder.DropIndex( - name: "ix_system_events_event_type_provider_id", - table: "system_events"); - - // PerProfileRefreshSchedule (reverse) - migrationBuilder.DropColumn( - name: "refresh_schedule_kind_override", - table: "profiles"); - - migrationBuilder.DropColumn( - name: "refresh_startup_catchup_override", - table: "profiles"); - - // ObservabilityMetrics (reverse) - migrationBuilder.DropTable( - name: "metrics_tokens"); - - migrationBuilder.DropColumn( - name: "observability_metrics_enable_channel_labels", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "observability_metrics_enabled", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "observability_metrics_local_allowed_cidrs", - table: "site_settings"); - - migrationBuilder.DropColumn( - name: "observability_metrics_mode", - table: "site_settings"); - - // CleanRelayMode (reverse) - migrationBuilder.DropColumn( - name: "clean_relay_mode", - table: "providers"); - - // SystemEvents (reverse) - migrationBuilder.DropTable( - name: "system_events"); - - migrationBuilder.DropColumn( - name: "event_retention_days", - table: "site_settings"); - - // RemoveConfigTracking (reverse) - migrationBuilder.AddColumn( - name: "config_source_path", - table: "providers", - type: "TEXT", - nullable: true); - - migrationBuilder.AddColumn( - name: "needs_env_var_substitution", - table: "providers", - type: "INTEGER", - nullable: false, - defaultValue: false); - - // ProviderExpiry (reverse) - migrationBuilder.DropColumn( - name: "playlist_expires_utc", - table: "providers"); - - // XtreamDetection (reverse) - migrationBuilder.DropColumn( - name: "xtream_detected_capable", - table: "providers"); - } - } -} diff --git a/src/M3Undle.Web/Data/Migrations/20260426000000_Alpha6_Schema.Designer.cs b/src/M3Undle.Web/Data/Migrations/20260605114014_Alpha_Schema.Designer.cs similarity index 95% rename from src/M3Undle.Web/Data/Migrations/20260426000000_Alpha6_Schema.Designer.cs rename to src/M3Undle.Web/Data/Migrations/20260605114014_Alpha_Schema.Designer.cs index b253b9a..29d87d0 100644 --- a/src/M3Undle.Web/Data/Migrations/20260426000000_Alpha6_Schema.Designer.cs +++ b/src/M3Undle.Web/Data/Migrations/20260605114014_Alpha_Schema.Designer.cs @@ -1,4 +1,4 @@ -// +// using System; using M3Undle.Web.Data; using Microsoft.EntityFrameworkCore; @@ -11,14 +11,14 @@ namespace M3Undle.Web.Data.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20260426000000_Alpha6_Schema")] - partial class Alpha6_Schema + [Migration("20260605114014_Alpha_Schema")] + partial class Alpha_Schema { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); modelBuilder.Entity("M3Undle.Web.Data.ApplicationUser", b => { @@ -1352,7 +1352,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired() .ValueGeneratedOnAdd() .HasColumnType("TEXT") - .HasDefaultValue("off") + .HasDefaultValue("auto") .HasColumnName("clean_relay_mode"); b.Property("CreatedUtc") @@ -1695,6 +1695,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("hdhr_advertised_base_url"); + b.Property("HdhrAllowedNetworks") + .HasColumnType("TEXT") + .HasColumnName("hdhr_allowed_networks"); + b.Property("HdhrDiscoveryEnabled") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -1835,6 +1839,12 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasDefaultValue(false) .HasColumnName("streaming_settings_restart_required"); + b.Property("XtreamCompatibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("xtream_compatibility_enabled"); + b.HasKey("Id"); b.ToTable("site_settings", (string)null); @@ -1868,7 +1878,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) StreamReconnectOutageWindowSeconds = 75, StreamReconnectReadStallTimeoutSeconds = 30, StreamingEnabled = true, - StreamingSettingsRestartRequired = false + StreamingSettingsRestartRequired = false, + XtreamCompatibilityEnabled = true }); }); @@ -1945,6 +1956,118 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("snapshots", (string)null); }); + modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamChannelHealthEvent", b => + { + b.Property("StreamChannelHealthEventId") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BytesSuppressed") + .HasColumnType("INTEGER") + .HasColumnName("bytes_suppressed"); + + b.Property("CleanWatchDurationMs") + .HasColumnType("REAL") + .HasColumnName("clean_watch_duration_ms"); + + b.Property("ClientAbortAfterRecovery") + .HasColumnType("INTEGER") + .HasColumnName("client_abort_after_recovery"); + + b.Property("ClientAbortAfterRecoveryDelayMs") + .HasColumnType("REAL") + .HasColumnName("client_abort_after_recovery_delay_ms"); + + b.Property("ClientDisconnectReason") + .HasColumnType("TEXT") + .HasColumnName("client_disconnect_reason"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("EventKind") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_kind"); + + b.Property("EventUtc") + .HasColumnType("TEXT") + .HasColumnName("event_utc"); + + b.Property("ForcedRetune") + .HasColumnType("INTEGER") + .HasColumnName("forced_retune"); + + b.Property("OutputHeldMs") + .HasColumnType("REAL") + .HasColumnName("output_held_ms"); + + b.Property("ProviderChannelId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("provider_channel_id"); + + b.Property("ProviderId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("provider_id"); + + b.Property("ReconnectAttempt") + .HasColumnType("INTEGER") + .HasColumnName("reconnect_attempt"); + + b.Property("RecoveryDurationMs") + .HasColumnType("REAL") + .HasColumnName("recovery_duration_ms"); + + b.Property("RelayMode") + .HasColumnType("TEXT") + .HasColumnName("relay_mode"); + + b.Property("RouteClassification") + .HasColumnType("TEXT") + .HasColumnName("route_classification"); + + b.Property("SafeStartKind") + .HasColumnType("TEXT") + .HasColumnName("safe_start_kind"); + + b.Property("SafeStartWaitMs") + .HasColumnType("REAL") + .HasColumnName("safe_start_wait_ms"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StallDurationMs") + .HasColumnType("REAL") + .HasColumnName("stall_duration_ms"); + + b.Property("TsSyncLoss") + .HasColumnType("INTEGER") + .HasColumnName("ts_sync_loss"); + + b.Property("UpstreamFailureKind") + .HasColumnType("TEXT") + .HasColumnName("upstream_failure_kind"); + + b.HasKey("StreamChannelHealthEventId"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_stream_channel_health_events_session_id"); + + b.HasIndex("EventKind", "EventUtc") + .HasDatabaseName("ix_stream_channel_health_events_event_kind_event_utc"); + + b.HasIndex("ProviderId", "ProviderChannelId", "EventUtc") + .HasDatabaseName("ix_stream_channel_health_events_provider_channel_event_utc"); + + b.ToTable("stream_channel_health_events", (string)null); + }); + modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => { b.Property("Value") diff --git a/src/M3Undle.Web/Data/Migrations/20260605114014_Alpha_Schema.cs b/src/M3Undle.Web/Data/Migrations/20260605114014_Alpha_Schema.cs new file mode 100644 index 0000000..1b85b2f --- /dev/null +++ b/src/M3Undle.Web/Data/Migrations/20260605114014_Alpha_Schema.cs @@ -0,0 +1,1601 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace M3Undle.Web.Data.Migrations +{ + /// + public partial class Alpha_Schema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AdaptiveLockoutEscalated = table.Column(type: "INTEGER", nullable: false), + UserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "TEXT", maxLength: 256, nullable: true), + Email = table.Column(type: "TEXT", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "TEXT", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "INTEGER", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: true), + ConcurrencyStamp = table.Column(type: "TEXT", nullable: true), + PhoneNumber = table.Column(type: "TEXT", maxLength: 256, nullable: true), + PhoneNumberConfirmed = table.Column(type: "INTEGER", nullable: false), + TwoFactorEnabled = table.Column(type: "INTEGER", nullable: false), + LockoutEnd = table.Column(type: "TEXT", nullable: true), + LockoutEnabled = table.Column(type: "INTEGER", nullable: false), + AccessFailedCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "endpoint_credentials", + columns: table => new + { + endpoint_credential_id = table.Column(type: "TEXT", nullable: false), + username = table.Column(type: "TEXT", nullable: false), + normalized_username = table.Column(type: "TEXT", nullable: false), + password_hash = table.Column(type: "TEXT", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + auth_type = table.Column(type: "TEXT", nullable: false), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_endpoint_credentials", x => x.endpoint_credential_id); + }); + + migrationBuilder.CreateTable( + name: "metrics_tokens", + columns: table => new + { + metrics_token_id = table.Column(type: "TEXT", nullable: false), + name = table.Column(type: "TEXT", nullable: false), + token_hash = table.Column(type: "TEXT", nullable: false), + scope = table.Column(type: "TEXT", nullable: false, defaultValue: "metrics:read"), + created_utc = table.Column(type: "TEXT", nullable: false), + last_used_utc = table.Column(type: "TEXT", nullable: true), + expires_utc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_metrics_tokens", x => x.metrics_token_id); + }); + + migrationBuilder.CreateTable( + name: "profiles", + columns: table => new + { + profile_id = table.Column(type: "TEXT", nullable: false), + name = table.Column(type: "TEXT", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + is_active = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + output_name = table.Column(type: "TEXT", nullable: false), + merge_mode = table.Column(type: "TEXT", nullable: false), + refresh_schedule_kind_override = table.Column(type: "TEXT", nullable: true), + refresh_startup_catchup_override = table.Column(type: "INTEGER", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_profiles", x => x.profile_id); + }); + + migrationBuilder.CreateTable( + name: "providers", + columns: table => new + { + provider_id = table.Column(type: "TEXT", nullable: false), + name = table.Column(type: "TEXT", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + playlist_url = table.Column(type: "TEXT", nullable: false), + xmltv_url = table.Column(type: "TEXT", nullable: true), + headers_json = table.Column(type: "TEXT", nullable: true), + user_agent = table.Column(type: "TEXT", nullable: true), + timeout_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 20), + max_concurrent_streams = table.Column(type: "INTEGER", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false), + include_vod = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + include_series = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + force_mpegts = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + clean_relay_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "auto"), + xtream_base_url = table.Column(type: "TEXT", nullable: true), + xtream_username = table.Column(type: "TEXT", nullable: true), + xtream_encrypted_password = table.Column(type: "TEXT", nullable: true), + xtream_include_xmltv = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + xtream_detected_capable = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + playlist_expires_utc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_providers", x => x.provider_id); + }); + + migrationBuilder.CreateTable( + name: "site_settings", + columns: table => new + { + id = table.Column(type: "INTEGER", nullable: false), + authentication_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + endpoint_security_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + streaming_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + stream_max_concurrent_sessions = table.Column(type: "INTEGER", nullable: false, defaultValue: 50), + stream_idle_grace_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 15), + stream_idle_grace_hard_cap_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 120), + stream_buffer_max_bytes_per_session = table.Column(type: "INTEGER", nullable: false, defaultValue: 4194304), + stream_buffer_max_bytes_hard_cap = table.Column(type: "INTEGER", nullable: false, defaultValue: 33554432), + stream_buffer_read_chunk_size_bytes = table.Column(type: "INTEGER", nullable: false, defaultValue: 32768), + stream_reconnect_read_stall_timeout_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 30), + stream_reconnect_outage_window_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 75), + stream_reconnect_connect_timeout_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 15), + streaming_settings_restart_required = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + hdhr_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + hdhr_tuner_count_override = table.Column(type: "INTEGER", nullable: true), + hdhr_advertised_base_url = table.Column(type: "TEXT", nullable: true), + hdhr_discovery_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + hdhr_ssdp_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + hdhr_silicondust_discovery_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + hdhr_friendly_name = table.Column(type: "TEXT", nullable: true), + hdhr_settings_restart_required = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + generated_hls_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + generated_hls_ffmpeg_path = table.Column(type: "TEXT", nullable: true), + generated_hls_settings_restart_required = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + refresh_schedule_kind = table.Column(type: "TEXT", nullable: false, defaultValue: "6h"), + refresh_startup_catchup = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + event_retention_days = table.Column(type: "INTEGER", nullable: false, defaultValue: 7), + observability_metrics_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + observability_metrics_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "LocalOnly"), + observability_metrics_enable_channel_labels = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + observability_metrics_local_allowed_cidrs = table.Column(type: "TEXT", nullable: true), + xtream_compatibility_enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + hdhr_allowed_networks = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_site_settings", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "stream_channel_health_events", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + provider_id = table.Column(type: "TEXT", nullable: false), + provider_channel_id = table.Column(type: "TEXT", nullable: false), + display_name = table.Column(type: "TEXT", nullable: false), + event_kind = table.Column(type: "TEXT", nullable: false), + event_utc = table.Column(type: "TEXT", nullable: false), + session_id = table.Column(type: "TEXT", nullable: true), + relay_mode = table.Column(type: "TEXT", nullable: true), + route_classification = table.Column(type: "TEXT", nullable: true), + upstream_failure_kind = table.Column(type: "TEXT", nullable: true), + reconnect_attempt = table.Column(type: "INTEGER", nullable: true), + stall_duration_ms = table.Column(type: "REAL", nullable: true), + recovery_duration_ms = table.Column(type: "REAL", nullable: true), + safe_start_wait_ms = table.Column(type: "REAL", nullable: true), + output_held_ms = table.Column(type: "REAL", nullable: true), + safe_start_kind = table.Column(type: "TEXT", nullable: true), + client_disconnect_reason = table.Column(type: "TEXT", nullable: true), + client_abort_after_recovery = table.Column(type: "INTEGER", nullable: false), + client_abort_after_recovery_delay_ms = table.Column(type: "REAL", nullable: true), + forced_retune = table.Column(type: "INTEGER", nullable: false), + ts_sync_loss = table.Column(type: "INTEGER", nullable: false), + bytes_suppressed = table.Column(type: "INTEGER", nullable: true), + clean_watch_duration_ms = table.Column(type: "REAL", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_stream_channel_health_events", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "system_events", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + event_type = table.Column(type: "TEXT", nullable: false), + severity = table.Column(type: "TEXT", nullable: false), + title = table.Column(type: "TEXT", nullable: false), + detail = table.Column(type: "TEXT", nullable: true), + provider_id = table.Column(type: "TEXT", nullable: true), + integration_id = table.Column(type: "TEXT", nullable: true), + occurred_at = table.Column(type: "TEXT", nullable: false), + occurrence_count = table.Column(type: "INTEGER", nullable: false, defaultValue: 1) + }, + constraints: table => + { + table.PrimaryKey("PK_system_events", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RoleId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "TEXT", nullable: false), + ClaimType = table.Column(type: "TEXT", nullable: true), + ClaimValue = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), + ProviderKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + ProviderDisplayName = table.Column(type: "TEXT", nullable: true), + UserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserPasskeys", + columns: table => new + { + CredentialId = table.Column(type: "BLOB", maxLength: 1024, nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Data = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserPasskeys", x => x.CredentialId); + table.ForeignKey( + name: "FK_AspNetUserPasskeys_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "TEXT", nullable: false), + LoginProvider = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Value = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "canonical_channels", + columns: table => new + { + channel_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + display_name = table.Column(type: "TEXT", nullable: false), + channel_number = table.Column(type: "INTEGER", nullable: false), + group_name = table.Column(type: "TEXT", nullable: true), + logo_url = table.Column(type: "TEXT", nullable: true), + enabled = table.Column(type: "INTEGER", nullable: false), + is_event = table.Column(type: "INTEGER", nullable: false), + event_policy = table.Column(type: "TEXT", nullable: false), + notes = table.Column(type: "TEXT", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_canonical_channels", x => x.channel_id); + table.ForeignKey( + name: "FK_canonical_channels_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "downstream_integrations", + columns: table => new + { + downstream_integration_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: true), + name = table.Column(type: "TEXT", nullable: false), + kind = table.Column(type: "TEXT", nullable: false), + base_url = table.Column(type: "TEXT", nullable: false), + api_key_encrypted = table.Column(type: "TEXT", nullable: true), + webhook_headers_json = table.Column(type: "TEXT", nullable: true), + trigger_on_lineup_update = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + trigger_on_guide_update = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + last_notified_utc = table.Column(type: "TEXT", nullable: true), + last_notify_error = table.Column(type: "TEXT", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_downstream_integrations", x => x.downstream_integration_id); + table.ForeignKey( + name: "FK_downstream_integrations_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "endpoint_access_bindings", + columns: table => new + { + endpoint_access_binding_id = table.Column(type: "TEXT", nullable: false), + endpoint_credential_id = table.Column(type: "TEXT", nullable: false), + active_profile_id = table.Column(type: "TEXT", nullable: true), + default_profile_id = table.Column(type: "TEXT", nullable: true), + virtual_tuner_id = table.Column(type: "TEXT", nullable: true), + enabled = table.Column(type: "INTEGER", nullable: false), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_endpoint_access_bindings", x => x.endpoint_access_binding_id); + table.ForeignKey( + name: "FK_endpoint_access_bindings_endpoint_credentials_endpoint_credential_id", + column: x => x.endpoint_credential_id, + principalTable: "endpoint_credentials", + principalColumn: "endpoint_credential_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_endpoint_access_bindings_profiles_active_profile_id", + column: x => x.active_profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_endpoint_access_bindings_profiles_default_profile_id", + column: x => x.default_profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "profile_custom_groups", + columns: table => new + { + custom_group_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + name = table.Column(type: "TEXT", nullable: false), + decision = table.Column(type: "TEXT", nullable: false, defaultValue: "include"), + channel_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "select"), + tracking_policy = table.Column(type: "TEXT", nullable: false, defaultValue: "review"), + tracking_keywords = table.Column(type: "TEXT", nullable: true), + auto_num_start = table.Column(type: "INTEGER", nullable: true), + auto_num_end = table.Column(type: "INTEGER", nullable: true), + track_new_channels = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + sort_override = table.Column(type: "INTEGER", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_profile_custom_groups", x => x.custom_group_id); + table.ForeignKey( + name: "FK_profile_custom_groups_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "snapshots", + columns: table => new + { + snapshot_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + created_utc = table.Column(type: "TEXT", nullable: false), + status = table.Column(type: "TEXT", nullable: false), + playlist_path = table.Column(type: "TEXT", nullable: false), + xmltv_path = table.Column(type: "TEXT", nullable: false), + channel_index_path = table.Column(type: "TEXT", nullable: false), + status_json_path = table.Column(type: "TEXT", nullable: false), + channel_count_published = table.Column(type: "INTEGER", nullable: false), + live_channel_count = table.Column(type: "INTEGER", nullable: false), + vod_channel_count = table.Column(type: "INTEGER", nullable: false), + series_channel_count = table.Column(type: "INTEGER", nullable: false), + error_summary = table.Column(type: "TEXT", nullable: true), + change_class = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_snapshots", x => x.snapshot_id); + table.ForeignKey( + name: "FK_snapshots_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "epg_sources", + columns: table => new + { + epg_source_id = table.Column(type: "TEXT", nullable: false), + provider_id = table.Column(type: "TEXT", nullable: true), + name = table.Column(type: "TEXT", nullable: false), + kind = table.Column(type: "TEXT", nullable: false, defaultValue: "xmltv_url"), + url_or_path = table.Column(type: "TEXT", nullable: true), + priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 10), + enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + headers_json = table.Column(type: "TEXT", nullable: true), + user_agent = table.Column(type: "TEXT", nullable: true), + timeout_seconds = table.Column(type: "INTEGER", nullable: false, defaultValue: 30), + etag = table.Column(type: "TEXT", nullable: true), + last_modified_utc = table.Column(type: "TEXT", nullable: true), + last_success_utc = table.Column(type: "TEXT", nullable: true), + last_failure_utc = table.Column(type: "TEXT", nullable: true), + refresh_interval_hours = table.Column(type: "INTEGER", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_epg_sources", x => x.epg_source_id); + table.ForeignKey( + name: "FK_epg_sources_providers_provider_id", + column: x => x.provider_id, + principalTable: "providers", + principalColumn: "provider_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "fetch_runs", + columns: table => new + { + fetch_run_id = table.Column(type: "TEXT", nullable: false), + provider_id = table.Column(type: "TEXT", nullable: false), + started_utc = table.Column(type: "TEXT", nullable: false), + finished_utc = table.Column(type: "TEXT", nullable: true), + status = table.Column(type: "TEXT", nullable: false), + type = table.Column(type: "TEXT", nullable: false, defaultValue: "snapshot"), + error_summary = table.Column(type: "TEXT", nullable: true), + playlist_etag = table.Column(type: "TEXT", nullable: true), + playlist_last_modified = table.Column(type: "TEXT", nullable: true), + xmltv_etag = table.Column(type: "TEXT", nullable: true), + xmltv_last_modified = table.Column(type: "TEXT", nullable: true), + playlist_bytes = table.Column(type: "INTEGER", nullable: true), + xmltv_bytes = table.Column(type: "INTEGER", nullable: true), + channel_count_seen = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_fetch_runs", x => x.fetch_run_id); + table.ForeignKey( + name: "FK_fetch_runs_providers_provider_id", + column: x => x.provider_id, + principalTable: "providers", + principalColumn: "provider_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "profile_providers", + columns: table => new + { + profile_id = table.Column(type: "TEXT", nullable: false), + provider_id = table.Column(type: "TEXT", nullable: false), + priority = table.Column(type: "INTEGER", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_profile_providers", x => new { x.profile_id, x.provider_id }); + table.ForeignKey( + name: "FK_profile_providers_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_profile_providers_providers_provider_id", + column: x => x.provider_id, + principalTable: "providers", + principalColumn: "provider_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "provider_groups", + columns: table => new + { + provider_group_id = table.Column(type: "TEXT", nullable: false), + provider_id = table.Column(type: "TEXT", nullable: false), + raw_name = table.Column(type: "TEXT", nullable: false), + normalized_name = table.Column(type: "TEXT", nullable: true), + first_seen_utc = table.Column(type: "TEXT", nullable: false), + last_seen_utc = table.Column(type: "TEXT", nullable: false), + active = table.Column(type: "INTEGER", nullable: false), + channel_count = table.Column(type: "INTEGER", nullable: true), + content_type = table.Column(type: "TEXT", nullable: false, defaultValue: "live") + }, + constraints: table => + { + table.PrimaryKey("PK_provider_groups", x => x.provider_group_id); + table.ForeignKey( + name: "FK_provider_groups_providers_provider_id", + column: x => x.provider_id, + principalTable: "providers", + principalColumn: "provider_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "channel_match_rules", + columns: table => new + { + rule_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + match_type = table.Column(type: "TEXT", nullable: false), + match_value = table.Column(type: "TEXT", nullable: false), + target_channel_id = table.Column(type: "TEXT", nullable: true), + target_group_name = table.Column(type: "TEXT", nullable: true), + default_priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 1), + is_event_rule = table.Column(type: "INTEGER", nullable: false), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_channel_match_rules", x => x.rule_id); + table.ForeignKey( + name: "FK_channel_match_rules_canonical_channels_target_channel_id", + column: x => x.target_channel_id, + principalTable: "canonical_channels", + principalColumn: "channel_id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_channel_match_rules_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "epg_channel_map", + columns: table => new + { + epg_map_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + channel_id = table.Column(type: "TEXT", nullable: false), + xmltv_channel_id = table.Column(type: "TEXT", nullable: false), + source = table.Column(type: "TEXT", nullable: false), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_epg_channel_map", x => x.epg_map_id); + table.ForeignKey( + name: "FK_epg_channel_map_canonical_channels_channel_id", + column: x => x.channel_id, + principalTable: "canonical_channels", + principalColumn: "channel_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_epg_channel_map_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "stream_keys", + columns: table => new + { + stream_key = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + channel_id = table.Column(type: "TEXT", nullable: false), + created_utc = table.Column(type: "TEXT", nullable: false), + last_used_utc = table.Column(type: "TEXT", nullable: true), + revoked = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_stream_keys", x => x.stream_key); + table.ForeignKey( + name: "FK_stream_keys_canonical_channels_channel_id", + column: x => x.channel_id, + principalTable: "canonical_channels", + principalColumn: "channel_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_stream_keys_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "epg_fetch_runs", + columns: table => new + { + epg_fetch_run_id = table.Column(type: "TEXT", nullable: false), + epg_source_id = table.Column(type: "TEXT", nullable: false), + started_utc = table.Column(type: "TEXT", nullable: false), + finished_utc = table.Column(type: "TEXT", nullable: true), + status = table.Column(type: "TEXT", nullable: false), + bytes = table.Column(type: "INTEGER", nullable: true), + channel_count = table.Column(type: "INTEGER", nullable: true), + programme_count = table.Column(type: "INTEGER", nullable: true), + error_summary = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_epg_fetch_runs", x => x.epg_fetch_run_id); + table.ForeignKey( + name: "FK_epg_fetch_runs_epg_sources_epg_source_id", + column: x => x.epg_source_id, + principalTable: "epg_sources", + principalColumn: "epg_source_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "epg_source_channels", + columns: table => new + { + epg_source_channel_id = table.Column(type: "TEXT", nullable: false), + epg_source_id = table.Column(type: "TEXT", nullable: false), + xmltv_channel_id = table.Column(type: "TEXT", nullable: false), + display_name = table.Column(type: "TEXT", nullable: false), + icon_url = table.Column(type: "TEXT", nullable: true), + last_seen_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_epg_source_channels", x => x.epg_source_channel_id); + table.ForeignKey( + name: "FK_epg_source_channels_epg_sources_epg_source_id", + column: x => x.epg_source_id, + principalTable: "epg_sources", + principalColumn: "epg_source_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "profile_custom_group_provider_links", + columns: table => new + { + link_id = table.Column(type: "TEXT", nullable: false), + custom_group_id = table.Column(type: "TEXT", nullable: false), + provider_group_id = table.Column(type: "TEXT", nullable: false), + created_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_profile_custom_group_provider_links", x => x.link_id); + table.ForeignKey( + name: "FK_profile_custom_group_provider_links_profile_custom_groups_custom_group_id", + column: x => x.custom_group_id, + principalTable: "profile_custom_groups", + principalColumn: "custom_group_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_profile_custom_group_provider_links_provider_groups_provider_group_id", + column: x => x.provider_group_id, + principalTable: "provider_groups", + principalColumn: "provider_group_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "profile_event_interest_rules", + columns: table => new + { + rule_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + provider_id = table.Column(type: "TEXT", nullable: true), + provider_group_id = table.Column(type: "TEXT", nullable: true), + enabled = table.Column(type: "INTEGER", nullable: false, defaultValue: true), + match_type = table.Column(type: "TEXT", nullable: false), + match_value = table.Column(type: "TEXT", nullable: false), + action = table.Column(type: "TEXT", nullable: false), + priority = table.Column(type: "INTEGER", nullable: false, defaultValue: 100), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_profile_event_interest_rules", x => x.rule_id); + table.ForeignKey( + name: "FK_profile_event_interest_rules_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_profile_event_interest_rules_provider_groups_provider_group_id", + column: x => x.provider_group_id, + principalTable: "provider_groups", + principalColumn: "provider_group_id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_profile_event_interest_rules_providers_provider_id", + column: x => x.provider_id, + principalTable: "providers", + principalColumn: "provider_id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "profile_group_filters", + columns: table => new + { + profile_group_filter_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + provider_group_id = table.Column(type: "TEXT", nullable: false), + decision = table.Column(type: "TEXT", nullable: false, defaultValue: "include"), + is_new = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + channel_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "select"), + tracking_policy = table.Column(type: "TEXT", nullable: false, defaultValue: "review"), + tracking_keywords = table.Column(type: "TEXT", nullable: true), + output_name = table.Column(type: "TEXT", nullable: true), + auto_num_start = table.Column(type: "INTEGER", nullable: true), + auto_num_end = table.Column(type: "INTEGER", nullable: true), + track_new_channels = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + sort_override = table.Column(type: "INTEGER", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_profile_group_filters", x => x.profile_group_filter_id); + table.ForeignKey( + name: "FK_profile_group_filters_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_profile_group_filters_provider_groups_provider_group_id", + column: x => x.provider_group_id, + principalTable: "provider_groups", + principalColumn: "provider_group_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "provider_channels", + columns: table => new + { + provider_channel_id = table.Column(type: "TEXT", nullable: false), + provider_id = table.Column(type: "TEXT", nullable: false), + provider_channel_key = table.Column(type: "TEXT", nullable: true), + display_name = table.Column(type: "TEXT", nullable: false), + tvg_id = table.Column(type: "TEXT", nullable: true), + tvg_name = table.Column(type: "TEXT", nullable: true), + logo_url = table.Column(type: "TEXT", nullable: true), + stream_url = table.Column(type: "TEXT", nullable: false), + group_title = table.Column(type: "TEXT", nullable: true), + provider_group_id = table.Column(type: "TEXT", nullable: true), + is_event = table.Column(type: "INTEGER", nullable: false), + is_placeholder = table.Column(type: "INTEGER", nullable: false, defaultValue: false), + event_slot_key = table.Column(type: "TEXT", nullable: true), + event_content_key = table.Column(type: "TEXT", nullable: true), + event_title = table.Column(type: "TEXT", nullable: true), + event_sport = table.Column(type: "TEXT", nullable: true), + event_league = table.Column(type: "TEXT", nullable: true), + event_participants_json = table.Column(type: "TEXT", nullable: true), + event_start_utc = table.Column(type: "TEXT", nullable: true), + event_end_utc = table.Column(type: "TEXT", nullable: true), + first_seen_utc = table.Column(type: "TEXT", nullable: false), + last_seen_utc = table.Column(type: "TEXT", nullable: false), + active = table.Column(type: "INTEGER", nullable: false), + content_type = table.Column(type: "TEXT", nullable: false, defaultValue: "live"), + last_fetch_run_id = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_provider_channels", x => x.provider_channel_id); + table.ForeignKey( + name: "FK_provider_channels_fetch_runs_last_fetch_run_id", + column: x => x.last_fetch_run_id, + principalTable: "fetch_runs", + principalColumn: "fetch_run_id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_provider_channels_provider_groups_provider_group_id", + column: x => x.provider_group_id, + principalTable: "provider_groups", + principalColumn: "provider_group_id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_provider_channels_providers_provider_id", + column: x => x.provider_id, + principalTable: "providers", + principalColumn: "provider_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "channel_sources", + columns: table => new + { + channel_source_id = table.Column(type: "TEXT", nullable: false), + channel_id = table.Column(type: "TEXT", nullable: false), + provider_id = table.Column(type: "TEXT", nullable: false), + provider_channel_id = table.Column(type: "TEXT", nullable: false), + priority = table.Column(type: "INTEGER", nullable: false), + enabled = table.Column(type: "INTEGER", nullable: false), + override_stream_url = table.Column(type: "TEXT", nullable: true), + last_success_utc = table.Column(type: "TEXT", nullable: true), + last_failure_utc = table.Column(type: "TEXT", nullable: true), + failure_count_rolling = table.Column(type: "INTEGER", nullable: false, defaultValue: 0), + health_state = table.Column(type: "TEXT", nullable: false), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_channel_sources", x => x.channel_source_id); + table.ForeignKey( + name: "FK_channel_sources_canonical_channels_channel_id", + column: x => x.channel_id, + principalTable: "canonical_channels", + principalColumn: "channel_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_channel_sources_provider_channels_provider_channel_id", + column: x => x.provider_channel_id, + principalTable: "provider_channels", + principalColumn: "provider_channel_id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_channel_sources_providers_provider_id", + column: x => x.provider_id, + principalTable: "providers", + principalColumn: "provider_id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "epg_channel_mappings", + columns: table => new + { + epg_channel_mapping_id = table.Column(type: "TEXT", nullable: false), + profile_id = table.Column(type: "TEXT", nullable: false), + provider_channel_id = table.Column(type: "TEXT", nullable: false), + epg_source_id = table.Column(type: "TEXT", nullable: false), + xmltv_channel_id = table.Column(type: "TEXT", nullable: false), + mapping_mode = table.Column(type: "TEXT", nullable: false, defaultValue: "auto_id"), + confidence = table.Column(type: "REAL", nullable: false, defaultValue: 1f), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_epg_channel_mappings", x => x.epg_channel_mapping_id); + table.ForeignKey( + name: "FK_epg_channel_mappings_epg_sources_epg_source_id", + column: x => x.epg_source_id, + principalTable: "epg_sources", + principalColumn: "epg_source_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_epg_channel_mappings_profiles_profile_id", + column: x => x.profile_id, + principalTable: "profiles", + principalColumn: "profile_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_epg_channel_mappings_provider_channels_provider_channel_id", + column: x => x.provider_channel_id, + principalTable: "provider_channels", + principalColumn: "provider_channel_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "profile_custom_group_channels", + columns: table => new + { + custom_group_channel_id = table.Column(type: "TEXT", nullable: false), + custom_group_id = table.Column(type: "TEXT", nullable: false), + provider_channel_id = table.Column(type: "TEXT", nullable: false), + state = table.Column(type: "TEXT", nullable: false, defaultValue: "included"), + channel_number = table.Column(type: "INTEGER", nullable: true), + display_name_override = table.Column(type: "TEXT", nullable: true), + tvg_id_override = table.Column(type: "TEXT", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_profile_custom_group_channels", x => x.custom_group_channel_id); + table.ForeignKey( + name: "FK_profile_custom_group_channels_profile_custom_groups_custom_group_id", + column: x => x.custom_group_id, + principalTable: "profile_custom_groups", + principalColumn: "custom_group_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_profile_custom_group_channels_provider_channels_provider_channel_id", + column: x => x.provider_channel_id, + principalTable: "provider_channels", + principalColumn: "provider_channel_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "profile_group_channel_filters", + columns: table => new + { + profile_group_channel_filter_id = table.Column(type: "TEXT", nullable: false), + profile_group_filter_id = table.Column(type: "TEXT", nullable: false), + provider_channel_id = table.Column(type: "TEXT", nullable: false), + state = table.Column(type: "TEXT", nullable: false, defaultValue: "included"), + display_name_override = table.Column(type: "TEXT", nullable: true), + output_group_name = table.Column(type: "TEXT", nullable: true), + channel_number = table.Column(type: "INTEGER", nullable: true), + tvg_id_override = table.Column(type: "TEXT", nullable: true), + created_utc = table.Column(type: "TEXT", nullable: false), + updated_utc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_profile_group_channel_filters", x => x.profile_group_channel_filter_id); + table.ForeignKey( + name: "FK_profile_group_channel_filters_profile_group_filters_profile_group_filter_id", + column: x => x.profile_group_filter_id, + principalTable: "profile_group_filters", + principalColumn: "profile_group_filter_id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_profile_group_channel_filters_provider_channels_provider_channel_id", + column: x => x.provider_channel_id, + principalTable: "provider_channels", + principalColumn: "provider_channel_id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.InsertData( + table: "site_settings", + columns: new[] { "id", "event_retention_days", "generated_hls_enabled", "generated_hls_ffmpeg_path", "hdhr_advertised_base_url", "hdhr_allowed_networks", "hdhr_discovery_enabled", "hdhr_enabled", "hdhr_friendly_name", "hdhr_silicondust_discovery_enabled", "hdhr_ssdp_enabled", "hdhr_tuner_count_override", "observability_metrics_enabled", "observability_metrics_local_allowed_cidrs", "observability_metrics_mode", "refresh_schedule_kind", "refresh_startup_catchup", "stream_buffer_max_bytes_hard_cap", "stream_buffer_max_bytes_per_session", "stream_buffer_read_chunk_size_bytes", "stream_idle_grace_hard_cap_seconds", "stream_idle_grace_seconds", "stream_max_concurrent_sessions", "stream_reconnect_connect_timeout_seconds", "stream_reconnect_outage_window_seconds", "stream_reconnect_read_stall_timeout_seconds", "streaming_enabled", "xtream_compatibility_enabled" }, + values: new object[] { 1, 7, true, null, null, null, true, true, null, true, true, null, true, null, "LocalOnly", "6h", true, 33554432, 4194304, 32768, 120, 15, 50, 15, 75, 30, true, true }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserPasskeys_UserId", + table: "AspNetUserPasskeys", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_canonical_channels_profile_enabled", + table: "canonical_channels", + columns: new[] { "profile_id", "enabled" }); + + migrationBuilder.CreateIndex( + name: "idx_canonical_channels_profile_number", + table: "canonical_channels", + columns: new[] { "profile_id", "channel_number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_match_rules_profile", + table: "channel_match_rules", + columns: new[] { "profile_id", "enabled" }); + + migrationBuilder.CreateIndex( + name: "IX_channel_match_rules_target_channel_id", + table: "channel_match_rules", + column: "target_channel_id"); + + migrationBuilder.CreateIndex( + name: "idx_channel_sources_channel", + table: "channel_sources", + columns: new[] { "channel_id", "priority" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_channel_sources_health", + table: "channel_sources", + columns: new[] { "health_state", "last_failure_utc" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "IX_channel_sources_provider_channel_id", + table: "channel_sources", + column: "provider_channel_id"); + + migrationBuilder.CreateIndex( + name: "IX_channel_sources_provider_id", + table: "channel_sources", + column: "provider_id"); + + migrationBuilder.CreateIndex( + name: "idx_downstream_integrations_profile", + table: "downstream_integrations", + column: "profile_id"); + + migrationBuilder.CreateIndex( + name: "idx_endpoint_access_bindings_active_profile", + table: "endpoint_access_bindings", + column: "active_profile_id"); + + migrationBuilder.CreateIndex( + name: "idx_endpoint_access_bindings_credential", + table: "endpoint_access_bindings", + column: "endpoint_credential_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_endpoint_access_bindings_default_profile_id", + table: "endpoint_access_bindings", + column: "default_profile_id"); + + migrationBuilder.CreateIndex( + name: "IX_endpoint_credentials_normalized_username", + table: "endpoint_credentials", + column: "normalized_username", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_endpoint_credentials_username", + table: "endpoint_credentials", + column: "username", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_epg_map_profile", + table: "epg_channel_map", + columns: new[] { "profile_id", "xmltv_channel_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_epg_channel_map_channel_id", + table: "epg_channel_map", + column: "channel_id"); + + migrationBuilder.CreateIndex( + name: "IX_epg_channel_map_profile_id_channel_id", + table: "epg_channel_map", + columns: new[] { "profile_id", "channel_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_epg_channel_mappings_profile_channel_source", + table: "epg_channel_mappings", + columns: new[] { "profile_id", "provider_channel_id", "epg_source_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_epg_channel_mappings_epg_source_id", + table: "epg_channel_mappings", + column: "epg_source_id"); + + migrationBuilder.CreateIndex( + name: "IX_epg_channel_mappings_provider_channel_id", + table: "epg_channel_mappings", + column: "provider_channel_id"); + + migrationBuilder.CreateIndex( + name: "idx_epg_fetch_runs_source_time", + table: "epg_fetch_runs", + columns: new[] { "epg_source_id", "started_utc" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "idx_epg_source_channels_source_channel", + table: "epg_source_channels", + columns: new[] { "epg_source_id", "xmltv_channel_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_epg_sources_provider", + table: "epg_sources", + column: "provider_id"); + + migrationBuilder.CreateIndex( + name: "idx_epg_sources_provider_priority", + table: "epg_sources", + columns: new[] { "provider_id", "priority" }); + + migrationBuilder.CreateIndex( + name: "idx_fetch_runs_provider_time", + table: "fetch_runs", + columns: new[] { "provider_id", "started_utc" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "idx_fetch_runs_status", + table: "fetch_runs", + columns: new[] { "status", "started_utc" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "IX_metrics_tokens_name", + table: "metrics_tokens", + column: "name", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_pcgc_group_channel_unique", + table: "profile_custom_group_channels", + columns: new[] { "custom_group_id", "provider_channel_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_pcgc_group_state", + table: "profile_custom_group_channels", + columns: new[] { "custom_group_id", "state" }); + + migrationBuilder.CreateIndex( + name: "IX_profile_custom_group_channels_provider_channel_id", + table: "profile_custom_group_channels", + column: "provider_channel_id"); + + migrationBuilder.CreateIndex( + name: "idx_pcgpl_group_provider_unique", + table: "profile_custom_group_provider_links", + columns: new[] { "custom_group_id", "provider_group_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_profile_custom_group_provider_links_provider_group_id", + table: "profile_custom_group_provider_links", + column: "provider_group_id"); + + migrationBuilder.CreateIndex( + name: "idx_pcg_profile_id", + table: "profile_custom_groups", + column: "profile_id"); + + migrationBuilder.CreateIndex( + name: "idx_pcg_profile_name_unique", + table: "profile_custom_groups", + columns: new[] { "profile_id", "name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_peir_profile_enabled_priority", + table: "profile_event_interest_rules", + columns: new[] { "profile_id", "enabled", "priority" }); + + migrationBuilder.CreateIndex( + name: "IX_profile_event_interest_rules_provider_group_id", + table: "profile_event_interest_rules", + column: "provider_group_id"); + + migrationBuilder.CreateIndex( + name: "IX_profile_event_interest_rules_provider_id", + table: "profile_event_interest_rules", + column: "provider_id"); + + migrationBuilder.CreateIndex( + name: "idx_pgcf_filter_channel_unique", + table: "profile_group_channel_filters", + columns: new[] { "profile_group_filter_id", "provider_channel_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_profile_group_channel_filters_provider_channel_id", + table: "profile_group_channel_filters", + column: "provider_channel_id"); + + migrationBuilder.CreateIndex( + name: "idx_pgf_profile_decision", + table: "profile_group_filters", + columns: new[] { "profile_id", "decision" }); + + migrationBuilder.CreateIndex( + name: "idx_pgf_profile_group_unique", + table: "profile_group_filters", + columns: new[] { "profile_id", "provider_group_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_pgf_profile_tracking_policy", + table: "profile_group_filters", + columns: new[] { "profile_id", "tracking_policy" }); + + migrationBuilder.CreateIndex( + name: "IX_profile_group_filters_provider_group_id", + table: "profile_group_filters", + column: "provider_group_id"); + + migrationBuilder.CreateIndex( + name: "idx_profile_providers_profile", + table: "profile_providers", + columns: new[] { "profile_id", "priority" }); + + migrationBuilder.CreateIndex( + name: "IX_profile_providers_provider_id", + table: "profile_providers", + column: "provider_id"); + + migrationBuilder.CreateIndex( + name: "idx_profiles_is_active", + table: "profiles", + column: "is_active", + unique: true, + filter: "is_active = 1"); + + migrationBuilder.CreateIndex( + name: "IX_profiles_name", + table: "profiles", + column: "name", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_provider_channels_event_content", + table: "provider_channels", + columns: new[] { "provider_id", "event_content_key" }, + filter: "event_content_key IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "idx_provider_channels_is_event", + table: "provider_channels", + columns: new[] { "provider_id", "is_event", "event_start_utc" }); + + migrationBuilder.CreateIndex( + name: "idx_provider_channels_placeholder_active", + table: "provider_channels", + columns: new[] { "provider_id", "is_placeholder", "active" }); + + migrationBuilder.CreateIndex( + name: "idx_provider_channels_provider_active", + table: "provider_channels", + columns: new[] { "provider_id", "active" }); + + migrationBuilder.CreateIndex( + name: "idx_provider_channels_seen", + table: "provider_channels", + columns: new[] { "provider_id", "last_seen_utc" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "IX_provider_channels_last_fetch_run_id", + table: "provider_channels", + column: "last_fetch_run_id"); + + migrationBuilder.CreateIndex( + name: "IX_provider_channels_provider_group_id", + table: "provider_channels", + column: "provider_group_id"); + + migrationBuilder.CreateIndex( + name: "IX_provider_channels_provider_id_provider_channel_key", + table: "provider_channels", + columns: new[] { "provider_id", "provider_channel_key" }, + unique: true, + filter: "provider_channel_key IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "idx_provider_groups_provider_active", + table: "provider_groups", + columns: new[] { "provider_id", "active" }); + + migrationBuilder.CreateIndex( + name: "IX_provider_groups_provider_id_raw_name", + table: "provider_groups", + columns: new[] { "provider_id", "raw_name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_providers_enabled", + table: "providers", + column: "enabled"); + + migrationBuilder.CreateIndex( + name: "IX_providers_name", + table: "providers", + column: "name", + unique: true); + + migrationBuilder.CreateIndex( + name: "idx_snapshots_profile_status", + table: "snapshots", + columns: new[] { "profile_id", "status", "created_utc" }, + descending: new[] { false, false, true }); + + migrationBuilder.CreateIndex( + name: "ix_stream_channel_health_events_event_kind_event_utc", + table: "stream_channel_health_events", + columns: new[] { "event_kind", "event_utc" }); + + migrationBuilder.CreateIndex( + name: "ix_stream_channel_health_events_provider_channel_event_utc", + table: "stream_channel_health_events", + columns: new[] { "provider_id", "provider_channel_id", "event_utc" }); + + migrationBuilder.CreateIndex( + name: "ix_stream_channel_health_events_session_id", + table: "stream_channel_health_events", + column: "session_id"); + + migrationBuilder.CreateIndex( + name: "idx_stream_keys_channel", + table: "stream_keys", + column: "channel_id"); + + migrationBuilder.CreateIndex( + name: "idx_stream_keys_profile", + table: "stream_keys", + columns: new[] { "profile_id", "revoked" }); + + migrationBuilder.CreateIndex( + name: "IX_stream_keys_profile_id_channel_id", + table: "stream_keys", + columns: new[] { "profile_id", "channel_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_system_events_event_type_integration_id", + table: "system_events", + columns: new[] { "event_type", "integration_id" }, + filter: "\"integration_id\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "ix_system_events_event_type_provider_id", + table: "system_events", + columns: new[] { "event_type", "provider_id" }, + filter: "\"provider_id\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "ix_system_events_occurred_at", + table: "system_events", + column: "occurred_at"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserPasskeys"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "channel_match_rules"); + + migrationBuilder.DropTable( + name: "channel_sources"); + + migrationBuilder.DropTable( + name: "downstream_integrations"); + + migrationBuilder.DropTable( + name: "endpoint_access_bindings"); + + migrationBuilder.DropTable( + name: "epg_channel_map"); + + migrationBuilder.DropTable( + name: "epg_channel_mappings"); + + migrationBuilder.DropTable( + name: "epg_fetch_runs"); + + migrationBuilder.DropTable( + name: "epg_source_channels"); + + migrationBuilder.DropTable( + name: "metrics_tokens"); + + migrationBuilder.DropTable( + name: "profile_custom_group_channels"); + + migrationBuilder.DropTable( + name: "profile_custom_group_provider_links"); + + migrationBuilder.DropTable( + name: "profile_event_interest_rules"); + + migrationBuilder.DropTable( + name: "profile_group_channel_filters"); + + migrationBuilder.DropTable( + name: "profile_providers"); + + migrationBuilder.DropTable( + name: "site_settings"); + + migrationBuilder.DropTable( + name: "snapshots"); + + migrationBuilder.DropTable( + name: "stream_channel_health_events"); + + migrationBuilder.DropTable( + name: "stream_keys"); + + migrationBuilder.DropTable( + name: "system_events"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + + migrationBuilder.DropTable( + name: "endpoint_credentials"); + + migrationBuilder.DropTable( + name: "epg_sources"); + + migrationBuilder.DropTable( + name: "profile_custom_groups"); + + migrationBuilder.DropTable( + name: "profile_group_filters"); + + migrationBuilder.DropTable( + name: "provider_channels"); + + migrationBuilder.DropTable( + name: "canonical_channels"); + + migrationBuilder.DropTable( + name: "fetch_runs"); + + migrationBuilder.DropTable( + name: "provider_groups"); + + migrationBuilder.DropTable( + name: "profiles"); + + migrationBuilder.DropTable( + name: "providers"); + } + } +} diff --git a/src/M3Undle.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/M3Undle.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 5d2b302..b673623 100644 --- a/src/M3Undle.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/M3Undle.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class ApplicationDbContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); modelBuilder.Entity("M3Undle.Web.Data.ApplicationUser", b => { @@ -1349,7 +1349,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .ValueGeneratedOnAdd() .HasColumnType("TEXT") - .HasDefaultValue("off") + .HasDefaultValue("auto") .HasColumnName("clean_relay_mode"); b.Property("CreatedUtc") @@ -1692,6 +1692,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("hdhr_advertised_base_url"); + b.Property("HdhrAllowedNetworks") + .HasColumnType("TEXT") + .HasColumnName("hdhr_allowed_networks"); + b.Property("HdhrDiscoveryEnabled") .ValueGeneratedOnAdd() .HasColumnType("INTEGER") @@ -1832,6 +1836,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDefaultValue(false) .HasColumnName("streaming_settings_restart_required"); + b.Property("XtreamCompatibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("xtream_compatibility_enabled"); + b.HasKey("Id"); b.ToTable("site_settings", (string)null); @@ -1865,7 +1875,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) StreamReconnectOutageWindowSeconds = 75, StreamReconnectReadStallTimeoutSeconds = 30, StreamingEnabled = true, - StreamingSettingsRestartRequired = false + StreamingSettingsRestartRequired = false, + XtreamCompatibilityEnabled = true }); }); @@ -1942,6 +1953,118 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("snapshots", (string)null); }); + modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamChannelHealthEvent", b => + { + b.Property("StreamChannelHealthEventId") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BytesSuppressed") + .HasColumnType("INTEGER") + .HasColumnName("bytes_suppressed"); + + b.Property("CleanWatchDurationMs") + .HasColumnType("REAL") + .HasColumnName("clean_watch_duration_ms"); + + b.Property("ClientAbortAfterRecovery") + .HasColumnType("INTEGER") + .HasColumnName("client_abort_after_recovery"); + + b.Property("ClientAbortAfterRecoveryDelayMs") + .HasColumnType("REAL") + .HasColumnName("client_abort_after_recovery_delay_ms"); + + b.Property("ClientDisconnectReason") + .HasColumnType("TEXT") + .HasColumnName("client_disconnect_reason"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("EventKind") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("event_kind"); + + b.Property("EventUtc") + .HasColumnType("TEXT") + .HasColumnName("event_utc"); + + b.Property("ForcedRetune") + .HasColumnType("INTEGER") + .HasColumnName("forced_retune"); + + b.Property("OutputHeldMs") + .HasColumnType("REAL") + .HasColumnName("output_held_ms"); + + b.Property("ProviderChannelId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("provider_channel_id"); + + b.Property("ProviderId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("provider_id"); + + b.Property("ReconnectAttempt") + .HasColumnType("INTEGER") + .HasColumnName("reconnect_attempt"); + + b.Property("RecoveryDurationMs") + .HasColumnType("REAL") + .HasColumnName("recovery_duration_ms"); + + b.Property("RelayMode") + .HasColumnType("TEXT") + .HasColumnName("relay_mode"); + + b.Property("RouteClassification") + .HasColumnType("TEXT") + .HasColumnName("route_classification"); + + b.Property("SafeStartKind") + .HasColumnType("TEXT") + .HasColumnName("safe_start_kind"); + + b.Property("SafeStartWaitMs") + .HasColumnType("REAL") + .HasColumnName("safe_start_wait_ms"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StallDurationMs") + .HasColumnType("REAL") + .HasColumnName("stall_duration_ms"); + + b.Property("TsSyncLoss") + .HasColumnType("INTEGER") + .HasColumnName("ts_sync_loss"); + + b.Property("UpstreamFailureKind") + .HasColumnType("TEXT") + .HasColumnName("upstream_failure_kind"); + + b.HasKey("StreamChannelHealthEventId"); + + b.HasIndex("SessionId") + .HasDatabaseName("ix_stream_channel_health_events_session_id"); + + b.HasIndex("EventKind", "EventUtc") + .HasDatabaseName("ix_stream_channel_health_events_event_kind_event_utc"); + + b.HasIndex("ProviderId", "ProviderChannelId", "EventUtc") + .HasDatabaseName("ix_stream_channel_health_events_provider_channel_event_utc"); + + b.ToTable("stream_channel_health_events", (string)null); + }); + modelBuilder.Entity("M3Undle.Web.Data.Entities.StreamKey", b => { b.Property("Value") diff --git a/src/M3Undle.Web/Data/StartupMigrationRepair.cs b/src/M3Undle.Web/Data/StartupMigrationRepair.cs deleted file mode 100644 index e761397..0000000 --- a/src/M3Undle.Web/Data/StartupMigrationRepair.cs +++ /dev/null @@ -1,422 +0,0 @@ -using System.Data; -using System.Data.Common; -using Microsoft.EntityFrameworkCore; - -namespace M3Undle.Web.Data; - -internal static class StartupMigrationRepair -{ - private const string Alpha4SchemaMigrationId = "20260314145015_Alpha4_Schema"; - private const string Alpha5SchemaMigrationId = "20260322000000_Alpha5_Schema"; - private const string ProductVersion = "10.0.5"; - - public static async Task RepairAlpha5PartialSchemaAsync(ApplicationDbContext db) - { - var conn = db.Database.GetDbConnection(); - if (conn.State != ConnectionState.Open) - await conn.OpenAsync(); - - if (!await TableExistsAsync(conn, "__EFMigrationsHistory")) - return; - - if (await MigrationAppliedAsync(conn, Alpha5SchemaMigrationId) - || !await MigrationAppliedAsync(conn, Alpha4SchemaMigrationId) - || !await HasPartialAlpha5SchemaAsync(conn)) - { - return; - } - - await using var tx = await conn.BeginTransactionAsync(); - - await EnsureColumnAsync(conn, tx, "providers", "force_mpegts", "INTEGER NOT NULL DEFAULT 0"); - await EnsureColumnAsync(conn, tx, "snapshots", "change_class", "TEXT NULL"); - - await EnsureColumnAsync(conn, tx, "site_settings", "generated_hls_enabled", "INTEGER NOT NULL DEFAULT 1"); - await EnsureColumnAsync(conn, tx, "site_settings", "generated_hls_ffmpeg_path", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "site_settings", "generated_hls_settings_restart_required", "INTEGER NOT NULL DEFAULT 0"); - await EnsureColumnAsync(conn, tx, "site_settings", "hdhr_advertised_base_url", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "site_settings", "hdhr_discovery_enabled", "INTEGER NOT NULL DEFAULT 1"); - await EnsureColumnAsync(conn, tx, "site_settings", "hdhr_enabled", "INTEGER NOT NULL DEFAULT 1"); - await EnsureColumnAsync(conn, tx, "site_settings", "hdhr_friendly_name", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "site_settings", "hdhr_settings_restart_required", "INTEGER NOT NULL DEFAULT 0"); - await EnsureColumnAsync(conn, tx, "site_settings", "hdhr_silicondust_discovery_enabled", "INTEGER NOT NULL DEFAULT 1"); - await EnsureColumnAsync(conn, tx, "site_settings", "hdhr_ssdp_enabled", "INTEGER NOT NULL DEFAULT 1"); - await EnsureColumnAsync(conn, tx, "site_settings", "hdhr_tuner_count_override", "INTEGER NULL"); - await EnsureColumnAsync(conn, tx, "site_settings", "refresh_schedule_kind", "TEXT NOT NULL DEFAULT '6h'"); - await EnsureColumnAsync(conn, tx, "site_settings", "refresh_startup_catchup", "INTEGER NOT NULL DEFAULT 1"); - - await EnsureColumnAsync(conn, tx, "provider_channels", "event_content_key", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "provider_channels", "event_league", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "provider_channels", "event_participants_json", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "provider_channels", "event_slot_key", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "provider_channels", "event_sport", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "provider_channels", "event_title", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "provider_channels", "is_placeholder", "INTEGER NOT NULL DEFAULT 0"); - - await EnsureColumnAsync(conn, tx, "profiles", "is_active", "INTEGER NOT NULL DEFAULT 0"); - await EnsureColumnAsync(conn, tx, "profile_group_filters", "tracking_keywords", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "profile_group_filters", "tracking_policy", "TEXT NOT NULL DEFAULT 'review'"); - await EnsureColumnAsync(conn, tx, "profile_group_channel_filters", "display_name_override", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "profile_group_channel_filters", "state", "TEXT NOT NULL DEFAULT 'included'"); - await EnsureColumnAsync(conn, tx, "profile_group_channel_filters", "tvg_id_override", "TEXT NULL"); - await EnsureColumnAsync(conn, tx, "profile_group_channel_filters", "updated_utc", "TEXT NOT NULL DEFAULT '0001-01-01 00:00:00'"); - await EnsureColumnAsync(conn, tx, "epg_sources", "refresh_interval_hours", "INTEGER NULL"); - await EnsureColumnAsync(conn, tx, "AspNetUsers", "AdaptiveLockoutEscalated", "INTEGER NOT NULL DEFAULT 0"); - - await ApplyAlpha5DataUpdatesAsync(conn, tx); - await EnsureAlpha5TablesAsync(conn, tx); - await EnsureAlpha5IndexesAsync(conn, tx); - await RecordMigrationAsync(conn, tx, Alpha5SchemaMigrationId); - - await tx.CommitAsync(); - } - - private static async Task HasPartialAlpha5SchemaAsync(DbConnection conn) - => await ColumnExistsAsync(conn, "site_settings", "hdhr_advertised_base_url") - || await ColumnExistsAsync(conn, "site_settings", "generated_hls_enabled") - || await ColumnExistsAsync(conn, "providers", "force_mpegts") - || await ColumnExistsAsync(conn, "profiles", "is_active") - || await TableExistsAsync(conn, "profile_custom_groups"); - - private static async Task ApplyAlpha5DataUpdatesAsync(DbConnection conn, DbTransaction tx) - { - await ExecuteAsync(conn, tx, """ - UPDATE profile_group_filters - SET decision = CASE - WHEN LOWER(TRIM(decision)) = 'hold' AND is_new = 1 THEN 'pending' - WHEN LOWER(TRIM(decision)) = 'hold' THEN 'include' - WHEN LOWER(TRIM(decision)) = 'exclude' THEN 'exclude' - WHEN LOWER(TRIM(decision)) = 'pending' THEN 'pending' - WHEN LOWER(TRIM(decision)) = 'include' THEN 'include' - ELSE 'include' - END; - """); - - await ExecuteAsync(conn, tx, """ - UPDATE profile_group_filters - SET is_new = CASE WHEN decision = 'pending' THEN 1 ELSE 0 END; - """); - - await ExecuteAsync(conn, tx, """ - UPDATE profile_group_filters - SET decision = 'include' - WHERE decision IN ('pending', 'hold'); - """); - - await ExecuteAsync(conn, tx, """ - UPDATE profile_group_channel_filters - SET state = CASE - WHEN state IS NULL OR TRIM(state) = '' THEN 'included' - WHEN LOWER(TRIM(state)) IN ('pending', 'included', 'excluded') THEN LOWER(TRIM(state)) - ELSE 'included' - END; - """); - - await ExecuteAsync(conn, tx, """ - UPDATE profile_group_channel_filters - SET updated_utc = COALESCE(created_utc, CURRENT_TIMESTAMP) - WHERE updated_utc IS NULL - OR updated_utc = '0001-01-01 00:00:00' - OR updated_utc = '0001-01-01 00:00:00.0000000' - OR updated_utc = '0001-01-01T00:00:00' - OR updated_utc = '0001-01-01T00:00:00.0000000'; - """); - - if (await ColumnExistsAsync(conn, tx, "providers", "is_active")) - { - await ExecuteAsync(conn, tx, """ - UPDATE profiles - SET is_active = 1 - WHERE profile_id = ( - SELECT pp.profile_id - FROM profile_providers pp - INNER JOIN providers p ON p.provider_id = pp.provider_id - WHERE p.is_active = 1 - ORDER BY pp.priority ASC - LIMIT 1 - ) - AND NOT EXISTS (SELECT 1 FROM profiles WHERE is_active = 1); - """); - } - - await ExecuteAsync(conn, tx, """ - UPDATE site_settings - SET generated_hls_enabled = 1, - hdhr_discovery_enabled = 1, - hdhr_enabled = 1, - hdhr_silicondust_discovery_enabled = 1, - hdhr_ssdp_enabled = 1, - refresh_schedule_kind = '6h', - refresh_startup_catchup = 1 - WHERE id = 1; - """); - } - - private static async Task EnsureAlpha5TablesAsync(DbConnection conn, DbTransaction tx) - { - await ExecuteAsync(conn, tx, """ - CREATE TABLE IF NOT EXISTS "downstream_integrations" ( - "downstream_integration_id" TEXT NOT NULL CONSTRAINT "PK_downstream_integrations" PRIMARY KEY, - "profile_id" TEXT NULL, - "name" TEXT NOT NULL, - "kind" TEXT NOT NULL, - "base_url" TEXT NOT NULL, - "api_key_encrypted" TEXT NULL, - "webhook_headers_json" TEXT NULL, - "trigger_on_lineup_update" INTEGER NOT NULL DEFAULT 1, - "trigger_on_guide_update" INTEGER NOT NULL DEFAULT 1, - "enabled" INTEGER NOT NULL DEFAULT 1, - "last_notified_utc" TEXT NULL, - "last_notify_error" TEXT NULL, - "created_utc" TEXT NOT NULL, - "updated_utc" TEXT NOT NULL, - CONSTRAINT "FK_downstream_integrations_profiles_profile_id" FOREIGN KEY ("profile_id") REFERENCES "profiles" ("profile_id") ON DELETE SET NULL - ); - """); - - await ExecuteAsync(conn, tx, """ - CREATE TABLE IF NOT EXISTS "profile_custom_groups" ( - "custom_group_id" TEXT NOT NULL CONSTRAINT "PK_profile_custom_groups" PRIMARY KEY, - "profile_id" TEXT NOT NULL, - "name" TEXT NOT NULL, - "decision" TEXT NOT NULL DEFAULT 'include', - "channel_mode" TEXT NOT NULL DEFAULT 'select', - "tracking_policy" TEXT NOT NULL DEFAULT 'review', - "tracking_keywords" TEXT NULL, - "auto_num_start" INTEGER NULL, - "auto_num_end" INTEGER NULL, - "track_new_channels" INTEGER NOT NULL DEFAULT 0, - "sort_override" INTEGER NULL, - "created_utc" TEXT NOT NULL, - "updated_utc" TEXT NOT NULL, - CONSTRAINT "FK_profile_custom_groups_profiles_profile_id" FOREIGN KEY ("profile_id") REFERENCES "profiles" ("profile_id") ON DELETE CASCADE - ); - """); - - await ExecuteAsync(conn, tx, """ - CREATE TABLE IF NOT EXISTS "profile_event_interest_rules" ( - "rule_id" TEXT NOT NULL CONSTRAINT "PK_profile_event_interest_rules" PRIMARY KEY, - "profile_id" TEXT NOT NULL, - "provider_id" TEXT NULL, - "provider_group_id" TEXT NULL, - "enabled" INTEGER NOT NULL DEFAULT 1, - "match_type" TEXT NOT NULL, - "match_value" TEXT NOT NULL, - "action" TEXT NOT NULL, - "priority" INTEGER NOT NULL DEFAULT 100, - "created_utc" TEXT NOT NULL, - "updated_utc" TEXT NOT NULL, - CONSTRAINT "FK_profile_event_interest_rules_profiles_profile_id" FOREIGN KEY ("profile_id") REFERENCES "profiles" ("profile_id") ON DELETE CASCADE, - CONSTRAINT "FK_profile_event_interest_rules_provider_groups_provider_group_id" FOREIGN KEY ("provider_group_id") REFERENCES "provider_groups" ("provider_group_id") ON DELETE SET NULL, - CONSTRAINT "FK_profile_event_interest_rules_providers_provider_id" FOREIGN KEY ("provider_id") REFERENCES "providers" ("provider_id") ON DELETE SET NULL - ); - """); - - await ExecuteAsync(conn, tx, """ - CREATE TABLE IF NOT EXISTS "profile_custom_group_channels" ( - "custom_group_channel_id" TEXT NOT NULL CONSTRAINT "PK_profile_custom_group_channels" PRIMARY KEY, - "custom_group_id" TEXT NOT NULL, - "provider_channel_id" TEXT NOT NULL, - "state" TEXT NOT NULL DEFAULT 'included', - "channel_number" INTEGER NULL, - "display_name_override" TEXT NULL, - "tvg_id_override" TEXT NULL, - "created_utc" TEXT NOT NULL, - "updated_utc" TEXT NOT NULL, - CONSTRAINT "FK_profile_custom_group_channels_profile_custom_groups_custom_group_id" FOREIGN KEY ("custom_group_id") REFERENCES "profile_custom_groups" ("custom_group_id") ON DELETE CASCADE, - CONSTRAINT "FK_profile_custom_group_channels_provider_channels_provider_channel_id" FOREIGN KEY ("provider_channel_id") REFERENCES "provider_channels" ("provider_channel_id") ON DELETE CASCADE - ); - """); - - await ExecuteAsync(conn, tx, """ - CREATE TABLE IF NOT EXISTS "profile_custom_group_provider_links" ( - "link_id" TEXT NOT NULL CONSTRAINT "PK_profile_custom_group_provider_links" PRIMARY KEY, - "custom_group_id" TEXT NOT NULL, - "provider_group_id" TEXT NOT NULL, - "created_utc" TEXT NOT NULL, - CONSTRAINT "FK_profile_custom_group_provider_links_profile_custom_groups_custom_group_id" FOREIGN KEY ("custom_group_id") REFERENCES "profile_custom_groups" ("custom_group_id") ON DELETE CASCADE, - CONSTRAINT "FK_profile_custom_group_provider_links_provider_groups_provider_group_id" FOREIGN KEY ("provider_group_id") REFERENCES "provider_groups" ("provider_group_id") ON DELETE CASCADE - ); - """); - } - - private static async Task EnsureAlpha5IndexesAsync(DbConnection conn, DbTransaction tx) - { - await DropIndexIfExistsAsync(conn, tx, "idx_providers_is_active"); - await EnsureIndexAsync(conn, tx, "idx_provider_channels_event_content", """ - CREATE INDEX "idx_provider_channels_event_content" - ON "provider_channels" ("provider_id", "event_content_key") - WHERE event_content_key IS NOT NULL; - """); - await EnsureIndexAsync(conn, tx, "idx_provider_channels_placeholder_active", """ - CREATE INDEX "idx_provider_channels_placeholder_active" - ON "provider_channels" ("provider_id", "is_placeholder", "active"); - """); - await EnsureIndexAsync(conn, tx, "idx_profiles_is_active", """ - CREATE UNIQUE INDEX "idx_profiles_is_active" - ON "profiles" ("is_active") - WHERE is_active = 1; - """); - await EnsureIndexAsync(conn, tx, "idx_pgf_profile_tracking_policy", """ - CREATE INDEX "idx_pgf_profile_tracking_policy" - ON "profile_group_filters" ("profile_id", "tracking_policy"); - """); - await EnsureIndexAsync(conn, tx, "idx_downstream_integrations_profile", """ - CREATE INDEX "idx_downstream_integrations_profile" - ON "downstream_integrations" ("profile_id"); - """); - await EnsureIndexAsync(conn, tx, "idx_pcgc_group_channel_unique", """ - CREATE UNIQUE INDEX "idx_pcgc_group_channel_unique" - ON "profile_custom_group_channels" ("custom_group_id", "provider_channel_id"); - """); - await EnsureIndexAsync(conn, tx, "idx_pcgc_group_state", """ - CREATE INDEX "idx_pcgc_group_state" - ON "profile_custom_group_channels" ("custom_group_id", "state"); - """); - await EnsureIndexAsync(conn, tx, "IX_profile_custom_group_channels_provider_channel_id", """ - CREATE INDEX "IX_profile_custom_group_channels_provider_channel_id" - ON "profile_custom_group_channels" ("provider_channel_id"); - """); - await EnsureIndexAsync(conn, tx, "idx_pcgpl_group_provider_unique", """ - CREATE UNIQUE INDEX "idx_pcgpl_group_provider_unique" - ON "profile_custom_group_provider_links" ("custom_group_id", "provider_group_id"); - """); - await EnsureIndexAsync(conn, tx, "IX_profile_custom_group_provider_links_provider_group_id", """ - CREATE INDEX "IX_profile_custom_group_provider_links_provider_group_id" - ON "profile_custom_group_provider_links" ("provider_group_id"); - """); - await EnsureIndexAsync(conn, tx, "idx_pcg_profile_id", """ - CREATE INDEX "idx_pcg_profile_id" - ON "profile_custom_groups" ("profile_id"); - """); - await EnsureIndexAsync(conn, tx, "idx_pcg_profile_name_unique", """ - CREATE UNIQUE INDEX "idx_pcg_profile_name_unique" - ON "profile_custom_groups" ("profile_id", "name"); - """); - await EnsureIndexAsync(conn, tx, "idx_peir_profile_enabled_priority", """ - CREATE INDEX "idx_peir_profile_enabled_priority" - ON "profile_event_interest_rules" ("profile_id", "enabled", "priority"); - """); - await EnsureIndexAsync(conn, tx, "IX_profile_event_interest_rules_provider_group_id", """ - CREATE INDEX "IX_profile_event_interest_rules_provider_group_id" - ON "profile_event_interest_rules" ("provider_group_id"); - """); - await EnsureIndexAsync(conn, tx, "IX_profile_event_interest_rules_provider_id", """ - CREATE INDEX "IX_profile_event_interest_rules_provider_id" - ON "profile_event_interest_rules" ("provider_id"); - """); - } - - private static async Task EnsureColumnAsync( - DbConnection conn, - DbTransaction tx, - string table, - string column, - string definition) - { - if (await ColumnExistsAsync(conn, tx, table, column)) - return; - - await ExecuteAsync(conn, tx, $"ALTER TABLE {QuoteIdentifier(table)} ADD COLUMN {QuoteIdentifier(column)} {definition};"); - } - - private static async Task EnsureIndexAsync(DbConnection conn, DbTransaction tx, string indexName, string createSql) - { - if (!await IndexExistsAsync(conn, tx, indexName)) - await ExecuteAsync(conn, tx, createSql); - } - - private static async Task DropIndexIfExistsAsync(DbConnection conn, DbTransaction tx, string indexName) - { - if (await IndexExistsAsync(conn, tx, indexName)) - await ExecuteAsync(conn, tx, $"DROP INDEX {QuoteIdentifier(indexName)};"); - } - - private static async Task RecordMigrationAsync(DbConnection conn, DbTransaction tx, string migrationId) - { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = """ - INSERT OR IGNORE INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") - VALUES (@id, @version); - """; - var id = cmd.CreateParameter(); - id.ParameterName = "@id"; - id.Value = migrationId; - cmd.Parameters.Add(id); - - var version = cmd.CreateParameter(); - version.ParameterName = "@version"; - version.Value = ProductVersion; - cmd.Parameters.Add(version); - - await cmd.ExecuteNonQueryAsync(); - } - - private static async Task MigrationAppliedAsync(DbConnection conn, string migrationId) - { - await using var cmd = conn.CreateCommand(); - cmd.CommandText = """ - SELECT COUNT(*) - FROM "__EFMigrationsHistory" - WHERE "MigrationId" = @id; - """; - var id = cmd.CreateParameter(); - id.ParameterName = "@id"; - id.Value = migrationId; - cmd.Parameters.Add(id); - return Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; - } - - private static async Task TableExistsAsync(DbConnection conn, string table) - { - await using var cmd = conn.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=@name;"; - var p = cmd.CreateParameter(); - p.ParameterName = "@name"; - p.Value = table; - cmd.Parameters.Add(p); - return Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; - } - - private static Task ColumnExistsAsync(DbConnection conn, string table, string column) - => ColumnExistsAsync(conn, null, table, column); - - private static async Task ColumnExistsAsync(DbConnection conn, DbTransaction? tx, string table, string column) - { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = $"SELECT COUNT(*) FROM pragma_table_info({QuoteLiteral(table)}) WHERE name=@name;"; - var p = cmd.CreateParameter(); - p.ParameterName = "@name"; - p.Value = column; - cmd.Parameters.Add(p); - return Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; - } - - private static async Task IndexExistsAsync(DbConnection conn, DbTransaction tx, string indexName) - { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name=@name;"; - var p = cmd.CreateParameter(); - p.ParameterName = "@name"; - p.Value = indexName; - cmd.Parameters.Add(p); - return Convert.ToInt64(await cmd.ExecuteScalarAsync()) > 0; - } - - private static async Task ExecuteAsync(DbConnection conn, DbTransaction tx, string sql) - { - await using var cmd = conn.CreateCommand(); - cmd.Transaction = tx; - cmd.CommandText = sql; - await cmd.ExecuteNonQueryAsync(); - } - - private static string QuoteIdentifier(string value) - => "\"" + value.Replace("\"", "\"\"", StringComparison.Ordinal) + "\""; - - private static string QuoteLiteral(string value) - => "'" + value.Replace("'", "''", StringComparison.Ordinal) + "'"; -} diff --git a/src/M3Undle.Web/M3Undle.Web.csproj b/src/M3Undle.Web/M3Undle.Web.csproj index 7b31aa2..557d52a 100644 --- a/src/M3Undle.Web/M3Undle.Web.csproj +++ b/src/M3Undle.Web/M3Undle.Web.csproj @@ -25,6 +25,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + all diff --git a/src/M3Undle.Web/Observability/M3UndleHealthChecks.cs b/src/M3Undle.Web/Observability/M3UndleHealthChecks.cs index c98e904..7cb1a6d 100644 --- a/src/M3Undle.Web/Observability/M3UndleHealthChecks.cs +++ b/src/M3Undle.Web/Observability/M3UndleHealthChecks.cs @@ -46,20 +46,36 @@ public async Task CheckHealthAsync( reasons.Add($"database unavailable: {ex.Message}"); } - if (refreshTrigger.IsRefreshing) + if (refreshTrigger.IsRefreshing && reasons.Count > 0) reasons.Add("refresh in progress"); if (reasons.Count == 0) return HealthCheckResult.Healthy("ready", new Dictionary { ["ready"] = true }); + var reason = string.Join("; ", reasons); + return HealthCheckResult.Unhealthy( - "not ready", + $"not ready: {reason}", data: new Dictionary { ["ready"] = false, - ["reasons"] = reasons, + ["reason"] = reason, + ["reasons"] = new ReadinessReasons(reasons), }); } + + private sealed class ReadinessReasons(IReadOnlyList reasons) : IReadOnlyList + { + public int Count => reasons.Count; + + public string this[int index] => reasons[index]; + + public IEnumerator GetEnumerator() => reasons.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + + public override string ToString() => string.Join("; ", reasons); + } } public static class M3UndleHealthResponseWriters @@ -76,9 +92,11 @@ public static Task WriteReadinessAsync(HttpContext context, HealthReport report) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); + var reason = string.Join("; ", reasons); + object payload = report.Status == HealthStatus.Healthy ? new { ready = true, status = report.Status.ToString() } - : new { ready = false, status = report.Status.ToString(), reasons }; + : new { ready = false, status = report.Status.ToString(), reason, reasons }; context.Response.ContentType = "application/json; charset=utf-8"; return JsonSerializer.SerializeAsync(context.Response.Body, payload, JsonOptions); @@ -92,6 +110,7 @@ public static Task WriteHealthSummaryAsync(HttpContext context, HealthReport rep { status = x.Value.Status.ToString(), description = x.Value.Description, + reason = TryGetReason(x.Value.Data), reasons = TryGetReasons(x.Value.Data), durationMilliseconds = x.Value.Duration.TotalMilliseconds, }, @@ -120,4 +139,9 @@ private static IEnumerable TryGetReasons(IReadOnlyDictionary data) + => data.TryGetValue("reason", out var value) && value is string reason + ? reason + : null; } diff --git a/src/M3Undle.Web/Program.cs b/src/M3Undle.Web/Program.cs index 0a5632f..b8e9967 100644 --- a/src/M3Undle.Web/Program.cs +++ b/src/M3Undle.Web/Program.cs @@ -112,7 +112,10 @@ var sqliteInterceptor = new SqliteConnectionInterceptor(); builder.Services.AddDbContext(options => - options.UseSqlite(runtimePaths.DatabaseConnectionString).AddInterceptors(sqliteInterceptor)); + options + .UseSqlite(runtimePaths.DatabaseConnectionString) + .AddInterceptors(sqliteInterceptor) + .ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.CoreEventId.FirstWithoutOrderByAndFilterWarning))); if (builder.Environment.IsDevelopment()) builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddProblemDetails(); @@ -121,7 +124,11 @@ builder.Services.AddM3UndleOpenApi(); builder.Services.AddValidation(); builder.Services.AddMemoryCache(); -builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(string.Empty, client => +{ + // No HttpClient-level timeout — per-provider CancellationTokenSource controls the deadline. + client.Timeout = Timeout.InfiniteTimeSpan; +}); builder.Services.AddCors(options => { options.AddPolicy(MediaSurfaceCorsPolicy, policy => @@ -140,6 +147,7 @@ }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -316,7 +324,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -345,7 +353,12 @@ builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -397,9 +410,6 @@ using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); - await RepairAlpha4MigrationHistoryAsync(db); - await StartupMigrationRepair.RepairAlpha5PartialSchemaAsync(db); - await RepairAlpha6SchemaAsync(db); appliedMigrations = db.Database.GetPendingMigrations().ToList(); db.Database.Migrate(); db.Database.ExecuteSqlRaw("PRAGMA journal_mode=WAL;"); @@ -706,174 +716,6 @@ static void EnsureWebRootExists() } } -// Repairs __EFMigrationsHistory when upgrading from alpha.4 databases that had four individual -// migrations before they were consolidated into a single Alpha4_Schema migration. -static async Task RepairAlpha4MigrationHistoryAsync(ApplicationDbContext db) -{ - var conn = db.Database.GetDbConnection(); - if (conn.State != System.Data.ConnectionState.Open) - await conn.OpenAsync(); - - await using var checkTable = conn.CreateCommand(); - checkTable.CommandText = - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; - if ((long)(await checkTable.ExecuteScalarAsync())! == 0) - return; - - await using var check = conn.CreateCommand(); - check.CommandText = - "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" IN (" + - "'20260314145015_Alpha4_StreamingSettings'," + - "'20260314152455_Alpha4_ProviderStreamLimits'," + - "'20260316121421_Alpha4_EpgSources'," + - "'20260317120000_Alpha4_StreamSettingsUi')"; - if ((long)(await check.ExecuteScalarAsync())! == 0) - return; - - await using var tx = await conn.BeginTransactionAsync(); - - await using var del = conn.CreateCommand(); - del.Transaction = tx; - del.CommandText = - "DELETE FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" IN (" + - "'20260314145015_Alpha4_StreamingSettings'," + - "'20260314152455_Alpha4_ProviderStreamLimits'," + - "'20260316121421_Alpha4_EpgSources'," + - "'20260317120000_Alpha4_StreamSettingsUi')"; - await del.ExecuteNonQueryAsync(); - - await using var ins = conn.CreateCommand(); - ins.Transaction = tx; - ins.CommandText = - "INSERT OR IGNORE INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") " + - "VALUES (@id, @ver)"; - var pId = ins.CreateParameter(); - pId.ParameterName = "@id"; - pId.Value = "20260314145015_Alpha4_Schema"; - ins.Parameters.Add(pId); - var pVer = ins.CreateParameter(); - pVer.ParameterName = "@ver"; - pVer.Value = "10.0.0"; - ins.Parameters.Add(pVer); - await ins.ExecuteNonQueryAsync(); - - await tx.CommitAsync(); -} - -// Repairs databases that were upgraded through alpha.5 with a providers table that had -// is_active removed outside of the normal migration path. Alpha6_ActiveProfile moves -// is_active from providers to profiles, but its data-migration SQL references -// providers.is_active which is already absent on these DBs, causing Migrate() to fail -// with "no such column: p.is_active" and leaving profiles.is_active unset. -// -// When detected, this function manually applies the profiles-side schema change, -// seeds a sensible active profile, and records Alpha6_ActiveProfile as applied so -// Migrate() skips its broken data-migration step. -static async Task RepairAlpha6SchemaAsync(ApplicationDbContext db) -{ - const string migrationId = "20260404000000_Alpha6_ActiveProfile"; - - var conn = db.Database.GetDbConnection(); - if (conn.State != System.Data.ConnectionState.Open) - await conn.OpenAsync(); - - // Bail on a fresh DB — Migrate() will create everything from scratch. - await using var checkHistory = conn.CreateCommand(); - checkHistory.CommandText = - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='__EFMigrationsHistory'"; - if ((long)(await checkHistory.ExecuteScalarAsync())! == 0) - return; - - // Already applied — nothing to repair. - await using var checkApplied = conn.CreateCommand(); - checkApplied.CommandText = - "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = @id"; - var p = checkApplied.CreateParameter(); - p.ParameterName = "@id"; - p.Value = migrationId; - checkApplied.Parameters.Add(p); - if ((long)(await checkApplied.ExecuteScalarAsync())! > 0) - return; - - // No providers table yet — Migrate() will build the full schema. - await using var checkProviders = conn.CreateCommand(); - checkProviders.CommandText = - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='providers'"; - if ((long)(await checkProviders.ExecuteScalarAsync())! == 0) - return; - - // If providers.is_active exists the normal Alpha6 migration will run fine. - await using var checkProviderCol = conn.CreateCommand(); - checkProviderCol.CommandText = - "SELECT COUNT(*) FROM pragma_table_info('providers') WHERE name='is_active'"; - if ((long)(await checkProviderCol.ExecuteScalarAsync())! > 0) - return; - - // Broken state confirmed: providers.is_active is missing and Alpha6 has never been - // recorded. Manually apply the profiles-side changes and mark the migration applied. - await using var checkProfileCol = conn.CreateCommand(); - checkProfileCol.CommandText = - "SELECT COUNT(*) FROM pragma_table_info('profiles') WHERE name='is_active'"; - var profileColExists = (long)(await checkProfileCol.ExecuteScalarAsync())! > 0; - - await using var tx = await conn.BeginTransactionAsync(); - - if (!profileColExists) - { - await using var addCol = conn.CreateCommand(); - addCol.Transaction = tx; - addCol.CommandText = - "ALTER TABLE \"profiles\" ADD COLUMN \"is_active\" INTEGER NOT NULL DEFAULT 0"; - await addCol.ExecuteNonQueryAsync(); - } - - // Mark the first enabled profile as active if none are flagged yet. - await using var setActive = conn.CreateCommand(); - setActive.Transaction = tx; - setActive.CommandText = """ - UPDATE profiles - SET is_active = 1 - WHERE profile_id = ( - SELECT profile_id FROM profiles WHERE enabled = 1 ORDER BY created_utc ASC LIMIT 1 - ) - AND NOT EXISTS (SELECT 1 FROM profiles WHERE is_active = 1) - """; - await setActive.ExecuteNonQueryAsync(); - - await using var checkIdx = conn.CreateCommand(); - checkIdx.Transaction = tx; - checkIdx.CommandText = - "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_profiles_is_active'"; - var idxExists = (long)(await checkIdx.ExecuteScalarAsync())! > 0; - - if (!idxExists) - { - await using var createIdx = conn.CreateCommand(); - createIdx.Transaction = tx; - createIdx.CommandText = - "CREATE UNIQUE INDEX \"idx_profiles_is_active\" ON \"profiles\" (\"is_active\") " + - "WHERE is_active = 1"; - await createIdx.ExecuteNonQueryAsync(); - } - - await using var ins = conn.CreateCommand(); - ins.Transaction = tx; - ins.CommandText = - "INSERT OR IGNORE INTO \"__EFMigrationsHistory\" (\"MigrationId\", \"ProductVersion\") " + - "VALUES (@id, @ver)"; - var pId = ins.CreateParameter(); - pId.ParameterName = "@id"; - pId.Value = migrationId; - ins.Parameters.Add(pId); - var pVer = ins.CreateParameter(); - pVer.ParameterName = "@ver"; - pVer.Value = "10.0.0"; - ins.Parameters.Add(pVer); - await ins.ExecuteNonQueryAsync(); - - await tx.CommitAsync(); -} - static async Task PublishStartupEventsAsync(WebApplication app, AppBuildInfo buildInfo, IReadOnlyCollection appliedMigrations) { try @@ -913,7 +755,13 @@ public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEn private static void ApplyPragmas(DbConnection connection) { using var cmd = connection.CreateCommand(); - cmd.CommandText = "PRAGMA busy_timeout=5000;"; + cmd.CommandText = """ + PRAGMA busy_timeout=5000; + PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA cache_size=-65536; + PRAGMA temp_store=MEMORY; + """; cmd.ExecuteNonQuery(); } } diff --git a/src/M3Undle.Web/Security/HdhrNetworkFilter.cs b/src/M3Undle.Web/Security/HdhrNetworkFilter.cs new file mode 100644 index 0000000..3a47ba1 --- /dev/null +++ b/src/M3Undle.Web/Security/HdhrNetworkFilter.cs @@ -0,0 +1,54 @@ +using System.Net; +using M3Undle.Web.Application; +using M3Undle.Web.Observability; + +namespace M3Undle.Web.Security; + +internal sealed class HdhrNetworkFilter( + IHdHomeRunSettingsService hdhrSettings, + ILogger logger) : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var http = context.HttpContext; + var allowedNetworks = await hdhrSettings.GetAllowedNetworksAsync(http.RequestAborted); + + if (string.IsNullOrWhiteSpace(allowedNetworks)) + return await next(context); + + var remoteIp = http.Connection.RemoteIpAddress; + + if (remoteIp is null) + { + logger.LogWarning("HDHR access denied — could not determine remote IP."); + http.Response.StatusCode = StatusCodes.Status403Forbidden; + return Results.StatusCode(StatusCodes.Status403Forbidden); + } + + if (IPAddress.IsLoopback(remoteIp)) + return await next(context); + + var normalized = remoteIp.IsIPv4MappedToIPv6 ? remoteIp.MapToIPv4() : remoteIp; + + foreach (var network in ParseNetworks(allowedNetworks)) + { + if (network.Contains(normalized)) + return await next(context); + } + + logger.LogWarning( + "HDHR access denied from {RemoteIp} — not in allowed networks.", + remoteIp); + http.Response.StatusCode = StatusCodes.Status403Forbidden; + return Results.StatusCode(StatusCodes.Status403Forbidden); + } + + private static IEnumerable ParseNetworks(string value) + { + foreach (var part in value.Split(['\r', '\n', ',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (ParsedIpNetwork.TryParse(part, out var network)) + yield return network; + } + } +} diff --git a/src/M3Undle.Web/Security/XtreamPathCredentialFilter.cs b/src/M3Undle.Web/Security/XtreamPathCredentialFilter.cs index a0ba069..6bd3534 100644 --- a/src/M3Undle.Web/Security/XtreamPathCredentialFilter.cs +++ b/src/M3Undle.Web/Security/XtreamPathCredentialFilter.cs @@ -26,6 +26,10 @@ internal sealed class XtreamPathCredentialFilter( var methodForLog = http.Request.Method.ReplaceLineEndings(" "); var clientForLog = (http.Connection.RemoteIpAddress?.ToString() ?? "unknown").ReplaceLineEndings(" "); + var xtreamEnabled = await endpointSecurityService.IsXtreamEnabledAsync(http.RequestAborted); + if (!xtreamEnabled) + return TypedResults.NotFound(); + var username = (string?)http.GetRouteValue("xtreamUser") ?? string.Empty; var password = (string?)http.GetRouteValue("xtreamPass") ?? string.Empty; diff --git a/src/M3Undle.Web/Streaming/Configuration/CleanRelayOptions.cs b/src/M3Undle.Web/Streaming/Configuration/CleanRelayOptions.cs index 8cdec0b..1e5dda5 100644 --- a/src/M3Undle.Web/Streaming/Configuration/CleanRelayOptions.cs +++ b/src/M3Undle.Web/Streaming/Configuration/CleanRelayOptions.cs @@ -5,13 +5,27 @@ namespace M3Undle.Web.Streaming.Configuration; public static class CleanRelayModes { public const string Off = "off"; + public const string On = "on"; + public const string Auto = "auto"; public const string Remux = "remux"; public static string Normalize(string? value) - => string.Equals(value, Remux, StringComparison.OrdinalIgnoreCase) ? Remux : Off; + { + if (string.Equals(value, Auto, StringComparison.OrdinalIgnoreCase)) + return Auto; + if (string.Equals(value, On, StringComparison.OrdinalIgnoreCase) + || string.Equals(value, Remux, StringComparison.OrdinalIgnoreCase)) + return On; + if (string.Equals(value, Off, StringComparison.OrdinalIgnoreCase)) + return Off; + return Auto; + } public static bool IsRemux(string? value) - => string.Equals(value, Remux, StringComparison.OrdinalIgnoreCase); + => string.Equals(Normalize(value), On, StringComparison.Ordinal); + + public static bool IsAuto(string? value) + => string.Equals(Normalize(value), Auto, StringComparison.Ordinal); } public sealed class CleanRelayOptions diff --git a/src/M3Undle.Web/Streaming/Configuration/ReconnectOptions.cs b/src/M3Undle.Web/Streaming/Configuration/ReconnectOptions.cs index ed6238a..13f776e 100644 --- a/src/M3Undle.Web/Streaming/Configuration/ReconnectOptions.cs +++ b/src/M3Undle.Web/Streaming/Configuration/ReconnectOptions.cs @@ -13,9 +13,27 @@ public sealed class ReconnectOptions public TimeSpan OutageWindow { get; set; } = TimeSpan.FromSeconds(75); + /// + /// Maximum fallback cooldown when the provider does not send Retry-After. + /// Provider-supplied Retry-After values are honored separately. + /// public TimeSpan StrikeCooldown { get; set; } = TimeSpan.FromMinutes(5); + public TimeSpan ProxyAuthFallbackCooldown { get; set; } = TimeSpan.FromSeconds(30); + + public TimeSpan RateLimitFallbackCooldown { get; set; } = TimeSpan.FromSeconds(60); + + public TimeSpan UpstreamServerErrorFallbackCooldown { get; set; } = TimeSpan.FromSeconds(30); + + public TimeSpan TransportFallbackCooldown { get; set; } = TimeSpan.FromSeconds(15); + public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(15); + public TimeSpan RecoveryOutputHoldLimit { get; set; } = TimeSpan.FromSeconds(3); + + public int RecoverySafeStartSearchLimitBytes { get; set; } = 512 * 1024; + + public bool AllowPacketBoundaryRecoveryFallback { get; set; } = true; + public int[] FixedStepBackoffSeconds { get; set; } = [0, 1, 2, 5, 10, 15, 30]; } diff --git a/src/M3Undle.Web/Streaming/Configuration/StreamingOptionsValidator.cs b/src/M3Undle.Web/Streaming/Configuration/StreamingOptionsValidator.cs index d3c639f..f0225bc 100644 --- a/src/M3Undle.Web/Streaming/Configuration/StreamingOptionsValidator.cs +++ b/src/M3Undle.Web/Streaming/Configuration/StreamingOptionsValidator.cs @@ -59,9 +59,30 @@ public ValidateOptionsResult Validate(string? name, ReconnectOptions options) if (options.OutageWindow < options.ReadStallTimeout) errors.Add("Streaming:Reconnect:OutageWindow must be greater than or equal to ReadStallTimeout."); + if (options.StrikeCooldown <= TimeSpan.Zero) + errors.Add("Streaming:Reconnect:StrikeCooldown must be greater than zero."); + + if (options.ProxyAuthFallbackCooldown <= TimeSpan.Zero) + errors.Add("Streaming:Reconnect:ProxyAuthFallbackCooldown must be greater than zero."); + + if (options.RateLimitFallbackCooldown <= TimeSpan.Zero) + errors.Add("Streaming:Reconnect:RateLimitFallbackCooldown must be greater than zero."); + + if (options.UpstreamServerErrorFallbackCooldown <= TimeSpan.Zero) + errors.Add("Streaming:Reconnect:UpstreamServerErrorFallbackCooldown must be greater than zero."); + + if (options.TransportFallbackCooldown <= TimeSpan.Zero) + errors.Add("Streaming:Reconnect:TransportFallbackCooldown must be greater than zero."); + if (options.ConnectTimeout <= TimeSpan.Zero) errors.Add("Streaming:Reconnect:ConnectTimeout must be greater than zero."); + if (options.RecoveryOutputHoldLimit <= TimeSpan.Zero) + errors.Add("Streaming:Reconnect:RecoveryOutputHoldLimit must be greater than zero."); + + if (options.RecoverySafeStartSearchLimitBytes < 188) + errors.Add("Streaming:Reconnect:RecoverySafeStartSearchLimitBytes must be greater than or equal to one MPEG-TS packet."); + return errors.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(errors); diff --git a/src/M3Undle.Web/Streaming/GeneratedHls/GeneratedHlsSessionManager.cs b/src/M3Undle.Web/Streaming/GeneratedHls/GeneratedHlsSessionManager.cs index e98cd66..7505eed 100644 --- a/src/M3Undle.Web/Streaming/GeneratedHls/GeneratedHlsSessionManager.cs +++ b/src/M3Undle.Web/Streaming/GeneratedHls/GeneratedHlsSessionManager.cs @@ -69,14 +69,24 @@ public static bool ShouldCountAsViewer(string? userAgent) if (!IsEffectivelyEnabled) return null; - Directory.CreateDirectory(_options.Directory); - if (TryGetReusableSession(request, out var reusableSession)) return reusableSession; var sessionId = Guid.NewGuid().ToString("N"); var sessionDir = Path.Combine(_options.Directory, sessionId); - Directory.CreateDirectory(sessionDir); + try + { + Directory.CreateDirectory(sessionDir); + } + catch (Exception ex) when (IsWorkDirectoryException(ex)) + { + logger.LogWarning( + ex, + "Generated HLS session startup failed for '{DisplayName}': work directory '{Directory}' is not writable.", + request.DisplayName, + _options.Directory); + return null; + } var manifestPath = Path.Combine(sessionDir, "index.m3u8"); var segmentPattern = Path.Combine(sessionDir, "segment_%06d.ts"); @@ -354,12 +364,50 @@ public async Task StartAsync(CancellationToken cancellationToken) return; } + if (!TryPrepareWorkDirectory()) + { + _ffmpegAvailable = false; + return; + } + logger.LogInformation("Generated HLS enabled. FFmpeg found at '{FfmpegPath}'.", _options.FfmpegPath); - Directory.CreateDirectory(_options.Directory); CleanupStaleDirectories(); _sweepTask = Task.Run(() => SweepLoopAsync(_lifetimeCts.Token), CancellationToken.None); } + private bool TryPrepareWorkDirectory() + { + var probeDirectory = Path.Combine(_options.Directory, $".write-test-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(_options.Directory); + Directory.CreateDirectory(probeDirectory); + Directory.Delete(probeDirectory); + return true; + } + catch (Exception ex) when (IsWorkDirectoryException(ex)) + { + _ffmpegUnavailableReason = + $"Generated HLS work directory '{_options.Directory}' is not writable: {ex.Message}"; + logger.LogWarning( + ex, + "Generated HLS auto-disabled: work directory '{Directory}' is not writable.", + _options.Directory); + return false; + } + finally + { + TryDeleteDirectory(probeDirectory); + } + } + + private static bool IsWorkDirectoryException(Exception ex) + => ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException; + public async Task StopAsync(CancellationToken cancellationToken) { if (Interlocked.Exchange(ref _stopState, 1) != 0) diff --git a/src/M3Undle.Web/Streaming/Models/SessionState.cs b/src/M3Undle.Web/Streaming/Models/SessionState.cs index 49e6913..9f5868a 100644 --- a/src/M3Undle.Web/Streaming/Models/SessionState.cs +++ b/src/M3Undle.Web/Streaming/Models/SessionState.cs @@ -6,6 +6,7 @@ public enum SessionState Connecting = 1, Live = 2, Reconnecting = 3, + HoldingOutput = 4, Closed = 5, Faulted = 6, } diff --git a/src/M3Undle.Web/Streaming/Observability/IStreamChannelHealthEventRecorder.cs b/src/M3Undle.Web/Streaming/Observability/IStreamChannelHealthEventRecorder.cs new file mode 100644 index 0000000..ab2dc8a --- /dev/null +++ b/src/M3Undle.Web/Streaming/Observability/IStreamChannelHealthEventRecorder.cs @@ -0,0 +1,22 @@ +namespace M3Undle.Web.Streaming.Observability; + +public interface IStreamChannelHealthEventRecorder +{ + void Record(StreamDiagnosticEvent diagnosticEvent); + Task FlushAsync(CancellationToken ct = default); +} + +public sealed class NoopStreamChannelHealthEventRecorder : IStreamChannelHealthEventRecorder +{ + public static NoopStreamChannelHealthEventRecorder Instance { get; } = new(); + + private NoopStreamChannelHealthEventRecorder() + { + } + + public void Record(StreamDiagnosticEvent diagnosticEvent) + { + } + + public Task FlushAsync(CancellationToken ct = default) => Task.CompletedTask; +} diff --git a/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthEventRecorder.cs b/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthEventRecorder.cs new file mode 100644 index 0000000..57d2d3c --- /dev/null +++ b/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthEventRecorder.cs @@ -0,0 +1,173 @@ +using System.Threading.Channels; +using M3Undle.Web.Data; +using M3Undle.Web.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace M3Undle.Web.Streaming.Observability; + +public sealed class StreamChannelHealthEventRecorder( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService, IStreamChannelHealthEventRecorder +{ + private static readonly TimeSpan PurgeInterval = TimeSpan.FromHours(4); + private static readonly TimeSpan RetentionWindow = TimeSpan.FromDays(7); + + private readonly Channel _queue = Channel.CreateBounded( + new BoundedChannelOptions(2048) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false, + }); + + private int _pendingCount; + + public void Record(StreamDiagnosticEvent diagnosticEvent) + { + if (!ShouldPersist(diagnosticEvent)) + return; + + Interlocked.Increment(ref _pendingCount); + if (!_queue.Writer.TryWrite(diagnosticEvent)) + { + Interlocked.Decrement(ref _pendingCount); + logger.LogWarning( + "Stream channel health event queue is full; dropping {EventKind} for {ProviderId}/{ProviderChannelId}.", + diagnosticEvent.Kind, + diagnosticEvent.ProviderId, + diagnosticEvent.ProviderChannelId); + } + } + + public async Task FlushAsync(CancellationToken ct = default) + { + while (Volatile.Read(ref _pendingCount) > 0) + await Task.Delay(10, ct); + } + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + => Task.WhenAll(ProcessQueueAsync(stoppingToken), PurgeOldEventsAsync(stoppingToken)); + + private async Task ProcessQueueAsync(CancellationToken stoppingToken) + { + await foreach (var diagnosticEvent in _queue.Reader.ReadAllAsync(stoppingToken)) + { + try + { + await PersistAsync(diagnosticEvent, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to persist stream channel health event {EventKind} for {ProviderId}/{ProviderChannelId}.", + diagnosticEvent.Kind, + diagnosticEvent.ProviderId, + diagnosticEvent.ProviderChannelId); + } + finally + { + Interlocked.Decrement(ref _pendingCount); + } + } + } + + private async Task PurgeOldEventsAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(PurgeInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + try + { + var cutoffUtc = DateTime.UtcNow - RetentionWindow; + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var deleted = await db.StreamChannelHealthEvents + .Where(e => e.EventUtc < cutoffUtc) + .ExecuteDeleteAsync(stoppingToken); + if (deleted > 0) + logger.LogInformation( + "Purged {Count} stream channel health events older than {RetentionDays} days.", + deleted, + (int)RetentionWindow.TotalDays); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to purge old stream channel health events."); + } + } + } + + private async Task PersistAsync(StreamDiagnosticEvent diagnosticEvent, CancellationToken ct) + { + var healthEvent = Map(diagnosticEvent); + if (healthEvent is null) + return; + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.StreamChannelHealthEvents.Add(healthEvent); + await db.SaveChangesAsync(ct); + } + + private static StreamChannelHealthEvent? Map(StreamDiagnosticEvent diagnosticEvent) + { + if (string.IsNullOrWhiteSpace(diagnosticEvent.ProviderId) + || string.IsNullOrWhiteSpace(diagnosticEvent.ProviderChannelId) + || string.IsNullOrWhiteSpace(diagnosticEvent.DisplayName)) + return null; + + var isRecoveryFailure = diagnosticEvent.Kind is StreamDiagnosticEventKind.RecoveryHoldLimitExceeded + or StreamDiagnosticEventKind.RecoveryFailedUnsafe; + var isForcedRetune = diagnosticEvent.Kind is StreamDiagnosticEventKind.RecoveryForcedRetune + or StreamDiagnosticEventKind.ControlledDownstreamRetune + || isRecoveryFailure; + var isAbortAfterRecovery = diagnosticEvent.Kind == StreamDiagnosticEventKind.ClientAbortAfterRecovery; + var isTsSyncLoss = diagnosticEvent.Kind == StreamDiagnosticEventKind.MpegTsSyncLost; + + return new StreamChannelHealthEvent + { + StreamChannelHealthEventId = diagnosticEvent.EventId, + ProviderId = diagnosticEvent.ProviderId, + ProviderChannelId = diagnosticEvent.ProviderChannelId, + DisplayName = diagnosticEvent.DisplayName, + EventKind = diagnosticEvent.Kind.ToString(), + EventUtc = diagnosticEvent.TimestampUtc.UtcDateTime, + SessionId = diagnosticEvent.SessionId, + RelayMode = diagnosticEvent.RelayMode, + RouteClassification = diagnosticEvent.RouteClassification, + UpstreamFailureKind = diagnosticEvent.UpstreamFailureKind?.ToString(), + ReconnectAttempt = diagnosticEvent.ReconnectAttempt, + RecoveryDurationMs = diagnosticEvent.RecoveryDurationMs, + SafeStartWaitMs = null, + OutputHeldMs = diagnosticEvent.OutputHeldMs, + SafeStartKind = diagnosticEvent.SafeStartKind, + ClientDisconnectReason = diagnosticEvent.DisconnectReason?.ToString(), + ClientAbortAfterRecovery = isAbortAfterRecovery, + ClientAbortAfterRecoveryDelayMs = diagnosticEvent.ClientAbortAfterRecoveryDelayMs, + ForcedRetune = isForcedRetune, + TsSyncLoss = isTsSyncLoss, + BytesSuppressed = diagnosticEvent.BytesSuppressed, + CleanWatchDurationMs = diagnosticEvent.CleanWatchDurationMs, + }; + } + + private static bool ShouldPersist(StreamDiagnosticEvent diagnosticEvent) + => diagnosticEvent.Kind is StreamDiagnosticEventKind.UpstreamFailure + or StreamDiagnosticEventKind.RecoveryOutputResumed + or StreamDiagnosticEventKind.RecoveryHoldLimitExceeded + or StreamDiagnosticEventKind.RecoveryFailedUnsafe + or StreamDiagnosticEventKind.RecoveryForcedRetune + or StreamDiagnosticEventKind.ClientAbortAfterRecovery + or StreamDiagnosticEventKind.MpegTsSyncLost + or StreamDiagnosticEventKind.ControlledDownstreamRetune + or StreamDiagnosticEventKind.CleanWatchCompleted; +} diff --git a/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthProfile.cs b/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthProfile.cs new file mode 100644 index 0000000..1d74fef6 --- /dev/null +++ b/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthProfile.cs @@ -0,0 +1,134 @@ +using M3Undle.Web.Streaming.Configuration; +using M3Undle.Web.Streaming.Upstream; + +namespace M3Undle.Web.Streaming.Observability; + +public enum StreamChannelHealthProfile +{ + Stable = 0, + Cautious = 1, + Unstable = 2, +} + +public sealed record StreamChannelRecoveryPolicy( + StreamChannelHealthProfile Profile, + TimeSpan RecoveryOutputHoldLimit, + int RecoverySafeStartSearchLimitBytes, + bool AllowPacketBoundaryRecoveryFallback, + bool RequireDownstreamRetune, + string? DownstreamRetuneReason, + string Reason) +{ + public static StreamChannelRecoveryPolicy FromOptions(ReconnectOptions options) + => new( + StreamChannelHealthProfile.Stable, + options.RecoveryOutputHoldLimit, + options.RecoverySafeStartSearchLimitBytes, + options.AllowPacketBoundaryRecoveryFallback, + RequireDownstreamRetune: false, + DownstreamRetuneReason: null, + "No channel health history requires adaptive recovery."); +} + +public sealed record StreamRelayPolicyDecision( + string ProviderRelayPolicy, + string SelectedRelayMode, + string Reason) +{ + public static StreamRelayPolicyDecision Direct(string providerRelayPolicy, string reason) + => new(providerRelayPolicy, UpstreamRelayModes.Direct, reason); + + public static StreamRelayPolicyDecision CleanRemux(string providerRelayPolicy, string reason) + => new(providerRelayPolicy, UpstreamRelayModes.FfmpegCleanRemux, reason); +} + +public sealed record StreamChannelHealthEvidence( + string ProviderId, + string ProviderChannelId, + StreamChannelRecoveryPolicy RecoveryPolicy, + StreamRelayPolicyDecision AutoRelayDecision, + int UpstreamFailures, + int RecoveryResumes, + int FallbackRecoveryResumes, + int IdrRecoveryResumes, + int ClientAbortAfterRecovery, + int ForcedRetunes, + int TsSyncLoss, + int CleanWatchEvents, + TimeSpan CleanWatchDuration, + DateTime? LastAdverseEventUtc, + DateTime? LastCleanWatchUtc, + StreamChannelHealthTrendResult Trend); + +public interface IStreamChannelHealthProfileService +{ + Task GetRecoveryPolicyAsync( + string providerId, + string providerChannelId, + ReconnectOptions reconnectOptions, + CancellationToken ct = default); + + StreamRelayPolicyDecision GetRelayPolicyDecision( + string providerRelayPolicy, + StreamChannelRecoveryPolicy recoveryPolicy); + + Task GetEvidenceAsync( + string providerId, + string providerChannelId, + ReconnectOptions reconnectOptions, + CancellationToken ct = default); + + void Invalidate(string providerId, string providerChannelId); +} + +public sealed class NoopStreamChannelHealthProfileService : IStreamChannelHealthProfileService +{ + public static NoopStreamChannelHealthProfileService Instance { get; } = new(); + + private NoopStreamChannelHealthProfileService() + { + } + + public Task GetRecoveryPolicyAsync( + string providerId, + string providerChannelId, + ReconnectOptions reconnectOptions, + CancellationToken ct = default) + => Task.FromResult(StreamChannelRecoveryPolicy.FromOptions(reconnectOptions)); + + public StreamRelayPolicyDecision GetRelayPolicyDecision( + string providerRelayPolicy, + StreamChannelRecoveryPolicy recoveryPolicy) + => StreamRelayPolicyDecision.Direct(Configuration.CleanRelayModes.Normalize(providerRelayPolicy), + "No channel health profile service is available; using direct relay."); + + public Task GetEvidenceAsync( + string providerId, + string providerChannelId, + ReconnectOptions reconnectOptions, + CancellationToken ct = default) + { + var policy = StreamChannelRecoveryPolicy.FromOptions(reconnectOptions); + return Task.FromResult(new StreamChannelHealthEvidence( + providerId, + providerChannelId, + policy, + GetRelayPolicyDecision(Configuration.CleanRelayModes.Auto, policy), + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + TimeSpan.Zero, + LastAdverseEventUtc: null, + LastCleanWatchUtc: null, + StreamChannelHealthTrendResult.Insufficient("No channel health profile service is available."))); + } + + public void Invalidate(string providerId, string providerChannelId) + { + } +} diff --git a/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthProfileService.cs b/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthProfileService.cs new file mode 100644 index 0000000..491417e --- /dev/null +++ b/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthProfileService.cs @@ -0,0 +1,417 @@ +using System.Collections.Concurrent; +using M3Undle.Web.Data; +using M3Undle.Web.Streaming.Configuration; +using Microsoft.EntityFrameworkCore; + +namespace M3Undle.Web.Streaming.Observability; + +public sealed class StreamChannelHealthProfileService( + IServiceScopeFactory scopeFactory, + ILogger logger, + TimeProvider? timeProvider = null) : IStreamChannelHealthProfileService +{ + private static readonly TimeSpan ObservationWindow = TimeSpan.FromHours(24); + private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(30); + private static readonly TimeSpan CleanWatchDecayThreshold = TimeSpan.FromMinutes(30); + private static readonly TimeSpan TrendRecentWindow = TimeSpan.FromHours(1); + private static readonly TimeSpan TrendComparisonWindow = TimeSpan.FromHours(2); + private static readonly TimeSpan UnstableHoldLimit = TimeSpan.FromSeconds(5); + private const int UnstableSearchLimitBytes = 2 * 1024 * 1024; + + private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System; + private readonly ConcurrentDictionary _cache = new(StringComparer.Ordinal); + + public async Task GetRecoveryPolicyAsync( + string providerId, + string providerChannelId, + ReconnectOptions reconnectOptions, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(providerId) || string.IsNullOrWhiteSpace(providerChannelId)) + return StreamChannelRecoveryPolicy.FromOptions(reconnectOptions); + + var now = _timeProvider.GetUtcNow(); + var cacheKey = $"{providerId}:{providerChannelId}"; + if (_cache.TryGetValue(cacheKey, out var cached) && now - cached.CachedAt < CacheTtl) + return BuildPolicy(cached.Summary, reconnectOptions); + + try + { + var summary = await LoadSummaryAsync(providerId, providerChannelId, now.UtcDateTime, ct); + _cache[cacheKey] = new CacheEntry(now, summary); + return BuildPolicy(summary, reconnectOptions); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Failed to derive stream channel health profile for {ProviderId}/{ProviderChannelId}; using configured recovery policy.", + providerId, + providerChannelId); + return StreamChannelRecoveryPolicy.FromOptions(reconnectOptions); + } + } + + public StreamRelayPolicyDecision GetRelayPolicyDecision( + string providerRelayPolicy, + StreamChannelRecoveryPolicy recoveryPolicy) + { + var normalizedPolicy = CleanRelayModes.Normalize(providerRelayPolicy); + return normalizedPolicy switch + { + CleanRelayModes.On => StreamRelayPolicyDecision.CleanRemux( + normalizedPolicy, + "Provider relay policy is On; clean remux is forced for this provider."), + CleanRelayModes.Auto when recoveryPolicy.Profile == StreamChannelHealthProfile.Unstable => + StreamRelayPolicyDecision.CleanRemux( + normalizedPolicy, + $"Provider relay policy is Auto and channel health is {recoveryPolicy.Profile}; clean remux selected. {recoveryPolicy.Reason}"), + CleanRelayModes.Auto => StreamRelayPolicyDecision.Direct( + normalizedPolicy, + $"Provider relay policy is Auto and channel health is {recoveryPolicy.Profile}; direct relay selected. {recoveryPolicy.Reason}"), + _ => StreamRelayPolicyDecision.Direct( + normalizedPolicy, + "Provider relay policy is Off; direct relay is forced for this provider."), + }; + } + + public async Task GetEvidenceAsync( + string providerId, + string providerChannelId, + ReconnectOptions reconnectOptions, + CancellationToken ct = default) + { + var now = _timeProvider.GetUtcNow(); + var rows = await LoadRowsAsync(providerId, providerChannelId, now.UtcDateTime - ObservationWindow, ct); + var summary = BuildSummaryFromRows(rows); + var trend = ComputeTrendFromRows(rows, now.UtcDateTime); + _cache[$"{providerId}:{providerChannelId}"] = new CacheEntry(now, summary); + var policy = BuildPolicy(summary, reconnectOptions); + return new StreamChannelHealthEvidence( + providerId, + providerChannelId, + policy, + GetRelayPolicyDecision(CleanRelayModes.Auto, policy), + summary.UpstreamFailures, + summary.RecoveryResumes, + summary.FallbackRecoveryResumes, + summary.IdrRecoveryResumes, + summary.ClientAbortAfterRecovery, + summary.ForcedRetunes, + summary.TsSyncLoss, + summary.CleanWatchEvents, + summary.CleanWatchDuration, + summary.LastAdverseEventUtc, + summary.LastCleanWatchUtc, + trend); + } + + public void Invalidate(string providerId, string providerChannelId) + => _cache.TryRemove($"{providerId}:{providerChannelId}", out _); + + private async Task> LoadRowsAsync( + string providerId, + string providerChannelId, + DateTime cutoffUtc, + CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.StreamChannelHealthEvents + .AsNoTracking() + .Where(e => e.ProviderId == providerId + && e.ProviderChannelId == providerChannelId + && e.EventUtc >= cutoffUtc) + .Select(e => new HealthEventRow( + e.EventKind, + e.EventUtc, + e.SafeStartKind, + e.ClientAbortAfterRecovery, + e.ForcedRetune, + e.TsSyncLoss, + e.CleanWatchDurationMs)) + .ToListAsync(ct); + } + + private static HealthSummary BuildSummaryFromRows(List rows) + { + if (rows.Count == 0) + return HealthSummary.Empty; + + var lastAdverseEventUtc = rows + .Where(IsAdverse) + .Select(e => (DateTime?)e.EventUtc) + .Max(); + var cleanRows = rows.Where(e => e.EventKind == nameof(StreamDiagnosticEventKind.CleanWatchCompleted) + && (lastAdverseEventUtc is null || e.EventUtc > lastAdverseEventUtc.Value)) + .ToList(); + var cleanWatchMs = cleanRows.Sum(e => Math.Max(0, e.CleanWatchDurationMs ?? 0)); + var lastCleanWatchUtc = cleanRows + .Select(e => (DateTime?)e.EventUtc) + .Max(); + + return new HealthSummary( + rows.Count(e => e.EventKind == nameof(StreamDiagnosticEventKind.UpstreamFailure)), + rows.Count(e => e.EventKind == nameof(StreamDiagnosticEventKind.RecoveryOutputResumed)), + rows.Count(e => e.EventKind == nameof(StreamDiagnosticEventKind.RecoveryOutputResumed) && e.SafeStartKind == "FallbackPacketBoundary"), + rows.Count(e => e.EventKind == nameof(StreamDiagnosticEventKind.RecoveryOutputResumed) && e.SafeStartKind == "H264Idr"), + rows.Count(e => e.ClientAbortAfterRecovery), + rows.Count(e => e.ForcedRetune), + rows.Count(e => e.TsSyncLoss), + cleanRows.Count, + TimeSpan.FromMilliseconds(cleanWatchMs), + lastAdverseEventUtc, + lastCleanWatchUtc); + } + + private async Task LoadSummaryAsync( + string providerId, + string providerChannelId, + DateTime nowUtc, + CancellationToken ct) + { + var rows = await LoadRowsAsync(providerId, providerChannelId, nowUtc - ObservationWindow, ct); + return BuildSummaryFromRows(rows); + } + + private static StreamChannelHealthTrendResult ComputeTrendFromRows( + List allRows, + DateTime nowUtc) + { + if (allRows.Count == 0) + return StreamChannelHealthTrendResult.Insufficient("No health events in the observation window."); + + var recentCutoff = nowUtc - TrendRecentWindow; + var comparisonCutoff = nowUtc - TrendComparisonWindow; + + var recentRows = allRows.Where(e => e.EventUtc >= recentCutoff).ToList(); + var comparisonRows = allRows.Where(e => e.EventUtc >= comparisonCutoff && e.EventUtc < recentCutoff).ToList(); + + var recentAdverse = recentRows.Count(IsTrendAdverse); + var comparisonAdverse = comparisonRows.Count(IsTrendAdverse); + + var recentCleanWatch = TimeSpan.FromMilliseconds( + recentRows.Where(e => e.EventKind == nameof(StreamDiagnosticEventKind.CleanWatchCompleted)) + .Sum(e => Math.Max(0, e.CleanWatchDurationMs ?? 0))); + var comparisonCleanWatch = TimeSpan.FromMilliseconds( + comparisonRows.Where(e => e.EventKind == nameof(StreamDiagnosticEventKind.CleanWatchCompleted)) + .Sum(e => Math.Max(0, e.CleanWatchDurationMs ?? 0))); + + var lastTrendAdverseUtc = allRows.Where(IsTrendAdverse).Select(e => (DateTime?)e.EventUtc).Max(); + var cleanWatchSinceLastAdverse = TimeSpan.FromMilliseconds( + allRows.Where(e => e.EventKind == nameof(StreamDiagnosticEventKind.CleanWatchCompleted) + && (lastTrendAdverseUtc is null || e.EventUtc > lastTrendAdverseUtc.Value)) + .Sum(e => Math.Max(0, e.CleanWatchDurationMs ?? 0))); + + var recentProfile = DeriveWindowProfile(recentRows); + var comparisonProfile = DeriveWindowProfile(comparisonRows); + + if (recentAdverse == 0 && comparisonAdverse == 0) + { + return new StreamChannelHealthTrendResult( + StreamChannelHealthTrend.Stable, + "No adverse health events detected in the last 2 hours.", + 0, 0, recentCleanWatch, comparisonCleanWatch, cleanWatchSinceLastAdverse, + recentProfile, comparisonProfile); + } + + var recentHasSevere = recentRows.Any(e => e.ForcedRetune || e.ClientAbortAfterRecovery); + if (recentHasSevere) + { + var severeReason = recentRows.Any(e => e.ForcedRetune) + ? "A forced retune occurred in the last hour." + : "A client abort after recovery occurred in the last hour."; + return new StreamChannelHealthTrendResult( + StreamChannelHealthTrend.Worsening, severeReason, + recentAdverse, comparisonAdverse, recentCleanWatch, comparisonCleanWatch, + cleanWatchSinceLastAdverse, recentProfile, comparisonProfile); + } + + if ((int)recentProfile > (int)comparisonProfile) + { + return new StreamChannelHealthTrendResult( + StreamChannelHealthTrend.Worsening, + $"Health profile escalated from {comparisonProfile} to {recentProfile} in the last hour.", + recentAdverse, comparisonAdverse, recentCleanWatch, comparisonCleanWatch, + cleanWatchSinceLastAdverse, recentProfile, comparisonProfile); + } + + if ((int)recentProfile < (int)comparisonProfile) + { + return new StreamChannelHealthTrendResult( + StreamChannelHealthTrend.Improving, + $"Health profile relaxed from {comparisonProfile} to {recentProfile} in the last hour.", + recentAdverse, comparisonAdverse, recentCleanWatch, comparisonCleanWatch, + cleanWatchSinceLastAdverse, recentProfile, comparisonProfile); + } + + if (recentAdverse == 0 && comparisonAdverse > 0) + { + var reason = cleanWatchSinceLastAdverse >= CleanWatchDecayThreshold + ? $"No adverse events in the last hour with {cleanWatchSinceLastAdverse.TotalMinutes:F0} min of clean watch since the last issue." + : $"No adverse events in the last hour (previously {comparisonAdverse} in the prior hour)."; + return new StreamChannelHealthTrendResult( + StreamChannelHealthTrend.Improving, reason, + recentAdverse, comparisonAdverse, recentCleanWatch, comparisonCleanWatch, + cleanWatchSinceLastAdverse, recentProfile, comparisonProfile); + } + + if (recentAdverse > comparisonAdverse) + { + return new StreamChannelHealthTrendResult( + StreamChannelHealthTrend.Worsening, + $"Adverse event count increased from {comparisonAdverse} to {recentAdverse} in the last hour.", + recentAdverse, comparisonAdverse, recentCleanWatch, comparisonCleanWatch, + cleanWatchSinceLastAdverse, recentProfile, comparisonProfile); + } + + if (recentAdverse < comparisonAdverse && recentCleanWatch > TimeSpan.Zero) + { + return new StreamChannelHealthTrendResult( + StreamChannelHealthTrend.Improving, + $"Adverse events decreased from {comparisonAdverse} to {recentAdverse} in the last hour with {recentCleanWatch.TotalMinutes:F0} min of recent clean watch.", + recentAdverse, comparisonAdverse, recentCleanWatch, comparisonCleanWatch, + cleanWatchSinceLastAdverse, recentProfile, comparisonProfile); + } + + return new StreamChannelHealthTrendResult( + StreamChannelHealthTrend.Stable, + $"Adverse event count is consistent across both hours ({recentAdverse} recent, {comparisonAdverse} prior).", + recentAdverse, comparisonAdverse, recentCleanWatch, comparisonCleanWatch, + cleanWatchSinceLastAdverse, recentProfile, comparisonProfile); + } + + private static StreamChannelHealthProfile DeriveWindowProfile(List rows) + { + var fallbackResumes = rows.Count(e => + e.EventKind == nameof(StreamDiagnosticEventKind.RecoveryOutputResumed) + && e.SafeStartKind == "FallbackPacketBoundary"); + var clientAborts = rows.Count(e => e.ClientAbortAfterRecovery); + var forcedRetunes = rows.Count(e => e.ForcedRetune); + var tsSyncLoss = rows.Count(e => e.TsSyncLoss); + var upstreamFailures = rows.Count(e => e.EventKind == nameof(StreamDiagnosticEventKind.UpstreamFailure)); + var recoveryResumes = rows.Count(e => e.EventKind == nameof(StreamDiagnosticEventKind.RecoveryOutputResumed)); + + if (forcedRetunes > 0 || clientAborts >= 2 || fallbackResumes >= 2 || tsSyncLoss >= 2) + return StreamChannelHealthProfile.Unstable; + if (clientAborts > 0 || fallbackResumes > 0 || upstreamFailures >= 2 || recoveryResumes >= 2 || tsSyncLoss > 0) + return StreamChannelHealthProfile.Cautious; + return StreamChannelHealthProfile.Stable; + } + + private static bool IsTrendAdverse(HealthEventRow row) + => row.EventKind == nameof(StreamDiagnosticEventKind.UpstreamFailure) + || row.ClientAbortAfterRecovery + || row.ForcedRetune + || row.TsSyncLoss + || (row.EventKind == nameof(StreamDiagnosticEventKind.RecoveryOutputResumed) + && row.SafeStartKind == "FallbackPacketBoundary"); + + private static StreamChannelRecoveryPolicy BuildPolicy(HealthSummary summary, ReconnectOptions options) + { + var profile = DeriveProfile(summary); + var requireRetune = profile == StreamChannelHealthProfile.Unstable + && summary.ClientAbortAfterRecovery >= 2 + && summary.IdrRecoveryResumes >= 1; + return profile switch + { + StreamChannelHealthProfile.Unstable => new StreamChannelRecoveryPolicy( + profile, + Max(options.RecoveryOutputHoldLimit, UnstableHoldLimit), + Math.Max(options.RecoverySafeStartSearchLimitBytes, UnstableSearchLimitBytes), + AllowPacketBoundaryRecoveryFallback: false, + RequireDownstreamRetune: requireRetune, + DownstreamRetuneReason: requireRetune + ? $"Channel has {summary.ClientAbortAfterRecovery} downstream client aborts after IDR recovery in the observation window." + : null, + Reason: BuildReason(summary, profile)), + _ => new StreamChannelRecoveryPolicy( + profile, + options.RecoveryOutputHoldLimit, + options.RecoverySafeStartSearchLimitBytes, + options.AllowPacketBoundaryRecoveryFallback, + RequireDownstreamRetune: false, + DownstreamRetuneReason: null, + BuildReason(summary, profile)), + }; + } + + private static StreamChannelHealthProfile DeriveProfile(HealthSummary summary) + { + StreamChannelHealthProfile profile; + if (summary.ForcedRetunes > 0 + || summary.ClientAbortAfterRecovery >= 2 + || summary.FallbackRecoveryResumes >= 2 + || summary.TsSyncLoss >= 2) + { + profile = StreamChannelHealthProfile.Unstable; + } + else if (summary.ClientAbortAfterRecovery > 0 + || summary.FallbackRecoveryResumes > 0 + || summary.UpstreamFailures >= 2 + || summary.RecoveryResumes >= 2 + || summary.TsSyncLoss > 0) + { + profile = StreamChannelHealthProfile.Cautious; + } + else + { + profile = StreamChannelHealthProfile.Stable; + } + + if (profile == StreamChannelHealthProfile.Stable || summary.CleanWatchDuration < CleanWatchDecayThreshold) + return profile; + + return profile == StreamChannelHealthProfile.Unstable + ? StreamChannelHealthProfile.Cautious + : StreamChannelHealthProfile.Stable; + } + + private static string BuildReason(HealthSummary summary, StreamChannelHealthProfile profile) + => profile switch + { + StreamChannelHealthProfile.Unstable => + $"Recent health events classify channel as unstable: upstreamFailures={summary.UpstreamFailures}, recoveries={summary.RecoveryResumes}, fallbackRecoveries={summary.FallbackRecoveryResumes}, abortsAfterRecovery={summary.ClientAbortAfterRecovery}, forcedRetunes={summary.ForcedRetunes}, tsSyncLoss={summary.TsSyncLoss}, cleanWatchSeconds={summary.CleanWatchDuration.TotalSeconds:F0}.", + StreamChannelHealthProfile.Cautious => + $"Recent health events classify channel as cautious: upstreamFailures={summary.UpstreamFailures}, recoveries={summary.RecoveryResumes}, fallbackRecoveries={summary.FallbackRecoveryResumes}, abortsAfterRecovery={summary.ClientAbortAfterRecovery}, tsSyncLoss={summary.TsSyncLoss}, cleanWatchSeconds={summary.CleanWatchDuration.TotalSeconds:F0}.", + _ => summary.CleanWatchDuration > TimeSpan.Zero + ? $"Recent clean watch evidence relaxed channel health: cleanWatchSeconds={summary.CleanWatchDuration.TotalSeconds:F0}." + : "No recent recovery failures or post-recovery aborts were found.", + }; + + private static TimeSpan Max(TimeSpan left, TimeSpan right) => left >= right ? left : right; + + private sealed record CacheEntry(DateTimeOffset CachedAt, HealthSummary Summary); + + private static bool IsAdverse(HealthEventRow row) + => !string.Equals(row.EventKind, nameof(StreamDiagnosticEventKind.CleanWatchCompleted), StringComparison.Ordinal); + + private sealed record HealthEventRow( + string EventKind, + DateTime EventUtc, + string? SafeStartKind, + bool ClientAbortAfterRecovery, + bool ForcedRetune, + bool TsSyncLoss, + double? CleanWatchDurationMs); + + private sealed record HealthSummary( + int UpstreamFailures, + int RecoveryResumes, + int FallbackRecoveryResumes, + int IdrRecoveryResumes, + int ClientAbortAfterRecovery, + int ForcedRetunes, + int TsSyncLoss, + int CleanWatchEvents, + TimeSpan CleanWatchDuration, + DateTime? LastAdverseEventUtc, + DateTime? LastCleanWatchUtc) + { + public static HealthSummary Empty { get; } = new(0, 0, 0, 0, 0, 0, 0, 0, TimeSpan.Zero, null, null); + } +} diff --git a/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthTrend.cs b/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthTrend.cs new file mode 100644 index 0000000..c631982 --- /dev/null +++ b/src/M3Undle.Web/Streaming/Observability/StreamChannelHealthTrend.cs @@ -0,0 +1,24 @@ +namespace M3Undle.Web.Streaming.Observability; + +public enum StreamChannelHealthTrend +{ + Unknown = 0, + Improving = 1, + Stable = 2, + Worsening = 3, +} + +public sealed record StreamChannelHealthTrendResult( + StreamChannelHealthTrend Trend, + string Reason, + int RecentAdverseCount, + int ComparisonAdverseCount, + TimeSpan RecentCleanWatchDuration, + TimeSpan ComparisonCleanWatchDuration, + TimeSpan CleanWatchSinceLastAdverse, + StreamChannelHealthProfile? RecentProfile, + StreamChannelHealthProfile? ComparisonProfile) +{ + public static StreamChannelHealthTrendResult Insufficient(string reason) + => new(StreamChannelHealthTrend.Unknown, reason, 0, 0, TimeSpan.Zero, TimeSpan.Zero, TimeSpan.Zero, null, null); +} diff --git a/src/M3Undle.Web/Streaming/Observability/StreamClientSnapshot.cs b/src/M3Undle.Web/Streaming/Observability/StreamClientSnapshot.cs index ec8d8c1..0a6dafb 100644 --- a/src/M3Undle.Web/Streaming/Observability/StreamClientSnapshot.cs +++ b/src/M3Undle.Web/Streaming/Observability/StreamClientSnapshot.cs @@ -16,5 +16,6 @@ public sealed record StreamClientSnapshot( long BytesSent, int QueueDepth, bool IsInternal = false, - ClientTransport Transport = ClientTransport.DirectRelay); + ClientTransport Transport = ClientTransport.DirectRelay, + long? BytesPerSecond = null); diff --git a/src/M3Undle.Web/Streaming/Observability/StreamDiagnosticEvent.cs b/src/M3Undle.Web/Streaming/Observability/StreamDiagnosticEvent.cs index 30e0720..579c0be 100644 --- a/src/M3Undle.Web/Streaming/Observability/StreamDiagnosticEvent.cs +++ b/src/M3Undle.Web/Streaming/Observability/StreamDiagnosticEvent.cs @@ -29,4 +29,12 @@ public sealed record StreamDiagnosticEvent( string? StopTrigger = null, double? CooldownSeconds = null, int? RetryAfterSeconds = null, + double? OutputHeldMs = null, + double? RecoveryDurationMs = null, + string? SafeStartKind = null, + long? BytesSuppressed = null, + double? RecoveryHoldLimitMs = null, + double? ClientAbortAfterRecoveryDelayMs = null, + double? CleanWatchDurationMs = null, + string? RelayMode = null, string? Message = null); diff --git a/src/M3Undle.Web/Streaming/Observability/StreamDiagnosticEventKind.cs b/src/M3Undle.Web/Streaming/Observability/StreamDiagnosticEventKind.cs index 394033c..1cdde7a 100644 --- a/src/M3Undle.Web/Streaming/Observability/StreamDiagnosticEventKind.cs +++ b/src/M3Undle.Web/Streaming/Observability/StreamDiagnosticEventKind.cs @@ -20,5 +20,15 @@ public enum StreamDiagnosticEventKind MpegTsPacketizerDisabled = 15, FfmpegRelayStarted = 16, FfmpegRelayFallbackToDirect = 17, + RecoveryStarted = 18, + RecoveryOutputHeld = 19, + RecoverySafeStartFound = 20, + RecoveryOutputResumed = 21, + RecoveryHoldLimitExceeded = 22, + RecoveryFailedUnsafe = 23, SubscriberQueueFull = 24, + RecoveryForcedRetune = 25, + ClientAbortAfterRecovery = 26, + ControlledDownstreamRetune = 27, + CleanWatchCompleted = 28, } diff --git a/src/M3Undle.Web/Streaming/Observability/StreamProviderSnapshot.cs b/src/M3Undle.Web/Streaming/Observability/StreamProviderSnapshot.cs index c1beba2..bd5bfe1 100644 --- a/src/M3Undle.Web/Streaming/Observability/StreamProviderSnapshot.cs +++ b/src/M3Undle.Web/Streaming/Observability/StreamProviderSnapshot.cs @@ -16,4 +16,9 @@ public sealed record StreamProviderSnapshot( int? LastUpstreamStatusCode = null, double? LastCooldownSeconds = null, string RelayMode = "Direct", - string? LastRelayFallbackReason = null); + string? LastRelayFallbackReason = null, + string? RelayPolicy = null, + string? RelayDecisionReason = null, + string? LastSafeStartKind = null, + double? LastRecoveryOutputHeldMs = null, + DateTimeOffset? LastRecoveryStartedUtc = null); diff --git a/src/M3Undle.Web/Streaming/Observability/StreamSessionSnapshot.cs b/src/M3Undle.Web/Streaming/Observability/StreamSessionSnapshot.cs index b1d2283..59d0d8f 100644 --- a/src/M3Undle.Web/Streaming/Observability/StreamSessionSnapshot.cs +++ b/src/M3Undle.Web/Streaming/Observability/StreamSessionSnapshot.cs @@ -26,4 +26,15 @@ public sealed record StreamSessionSnapshot( int? LastUpstreamStatusCode = null, double? LastCooldownSeconds = null, string RelayMode = "Direct", - string? LastRelayFallbackReason = null); + string? LastRelayFallbackReason = null, + string? RelayPolicy = null, + string? RelayDecisionReason = null, + string? LastSafeStartKind = null, + double? LastRecoveryOutputHeldMs = null, + DateTimeOffset? LastRecoveryStartedUtc = null, + long TotalBytesRelayed = 0, + long? UpstreamBytesPerSecond = null, + StreamChannelHealthProfile? HealthProfile = null, + string? HealthProfileReason = null, + bool RequiresDownstreamRetune = false, + string? DownstreamRetuneReason = null); diff --git a/src/M3Undle.Web/Streaming/Observability/StreamingRegistry.cs b/src/M3Undle.Web/Streaming/Observability/StreamingRegistry.cs index a288793..0c08f32 100644 --- a/src/M3Undle.Web/Streaming/Observability/StreamingRegistry.cs +++ b/src/M3Undle.Web/Streaming/Observability/StreamingRegistry.cs @@ -1,10 +1,11 @@ using System.Collections.Concurrent; using M3Undle.Web.Streaming.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; namespace M3Undle.Web.Streaming.Observability; -public sealed class StreamingRegistry(IOptions options) +public sealed class StreamingRegistry(IOptions options) : IHostedService { private readonly StreamProxyOptions _options = options.Value; private readonly ConcurrentDictionary _sessions = new(StringComparer.Ordinal); @@ -12,10 +13,74 @@ public sealed class StreamingRegistry(IOptions options) private readonly ConcurrentDictionary _providers = new(StringComparer.Ordinal); private readonly ConcurrentQueue<(DateTimeOffset EndedUtc, StreamSessionSnapshot Snapshot)> _recentEnded = new(); + // Rate calculation — written only by the background timer, read on GetActive* calls. + private readonly ConcurrentDictionary _sessionUpstreamRates = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _lastSessionBytes = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _clientSendRates = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _lastClientBytes = new(StringComparer.Ordinal); + + private DateTimeOffset _lastRateSampleAt = DateTimeOffset.UtcNow; + private readonly CancellationTokenSource _cts = new(); + private Task? _rateTask; + + // IHostedService ------------------------------------------------------- + + public Task StartAsync(CancellationToken cancellationToken) + { + _rateTask = RunRateCalculatorAsync(_cts.Token); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await _cts.CancelAsync(); + if (_rateTask is not null) + await _rateTask.ConfigureAwait(false); + } + + private async Task RunRateCalculatorAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2)); + try + { + while (await timer.WaitForNextTickAsync(ct)) + ComputeRates(); + } + catch (OperationCanceledException) { } + } + + private void ComputeRates() + { + var now = DateTimeOffset.UtcNow; + var elapsed = (now - _lastRateSampleAt).TotalSeconds; + if (elapsed < 1.0) return; + _lastRateSampleAt = now; + + foreach (var (sessionId, snapshot) in _sessions) + { + var current = snapshot.TotalBytesRelayed; + if (_lastSessionBytes.TryGetValue(sessionId, out var prev) && current > prev) + _sessionUpstreamRates[sessionId] = (long)((current - prev) / elapsed); + // If current == prev (no new bytes this window), keep the last known rate. + // The Last Data column already signals a stall; blanking the rate adds noise. + _lastSessionBytes[sessionId] = current; + } + + foreach (var (clientId, snapshot) in _clients) + { + var current = snapshot.BytesSent; + if (_lastClientBytes.TryGetValue(clientId, out var prev) && current > prev) + _clientSendRates[clientId] = (long)((current - prev) / elapsed); + _lastClientBytes[clientId] = current; + } + } + + // Read ----------------------------------------------------------------- + public IReadOnlyList GetActiveSessions() => _sessions.Values .Where(x => !x.IsInternal) - .Select(AggregateVisibleSession) + .Select(x => EnrichSessionRate(AggregateVisibleSession(x))) .OrderBy(x => x.StartedUtc) .ToArray(); @@ -27,15 +92,23 @@ public IReadOnlyList GetRecentEndedSessions() public StreamSessionSnapshot? TryGetSession(string sessionId) => _sessions.TryGetValue(sessionId, out var snapshot) - ? snapshot.IsInternal ? snapshot : AggregateVisibleSession(snapshot) + ? EnrichSessionRate(snapshot.IsInternal ? snapshot : AggregateVisibleSession(snapshot)) : null; public IReadOnlyList GetActiveClients() - => _clients.Values.Where(x => !x.IsInternal).OrderBy(x => x.ConnectedUtc).ToArray(); + => _clients.Values + .Where(x => !x.IsInternal) + .Select(x => _clientSendRates.TryGetValue(x.ClientId, out var rate) && rate > 0 + ? x with { BytesPerSecond = rate } + : x) + .OrderBy(x => x.ConnectedUtc) + .ToArray(); public IReadOnlyList GetActiveProviderStreams() => _providers.Values.OrderBy(x => x.SessionId).ToArray(); + // Write ---------------------------------------------------------------- + public void UpsertSession(StreamSessionSnapshot snapshot) { _sessions[snapshot.SessionId] = snapshot; @@ -51,6 +124,8 @@ public void RemoveSession(string sessionId) } _providers.TryRemove(sessionId, out _); + _sessionUpstreamRates.TryRemove(sessionId, out _); + _lastSessionBytes.TryRemove(sessionId, out _); PruneRecentEnded(); } @@ -58,7 +133,11 @@ public void UpsertClient(StreamClientSnapshot snapshot) => _clients[snapshot.ClientId] = snapshot; public void RemoveClient(string clientId) - => _clients.TryRemove(clientId, out _); + { + _clients.TryRemove(clientId, out _); + _clientSendRates.TryRemove(clientId, out _); + _lastClientBytes.TryRemove(clientId, out _); + } public void UpsertProvider(StreamProviderSnapshot snapshot) => _providers[snapshot.SessionId] = snapshot; @@ -66,6 +145,8 @@ public void UpsertProvider(StreamProviderSnapshot snapshot) public void RemoveProvider(string sessionId) => _providers.TryRemove(sessionId, out _); + // Helpers -------------------------------------------------------------- + private void PruneRecentEnded() { var retention = TimeSpan.FromSeconds(Math.Clamp(_options.DetailedStatusRetentionSeconds, 0, 3600)); @@ -101,5 +182,9 @@ private StreamSessionSnapshot AggregateVisibleSession(StreamSessionSnapshot snap InferredHlsSubscriberCount = hlsSubscribers, }; } -} + private StreamSessionSnapshot EnrichSessionRate(StreamSessionSnapshot snapshot) + => _sessionUpstreamRates.TryGetValue(snapshot.SessionId, out var rate) && rate > 0 + ? snapshot with { UpstreamBytesPerSecond = rate } + : snapshot; +} diff --git a/src/M3Undle.Web/Streaming/Sessions/ChannelSessionManager.cs b/src/M3Undle.Web/Streaming/Sessions/ChannelSessionManager.cs index 0f2bdb3..dfd995f 100644 --- a/src/M3Undle.Web/Streaming/Sessions/ChannelSessionManager.cs +++ b/src/M3Undle.Web/Streaming/Sessions/ChannelSessionManager.cs @@ -21,6 +21,8 @@ public sealed class ChannelSessionManager : IHostedService, IDisposable private readonly StreamAdmissionBackoffStore _admissionBackoffStore; private readonly StreamingRegistry _registry; private readonly StreamingDiagnosticsStore _diagnosticsStore; + private readonly IStreamChannelHealthEventRecorder _healthEventRecorder; + private readonly IStreamChannelHealthProfileService _healthProfileService; private readonly IEventService _eventService; private readonly M3UndleMetrics? _metrics; private readonly ILoggerFactory _loggerFactory; @@ -45,6 +47,8 @@ public ChannelSessionManager( StreamAdmissionBackoffStore admissionBackoffStore, StreamingRegistry registry, StreamingDiagnosticsStore diagnosticsStore, + IStreamChannelHealthEventRecorder healthEventRecorder, + IStreamChannelHealthProfileService healthProfileService, IEventService eventService, ILoggerFactory loggerFactory, TimeProvider timeProvider, @@ -58,6 +62,8 @@ public ChannelSessionManager( _admissionBackoffStore = admissionBackoffStore; _registry = registry; _diagnosticsStore = diagnosticsStore; + _healthEventRecorder = healthEventRecorder; + _healthProfileService = healthProfileService; _eventService = eventService; _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); @@ -140,12 +146,19 @@ private async ValueTask GetOrCreateCoreAsync(StreamSourceD if (_sessions.TryGetValue(key, out var existing)) { - _logger.LogDebug( - "Joining existing session {SessionId} for '{DisplayName}' ({SubscriberCount} viewer(s) already watching).", - existing.SessionId, - source.DisplayName, - existing.SubscriberCount); - return existing; + if (!existing.IsAcceptingSubscribers) + { + _sessions.TryRemove(key, out _); + } + else + { + _logger.LogDebug( + "Joining existing session {SessionId} for '{DisplayName}' ({SubscriberCount} viewer(s) already watching).", + existing.SessionId, + source.DisplayName, + existing.SubscriberCount); + return existing; + } } EvictExpiredHlsSlotsLocked(); @@ -207,6 +220,8 @@ private async ValueTask GetOrCreateCoreAsync(StreamSourceD _strikeStore, _registry, _diagnosticsStore, + _healthEventRecorder, + _healthProfileService, _eventService, _loggerFactory.CreateLogger(), RemoveIfClosedAsync, @@ -270,7 +285,7 @@ public void CheckAdmission(StreamSourceDescriptor source) bool useSharedSession, CancellationToken ct) { - if (_sessions.TryGetValue(source.SessionKey, out var existing)) + if (_sessions.TryGetValue(source.SessionKey, out var existing) && existing.IsAcceptingSubscribers) return existing; if (!useSharedSession) diff --git a/src/M3Undle.Web/Streaming/Sessions/ChannelStreamSession.cs b/src/M3Undle.Web/Streaming/Sessions/ChannelStreamSession.cs index 66e1515..c7c181d 100644 --- a/src/M3Undle.Web/Streaming/Sessions/ChannelStreamSession.cs +++ b/src/M3Undle.Web/Streaming/Sessions/ChannelStreamSession.cs @@ -21,6 +21,8 @@ public sealed class ChannelStreamSession : IAsyncDisposable private readonly UpstreamFailureStrikeStore _strikeStore; private readonly StreamingRegistry _registry; private readonly StreamingDiagnosticsStore _diagnosticsStore; + private readonly IStreamChannelHealthEventRecorder _healthEventRecorder; + private readonly IStreamChannelHealthProfileService _healthProfileService; private readonly IEventService _eventService; private readonly M3UndleMetrics? _metrics; private readonly ILogger _logger; @@ -69,6 +71,17 @@ public sealed class ChannelStreamSession : IAsyncDisposable private int _mpegTsProbeBytes; private bool _mpegTsPacketModeKnown; private bool _mpegTsPacketizeEnabled; + private bool _recoveryOutputHoldActive; + private DateTimeOffset? _recoveryOutputHoldStartedUtc; + private long _recoveryBytesSuppressed; + private bool _recoveryResumedSinceLastReconnect; + private StreamChannelRecoveryPolicy? _currentRecoveryPolicy; + private string? _relayPolicy; + private string? _relayDecisionReason; + private string? _lastSafeStartKind; + private double? _lastRecoveryOutputHeldMs; + private DateTimeOffset? _lastRecoveryStartedUtc; + private DateTimeOffset? _lastRecoveryResumedUtc; private CancellationTokenSource? _idleCts; private string? _lastIdleGraceRemoteIp; @@ -81,6 +94,8 @@ public ChannelStreamSession( UpstreamFailureStrikeStore strikeStore, StreamingRegistry registry, StreamingDiagnosticsStore diagnosticsStore, + IStreamChannelHealthEventRecorder healthEventRecorder, + IStreamChannelHealthProfileService healthProfileService, IEventService eventService, ILogger logger, Func onClosed, @@ -94,6 +109,8 @@ public ChannelStreamSession( _strikeStore = strikeStore; _registry = registry; _diagnosticsStore = diagnosticsStore; + _healthEventRecorder = healthEventRecorder; + _healthProfileService = healthProfileService; _eventService = eventService; _logger = logger; _onClosed = onClosed; @@ -118,6 +135,11 @@ public ChannelStreamSession( public int InternalSubscriberCount => _subscribers.Values.Count(s => s.IsInternal); + public bool IsAcceptingSubscribers + => Volatile.Read(ref _stopRequested) == 0 + && Volatile.Read(ref _closeNotified) == 0 + && !_sessionCts.IsCancellationRequested; + public bool CanPreemptIdleGraceForRemoteIp(string? remoteIp) { if (string.IsNullOrWhiteSpace(remoteIp)) @@ -153,9 +175,15 @@ public async Task AttachSubscriberAsync( try { + if (!IsAcceptingSubscribers) + throw new OperationCanceledException(_sessionCts.Token); + EnsureStarted(); await _headersReadyTcs.Task.WaitAsync(requestCt); + if (!IsAcceptingSubscribers) + throw new OperationCanceledException(_sessionCts.Token); + var subscriber = new SubscriberConnection( sessionId: _sessionId, requestedRoute: _source.RequestedRoute, @@ -215,6 +243,30 @@ public Task RemoveSubscriberAsync(SubscriberConnection subscriber, SubscriberDis subscriber: subscriber, disconnectReason: reason, message: "Subscriber removed."); + if (_recoveryResumedSinceLastReconnect && reason == SubscriberDisconnectReason.ClientAborted) + { + var abortDelay = _lastRecoveryResumedUtc is { } resumedUtc + ? DateTimeOffset.UtcNow - resumedUtc + : (TimeSpan?)null; + RecordDiagnostic( + StreamDiagnosticEventKind.ClientAbortAfterRecovery, + subscriber: subscriber, + disconnectReason: reason, + clientAbortAfterRecoveryDelay: abortDelay, + message: "Client aborted after MPEG-TS recovery resumed."); + _logger.LogWarning( + "Client aborted after MPEG-TS recovery: SessionId={SessionId} DisplayName={DisplayName} ProviderId={ProviderId} ProviderChannelId={ProviderChannelId} RelayMode={RelayMode} SafeStartKind={SafeStartKind} LastOutputHeldMs={LastOutputHeldMs} AbortDelayMs={AbortDelayMs} BytesSent={BytesSent} DisconnectReason={DisconnectReason}", + _sessionId, + _source.DisplayName, + _source.ProviderId, + _source.ProviderChannelId, + _relayMode, + _lastSafeStartKind, + _lastRecoveryOutputHeldMs, + abortDelay?.TotalMilliseconds, + subscriber.BytesSent, + reason); + } lock (_gate) { @@ -320,12 +372,24 @@ private async Task RunAsync() StreamDiagnosticEventKind.UpstreamConnectStarted, reconnectAttempt: reconnectAttempt, message: "Opening upstream stream connection."); - await using var upstream = await _upstreamConnector.ConnectAsync(_source, _sessionCts.Token); + if (_currentRecoveryPolicy is null || reconnectAttempt == 0) + { + _currentRecoveryPolicy = await _healthProfileService.GetRecoveryPolicyAsync( + _source.ProviderId, + _source.ProviderChannelId, + _reconnectOptions, + _sessionCts.Token); + PublishSnapshots(); + } + + await using var upstream = await _upstreamConnector.ConnectAsync(_source, _currentRecoveryPolicy, _sessionCts.Token); _lastUpstreamStatusCode = upstream.StatusCode; _contentType = upstream.ContentType; _cacheControl = upstream.Response?.Headers.CacheControl?.ToString(); _relayMode = upstream.RelayMode; _lastRelayFallbackReason = upstream.RelayFallbackReason; + _relayPolicy = upstream.RelayPolicy; + _relayDecisionReason = upstream.RelayDecisionReason; _headersReadyTcs.TrySetResult(true); Interlocked.Exchange(ref _bytesSinceReconnect, 0); RecordDiagnostic( @@ -352,30 +416,50 @@ await PublishUnstableProviderEventAsync( $"FFmpeg relay fell back to direct streaming ({_lastRelayFallbackReason})."); } - if (reconnectAttempt > 0) + var recoveredAttempt = reconnectAttempt; + var shouldHoldRecoveredOutput = recoveredAttempt > 0 && IsMpegTsRelay(); + if (recoveredAttempt > 0) { _metrics?.RecordStreamReconnect(_source.ProviderId); _logger.LogInformation( "Stream '{DisplayName}' recovered successfully after {Attempts} reconnect attempt(s).", _source.DisplayName, - reconnectAttempt); + recoveredAttempt); _buffer.ResetGeneration(); ResetMpegTsBoundaryState(); RecordDiagnostic( StreamDiagnosticEventKind.ReconnectRecovered, httpStatusCode: upstream.StatusCode, - reconnectAttempt: reconnectAttempt, + reconnectAttempt: recoveredAttempt, message: "Upstream stream recovered after reconnect."); + if (shouldHoldRecoveredOutput) + { + _currentRecoveryPolicy = await _healthProfileService.GetRecoveryPolicyAsync( + _source.ProviderId, + _source.ProviderChannelId, + _reconnectOptions, + _sessionCts.Token); + BeginRecoveryOutputHold(recoveredAttempt); + if (_currentRecoveryPolicy.RequireDownstreamRetune) + await ExecuteControlledDownstreamRetuneAsync(); + } + await PublishRecoveredProviderEventIfNeededAsync(CancellationToken.None); } else { ResetMpegTsBoundaryState(); + _currentRecoveryPolicy = await _healthProfileService.GetRecoveryPolicyAsync( + _source.ProviderId, + _source.ProviderChannelId, + _reconnectOptions, + _sessionCts.Token); } reconnectAttempt = 0; outageStartedUtc = null; - SetState(SessionState.Live); + if (!_recoveryOutputHoldActive) + SetState(SessionState.Live); _logger.LogInformation( "Stream '{DisplayName}' is live — content type: {ContentType}.", @@ -423,7 +507,7 @@ await PublishUnstableProviderEventAsync( if (ShouldCooldownImmediately(kind)) { - var retryAfter = ResolveCooldownDuration(ex); + var retryAfter = ResolveCooldownDuration(kind, ex); _lastCooldownSeconds = retryAfter.TotalSeconds; _strikeStore.RecordStrike(Key, retryAfter); _logger.LogWarning( @@ -469,8 +553,11 @@ await PublishUnstableProviderEventAsync( if (!_headersReadyTcs.Task.IsCompleted) _headersReadyTcs.TrySetException(ex); - MarkPendingStopTrigger("upstream_fault"); - LogStopTrigger("upstream_fault", subscriberDisconnectReason: kind.ToString()); + var fatalStopTrigger = GetPendingStopTrigger() == "recovery_failed_unsafe" + ? "recovery_failed_unsafe" + : "upstream_fault"; + MarkPendingStopTrigger(fatalStopTrigger); + LogStopTrigger(fatalStopTrigger, subscriberDisconnectReason: kind.ToString()); SetState(SessionState.Faulted); await ForceCloseSubscribersAsync(); break; @@ -480,17 +567,28 @@ await PublishUnstableProviderEventAsync( var outageDuration = DateTimeOffset.UtcNow - outageStartedUtc.Value; if (outageDuration >= _reconnectOptions.OutageWindow) { - _strikeStore.RecordStrike(Key, _reconnectOptions.StrikeCooldown); + var cooldown = ResolveCooldownDuration(kind, ex); + _lastCooldownSeconds = cooldown.TotalSeconds; + _strikeStore.RecordStrike(Key, cooldown); + RecordDiagnostic( + StreamDiagnosticEventKind.CooldownRecorded, + upstreamFailureKind: kind, + httpStatusCode: _lastUpstreamStatusCode, + cooldownSeconds: cooldown.TotalSeconds, + retryAfterSeconds: Math.Max(1, (int)Math.Ceiling(Math.Min(30, cooldown.TotalSeconds))), + message: "Upstream cooldown recorded after outage window exhaustion."); _headersReadyTcs.TrySetException(new TimeoutException("Reconnect outage window exhausted.")); MarkPendingStopTrigger("upstream_fault"); LogStopTrigger("upstream_fault", subscriberDisconnectReason: "outage_window_exhausted"); SetState(SessionState.Faulted); await ForceCloseSubscribersAsync(); _logger.LogWarning( - "Session {SessionId} outage window exhausted; entering cooldown for {ProviderId}/{ProviderChannelId}.", + "Session {SessionId} outage window exhausted; entering cooldown for {ProviderId}/{ProviderChannelId}. kind={FailureKind} cooldownSeconds={CooldownSeconds:F0}.", _sessionId, _source.ProviderId, - _source.ProviderChannelId); + _source.ProviderChannelId, + kind, + cooldown.TotalSeconds); break; } @@ -618,11 +716,33 @@ private async Task PublishUpstreamBytesAsync(ReadOnlyMemory data) resetStallTimer = HasNonNullTsContent(batch.Data); Interlocked.Add(ref _mpegTsBytesSinceReset, batch.Data.Length); using var published = _buffer.Write(batch.Data); - MarkMpegTsSafeStartIfReady(published, batch.StartupKind); + var safeStart = MarkMpegTsSafeStartIfReady( + published, + batch.StartupKind, + batch.Data.Length, + batch.HasKnownH264VideoStream); + if (_recoveryOutputHoldActive) + { + _recoveryBytesSuppressed += batch.Data.Length; + if (safeStart.Selected) + { + await ResumeRecoveryOutputAsync(safeStart.Kind); + } + else if (IsRecoveryHoldLimitExceeded()) + { + await FailRecoveryOutputAsync(); + } + + return resetStallTimer; + } + await PublishToSubscribersAsync(published); } else { + if (_recoveryOutputHoldActive) + await FailRecoveryOutputAsync(); + using var published = _buffer.Write(data); await PublishToSubscribersAsync(published); } @@ -668,6 +788,9 @@ private async Task PublishToSubscribersAsync(BufferLease published) private BufferSnapshot CreateSubscriberStartupSnapshot(bool isInternal) { + if (_recoveryOutputHoldActive) + return _buffer.CreateLiveEdgeSnapshot(); + if (IsMpegTsRelay() && isInternal) return _buffer.CreateSafeStartSnapshot(); @@ -684,7 +807,7 @@ private bool IsMpegTsRelay() || _contentType?.Contains("mpegts", StringComparison.OrdinalIgnoreCase) == true; private bool ShouldInjectMpegTsKeepalive() - => IsMpegTsRelay() && HasNonHdhrExternalSubscriber(); + => IsMpegTsRelay() && !_recoveryOutputHoldActive && HasNonHdhrExternalSubscriber(); private bool HasNonHdhrExternalSubscriber() => _subscribers.Values.Any(subscriber => @@ -798,28 +921,40 @@ private static bool HasLikelyMpegTsSync(ReadOnlySpan data) return false; } - private void MarkMpegTsSafeStartIfReady(BufferLease lease, MpegTsStartupKind kind) + private SafeStartDecision MarkMpegTsSafeStartIfReady( + BufferLease lease, + MpegTsStartupKind kind, + int batchLength, + bool hasKnownH264VideoStream) { - var fallbackBytes = Math.Min(Math.Max(MpegTsBoundaryScanner.PacketSize, _bufferOptions.MaxBytesPerSession / 2), 512 * 1024); + var fallbackBytes = ResolveRecoverySafeStartSearchLimitBytes(); if (kind == MpegTsStartupKind.PatPmt && _mpegTsCandidateSafeStartSequence is null) { _mpegTsCandidateSafeStartGeneration = lease.Generation; - _mpegTsCandidateSafeStartSequence = lease.Sequence; + _mpegTsCandidateSafeStartSequence = batchLength == MpegTsBoundaryScanner.PacketSize + ? Math.Max(0, lease.Sequence - 1) + : lease.Sequence; } - var selected = kind is MpegTsStartupKind.H264Idr or MpegTsStartupKind.PatPmt; + var selected = kind == MpegTsStartupKind.H264Idr + || (kind == MpegTsStartupKind.PatPmt && !hasKnownH264VideoStream); var fallback = false; - if (!selected && !_mpegTsSafeStartSelected && Interlocked.Read(ref _mpegTsBytesSinceReset) >= fallbackBytes) + if (!selected + && ResolveRecoveryPolicy().AllowPacketBoundaryRecoveryFallback + && !_mpegTsSafeStartSelected + && Interlocked.Read(ref _mpegTsBytesSinceReset) >= fallbackBytes) { selected = true; fallback = true; } if (!selected) - return; + return SafeStartDecision.NotSelected; + + var safeStartKind = fallback ? "FallbackPacketBoundary" : kind.ToString(); - if (kind == MpegTsStartupKind.H264Idr + if (kind is MpegTsStartupKind.H264Idr or MpegTsStartupKind.PatPmt && _mpegTsCandidateSafeStartGeneration is { } generation && _mpegTsCandidateSafeStartSequence is { } sequence) _buffer.MarkSafeStart(generation, sequence); @@ -831,10 +966,194 @@ private void MarkMpegTsSafeStartIfReady(BufferLease lease, MpegTsStartupKind kin _mpegTsSafeStartSelected = true; RecordDiagnostic( StreamDiagnosticEventKind.MpegTsSafeStartSelected, - message: $"MPEG-TS safe start selected: {(fallback ? "FallbackPacketBoundary" : kind)}."); + safeStartKind: safeStartKind, + message: $"MPEG-TS safe start selected: {safeStartKind}."); } + + return new SafeStartDecision(true, safeStartKind); + } + + private void BeginRecoveryOutputHold(int reconnectAttempt) + { + _recoveryOutputHoldActive = true; + _recoveryOutputHoldStartedUtc = DateTimeOffset.UtcNow; + _lastRecoveryStartedUtc = _recoveryOutputHoldStartedUtc; + _recoveryBytesSuppressed = 0; + _recoveryResumedSinceLastReconnect = false; + _currentRecoveryPolicy ??= StreamChannelRecoveryPolicy.FromOptions(_reconnectOptions); + var policy = ResolveRecoveryPolicy(); + SetState(SessionState.HoldingOutput); + RecordDiagnostic( + StreamDiagnosticEventKind.RecoveryStarted, + reconnectAttempt: reconnectAttempt, + recoveryHoldLimit: policy.RecoveryOutputHoldLimit, + message: "MPEG-TS recovery output hold started after reconnect."); + RecordDiagnostic( + StreamDiagnosticEventKind.RecoveryOutputHeld, + reconnectAttempt: reconnectAttempt, + recoveryHoldLimit: policy.RecoveryOutputHoldLimit, + message: "Downstream output is held while recovered MPEG-TS is scanned for a safe start."); + _logger.LogInformation( + "Recovery hold started: SessionId={SessionId} DisplayName={DisplayName} ProviderId={ProviderId} ProviderChannelId={ProviderChannelId} RelayMode={RelayMode} ReconnectAttempt={ReconnectAttempt} HoldLimitMs={HoldLimitMs} SearchLimitBytes={SearchLimitBytes} HealthProfile={HealthProfile} PacketBoundaryFallbackAllowed={PacketBoundaryFallbackAllowed} PolicyReason={PolicyReason}", + _sessionId, + _source.DisplayName, + _source.ProviderId, + _source.ProviderChannelId, + _relayMode, + reconnectAttempt, + policy.RecoveryOutputHoldLimit.TotalMilliseconds, + ResolveRecoverySafeStartSearchLimitBytes(), + policy.Profile, + policy.AllowPacketBoundaryRecoveryFallback, + policy.Reason); + PublishSnapshots(); + } + + private async Task ResumeRecoveryOutputAsync(string safeStartKind) + { + if (!_recoveryOutputHoldActive) + return; + + var heldDuration = GetRecoveryHoldDuration(); + var recoveryDuration = _lastRecoveryStartedUtc is { } recoveryStartedUtc + ? DateTimeOffset.UtcNow - recoveryStartedUtc + : (TimeSpan?)null; + RecordDiagnostic( + StreamDiagnosticEventKind.RecoverySafeStartFound, + outputHeld: heldDuration, + recoveryDuration: recoveryDuration, + safeStartKind: safeStartKind, + bytesSuppressed: _recoveryBytesSuppressed, + recoveryHoldLimit: ResolveRecoveryPolicy().RecoveryOutputHoldLimit, + message: "Recovered MPEG-TS safe start found."); + + var snapshot = _buffer.CreateSafeStartSnapshot(); + try + { + foreach (var lease in snapshot.Chunks) + { + using (lease) + { + await PublishToSubscribersAsync(lease); + } + } + } + finally + { + foreach (var lease in snapshot.Chunks) + lease.Dispose(); + } + + _recoveryOutputHoldActive = false; + _recoveryOutputHoldStartedUtc = null; + _recoveryResumedSinceLastReconnect = true; + _lastSafeStartKind = safeStartKind; + _lastRecoveryOutputHeldMs = heldDuration.TotalMilliseconds; + _lastRecoveryResumedUtc = DateTimeOffset.UtcNow; + SetState(SessionState.Live); + RecordDiagnostic( + StreamDiagnosticEventKind.RecoveryOutputResumed, + outputHeld: heldDuration, + recoveryDuration: recoveryDuration, + safeStartKind: safeStartKind, + bytesSuppressed: _recoveryBytesSuppressed, + recoveryHoldLimit: ResolveRecoveryPolicy().RecoveryOutputHoldLimit, + message: "Downstream output resumed from recovered MPEG-TS safe start."); + _logger.LogInformation( + "Recovery resumed: SessionId={SessionId} DisplayName={DisplayName} ProviderId={ProviderId} ProviderChannelId={ProviderChannelId} RelayMode={RelayMode} SafeStartKind={SafeStartKind} OutputHeldMs={OutputHeldMs} BytesSuppressed={BytesSuppressed}", + _sessionId, + _source.DisplayName, + _source.ProviderId, + _source.ProviderChannelId, + _relayMode, + safeStartKind, + heldDuration.TotalMilliseconds, + _recoveryBytesSuppressed); + PublishSnapshots(); + } + + private Task FailRecoveryOutputAsync() + { + var heldDuration = GetRecoveryHoldDuration(); + var recoveryDuration = _lastRecoveryStartedUtc is { } recoveryStartedUtc + ? DateTimeOffset.UtcNow - recoveryStartedUtc + : (TimeSpan?)null; + var bytesSuppressed = _recoveryBytesSuppressed; + var searchLimitExceeded = bytesSuppressed >= ResolveRecoverySafeStartSearchLimitBytes(); + RecordDiagnostic( + searchLimitExceeded + ? StreamDiagnosticEventKind.RecoveryFailedUnsafe + : StreamDiagnosticEventKind.RecoveryHoldLimitExceeded, + outputHeld: heldDuration, + recoveryDuration: recoveryDuration, + bytesSuppressed: bytesSuppressed, + recoveryHoldLimit: ResolveRecoveryPolicy().RecoveryOutputHoldLimit, + message: searchLimitExceeded + ? "MPEG-TS recovery safe-start search limit was exceeded." + : "MPEG-TS recovery output hold limit was exceeded."); + _logger.LogWarning( + "Recovery failed — forced retune: SessionId={SessionId} DisplayName={DisplayName} ProviderId={ProviderId} ProviderChannelId={ProviderChannelId} RelayMode={RelayMode} OutputHeldMs={OutputHeldMs} BytesSuppressed={BytesSuppressed} SearchLimitExceeded={SearchLimitExceeded} HealthProfile={HealthProfile} PacketBoundaryFallbackAllowed={PacketBoundaryFallbackAllowed} PolicyReason={PolicyReason}", + _sessionId, + _source.DisplayName, + _source.ProviderId, + _source.ProviderChannelId, + _relayMode, + heldDuration.TotalMilliseconds, + bytesSuppressed, + searchLimitExceeded, + ResolveRecoveryPolicy().Profile, + ResolveRecoveryPolicy().AllowPacketBoundaryRecoveryFallback, + ResolveRecoveryPolicy().Reason); + RecordDiagnostic( + StreamDiagnosticEventKind.RecoveryForcedRetune, + outputHeld: heldDuration, + recoveryDuration: recoveryDuration, + bytesSuppressed: bytesSuppressed, + recoveryHoldLimit: ResolveRecoveryPolicy().RecoveryOutputHoldLimit, + stopTrigger: "recovery_failed_unsafe", + message: "Closing downstream subscribers because recovery could not resume safely."); + MarkPendingStopTrigger("recovery_failed_unsafe"); + LogStopTrigger("recovery_failed_unsafe"); + SetState(SessionState.Faulted); + _recoveryOutputHoldActive = false; + _recoveryOutputHoldStartedUtc = null; + + throw new UpstreamConnectException( + "Recovered MPEG-TS stream did not reach a safe restart point before the recovery hold limit.", + UpstreamFailureKind.StartupFatal); } + private bool IsRecoveryHoldLimitExceeded() + { + if (!_recoveryOutputHoldActive) + return false; + + return GetRecoveryHoldDuration() >= ResolveRecoveryPolicy().RecoveryOutputHoldLimit + || _recoveryBytesSuppressed >= ResolveRecoverySafeStartSearchLimitBytes(); + } + + private TimeSpan GetRecoveryHoldDuration() + => _recoveryOutputHoldStartedUtc is { } started + ? DateTimeOffset.UtcNow - started + : TimeSpan.Zero; + + private int ResolveRecoverySafeStartSearchLimitBytes() + { + var policy = ResolveRecoveryPolicy(); + var configuredSearchLimit = Math.Max( + MpegTsBoundaryScanner.PacketSize, + policy.RecoverySafeStartSearchLimitBytes); + var currentFallbackWindow = Math.Min( + Math.Max(MpegTsBoundaryScanner.PacketSize, _bufferOptions.MaxBytesPerSession / 2), + Math.Max(512 * 1024, configuredSearchLimit)); + return Math.Max( + MpegTsBoundaryScanner.PacketSize, + Math.Min(currentFallbackWindow, configuredSearchLimit)); + } + + private StreamChannelRecoveryPolicy ResolveRecoveryPolicy() + => _currentRecoveryPolicy ?? StreamChannelRecoveryPolicy.FromOptions(_reconnectOptions); + private void SetState(SessionState state) { _state = state; @@ -849,21 +1168,38 @@ private static bool ShouldCooldownImmediately(UpstreamFailureKind kind) => kind is UpstreamFailureKind.UpstreamProxyAuthRequired or UpstreamFailureKind.UpstreamRateLimited; - private TimeSpan ResolveCooldownDuration(Exception ex) + private TimeSpan ResolveCooldownDuration(UpstreamFailureKind kind, Exception ex) { if (ex is UpstreamConnectException { RetryAfter: { } retryAfter }) { if (retryAfter <= TimeSpan.Zero) return TimeSpan.FromSeconds(1); - return _reconnectOptions.StrikeCooldown > retryAfter - ? _reconnectOptions.StrikeCooldown - : retryAfter; + return retryAfter; } - return _reconnectOptions.StrikeCooldown > TimeSpan.Zero + var fallback = kind switch + { + UpstreamFailureKind.UpstreamRateLimited => _reconnectOptions.RateLimitFallbackCooldown, + UpstreamFailureKind.UpstreamProxyAuthRequired => _reconnectOptions.ProxyAuthFallbackCooldown, + UpstreamFailureKind.UpstreamServerError => _reconnectOptions.UpstreamServerErrorFallbackCooldown, + UpstreamFailureKind.Transport + or UpstreamFailureKind.TimeoutOrStall + or UpstreamFailureKind.EndOfStream => _reconnectOptions.TransportFallbackCooldown, + _ => _reconnectOptions.UpstreamServerErrorFallbackCooldown, + }; + + return CapFallbackCooldown(fallback); + } + + private TimeSpan CapFallbackCooldown(TimeSpan fallback) + { + if (fallback <= TimeSpan.Zero) + fallback = TimeSpan.FromSeconds(1); + + return _reconnectOptions.StrikeCooldown > TimeSpan.Zero && fallback > _reconnectOptions.StrikeCooldown ? _reconnectOptions.StrikeCooldown - : TimeSpan.FromSeconds(1); + : fallback; } private TimeSpan GetReconnectDelay(int attempt) @@ -884,6 +1220,65 @@ private async Task ForceCloseSubscribersAsync() } } + private async Task ExecuteControlledDownstreamRetuneAsync() + { + var policy = ResolveRecoveryPolicy(); + var heldDuration = GetRecoveryHoldDuration(); + var internalSubscriberCount = InternalSubscriberCount; + if (internalSubscriberCount > 0) + { + _logger.LogInformation( + "Controlled downstream retune suppressed: SessionId={SessionId} DisplayName={DisplayName} ProviderId={ProviderId} ProviderChannelId={ProviderChannelId} HealthProfile={HealthProfile} DownstreamRetuneReason={DownstreamRetuneReason} OutputHeldMs={OutputHeldMs} InternalSubscriberCount={InternalSubscriberCount} ExternalSubscriberCount={ExternalSubscriberCount}", + _sessionId, + _source.DisplayName, + _source.ProviderId, + _source.ProviderChannelId, + policy.Profile, + policy.DownstreamRetuneReason, + heldDuration.TotalMilliseconds, + internalSubscriberCount, + ExternalSubscriberCount); + return; + } + + _logger.LogInformation( + "Controlled downstream retune: SessionId={SessionId} DisplayName={DisplayName} ProviderId={ProviderId} ProviderChannelId={ProviderChannelId} HealthProfile={HealthProfile} DownstreamRetuneRequired=true DownstreamRetuneReason={DownstreamRetuneReason} OutputHeldMs={OutputHeldMs} BytesSuppressed={BytesSuppressed}", + _sessionId, + _source.DisplayName, + _source.ProviderId, + _source.ProviderChannelId, + policy.Profile, + policy.DownstreamRetuneReason, + heldDuration.TotalMilliseconds, + _recoveryBytesSuppressed); + RecordDiagnostic( + StreamDiagnosticEventKind.ControlledDownstreamRetune, + outputHeld: heldDuration, + bytesSuppressed: _recoveryBytesSuppressed, + recoveryHoldLimit: policy.RecoveryOutputHoldLimit, + stopTrigger: "controlled_downstream_retune", + message: $"Closing shared stream session for controlled downstream retune. Reason: {policy.DownstreamRetuneReason}"); + Interlocked.Exchange(ref _stopRequested, 1); + MarkPendingStopTrigger("controlled_downstream_retune"); + LogStopTrigger("controlled_downstream_retune", subscriberDisconnectReason: SubscriberDisconnectReason.Retuned.ToString()); + _recoveryOutputHoldActive = false; + _recoveryOutputHoldStartedUtc = null; + SetState(SessionState.Closed); + foreach (var subscriber in _subscribers.Values) + { + if (!subscriber.IsInternal) + await subscriber.CompleteAsync(SubscriberDisconnectReason.Retuned); + } + foreach (var subscriber in _subscribers.Values) + { + if (subscriber.IsInternal) + await subscriber.CompleteAsync(SubscriberDisconnectReason.SessionClosed); + } + + _sessionCts.Cancel(); + throw new OperationCanceledException(_sessionCts.Token); + } + private void BeginSubscriberAttach() { lock (_gate) @@ -1173,6 +1568,14 @@ private void MarkPendingStopTrigger(string stopTrigger) : null; } + private string? GetPendingStopTrigger() + { + lock (_gate) + { + return GetPendingStopTriggerNoLock(); + } + } + private TimeSpan? GetTimeSinceLastUpstreamByte(DateTimeOffset now) => _lastUpstreamByteUtc is { } last ? now - last : null; @@ -1207,12 +1610,22 @@ private void PublishSnapshots() LastFailureKind: _lastFailureKind, FirstByteLatencyMs: _lastFirstByteLatencyMs, BytesSinceReconnect: Interlocked.Read(ref _bytesSinceReconnect), + TotalBytesRelayed: Interlocked.Read(ref _totalBytesRelayed), LastDisconnectReason: _lastDisconnectReason, LastStopTrigger: _lastStopTrigger, LastUpstreamStatusCode: _lastUpstreamStatusCode, LastCooldownSeconds: _lastCooldownSeconds, - RelayMode: _relayMode, - LastRelayFallbackReason: _lastRelayFallbackReason); + RelayMode: _relayMode, + LastRelayFallbackReason: _lastRelayFallbackReason, + RelayPolicy: _relayPolicy, + RelayDecisionReason: _relayDecisionReason, + LastSafeStartKind: _lastSafeStartKind, + LastRecoveryOutputHeldMs: _lastRecoveryOutputHeldMs, + LastRecoveryStartedUtc: _lastRecoveryStartedUtc, + HealthProfile: _currentRecoveryPolicy?.Profile, + HealthProfileReason: _currentRecoveryPolicy?.Reason, + RequiresDownstreamRetune: _currentRecoveryPolicy?.RequireDownstreamRetune ?? false, + DownstreamRetuneReason: _currentRecoveryPolicy?.DownstreamRetuneReason); _registry.UpsertSession(session); _registry.UpsertProvider(new StreamProviderSnapshot( @@ -1229,7 +1642,12 @@ private void PublishSnapshots() LastUpstreamStatusCode: _lastUpstreamStatusCode, LastCooldownSeconds: _lastCooldownSeconds, RelayMode: _relayMode, - LastRelayFallbackReason: _lastRelayFallbackReason)); + LastRelayFallbackReason: _lastRelayFallbackReason, + RelayPolicy: _relayPolicy, + RelayDecisionReason: _relayDecisionReason, + LastSafeStartKind: _lastSafeStartKind, + LastRecoveryOutputHeldMs: _lastRecoveryOutputHeldMs, + LastRecoveryStartedUtc: _lastRecoveryStartedUtc)); } private async Task NotifyClosedAsync() @@ -1245,9 +1663,29 @@ private async Task NotifyClosedAsync() StreamDiagnosticEventKind.SessionClosed, stopTrigger: _lastStopTrigger, message: "Shared stream session closed."); + RecordCleanWatchIfEligible(); await _onClosed(Key, this); } + private void RecordCleanWatchIfEligible() + { + if (_state != SessionState.Closed + || _reconnectAttempts > 0 + || !string.IsNullOrWhiteSpace(_lastFailureKind) + || _lastUpstreamByteUtc is null + || Interlocked.Read(ref _totalBytesRelayed) <= 0) + return; + + var duration = DateTimeOffset.UtcNow - _startedUtc; + if (duration <= TimeSpan.Zero) + return; + + RecordDiagnostic( + StreamDiagnosticEventKind.CleanWatchCompleted, + cleanWatchDuration: duration, + message: "Shared stream session completed without an observed upstream health incident."); + } + private void RecordDiagnostic( StreamDiagnosticEventKind kind, SubscriberConnection? subscriber = null, @@ -1261,9 +1699,16 @@ private void RecordDiagnostic( double? cooldownSeconds = null, int? retryAfterSeconds = null, int? queueDepth = null, + TimeSpan? outputHeld = null, + TimeSpan? recoveryDuration = null, + string? safeStartKind = null, + long? bytesSuppressed = null, + TimeSpan? recoveryHoldLimit = null, + TimeSpan? clientAbortAfterRecoveryDelay = null, + TimeSpan? cleanWatchDuration = null, string? message = null) { - _diagnosticsStore.Record(new StreamDiagnosticEvent( + var diagnosticEvent = new StreamDiagnosticEvent( EventId: Guid.NewGuid().ToString("N"), TimestampUtc: DateTimeOffset.UtcNow, Kind: kind, @@ -1289,7 +1734,17 @@ private void RecordDiagnostic( StopTrigger: stopTrigger, CooldownSeconds: cooldownSeconds, RetryAfterSeconds: retryAfterSeconds, - Message: message)); + OutputHeldMs: outputHeld?.TotalMilliseconds, + RecoveryDurationMs: recoveryDuration?.TotalMilliseconds, + SafeStartKind: safeStartKind, + BytesSuppressed: bytesSuppressed, + RecoveryHoldLimitMs: recoveryHoldLimit?.TotalMilliseconds, + ClientAbortAfterRecoveryDelayMs: clientAbortAfterRecoveryDelay?.TotalMilliseconds, + CleanWatchDurationMs: cleanWatchDuration?.TotalMilliseconds, + RelayMode: _relayMode, + Message: message); + _diagnosticsStore.Record(diagnosticEvent); + _healthEventRecorder.Record(diagnosticEvent); } private async Task PublishUnstableProviderEventAsync(string detail) @@ -1361,4 +1816,9 @@ public void Dispose() { } } + + private readonly record struct SafeStartDecision(bool Selected, string Kind) + { + public static SafeStartDecision NotSelected => new(false, string.Empty); + } } diff --git a/src/M3Undle.Web/Streaming/Subscribers/SubscriberConnection.cs b/src/M3Undle.Web/Streaming/Subscribers/SubscriberConnection.cs index 50af92f..ae42089 100644 --- a/src/M3Undle.Web/Streaming/Subscribers/SubscriberConnection.cs +++ b/src/M3Undle.Web/Streaming/Subscribers/SubscriberConnection.cs @@ -52,7 +52,10 @@ public SubscriberConnection( _outbound = Channel.CreateBounded(options); ClientId = Guid.NewGuid().ToString("N"); ConnectedUtc = DateTimeOffset.UtcNow; - RemoteIp = context.Connection.RemoteIpAddress?.ToString(); + var addr = context.Connection.RemoteIpAddress; + RemoteIp = addr is null ? null + : addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4().ToString() + : addr.ToString(); UserAgent = context.Request.Headers.UserAgent.ToString(); RequestPath = context.Request.Path.Value ?? requestedRoute; } diff --git a/src/M3Undle.Web/Streaming/Upstream/UpstreamConnection.cs b/src/M3Undle.Web/Streaming/Upstream/UpstreamConnection.cs index a836a1f..137e6d4 100644 --- a/src/M3Undle.Web/Streaming/Upstream/UpstreamConnection.cs +++ b/src/M3Undle.Web/Streaming/Upstream/UpstreamConnection.cs @@ -16,7 +16,9 @@ public UpstreamConnection( HttpResponseMessage response, Stream stream, string relayMode = UpstreamRelayModes.Direct, - string? relayFallbackReason = null) + string? relayFallbackReason = null, + string? relayPolicy = null, + string? relayDecisionReason = null) { _client = client; _response = response; @@ -25,6 +27,8 @@ public UpstreamConnection( _contentType = response.Content.Headers.ContentType?.ToString(); RelayMode = relayMode; RelayFallbackReason = relayFallbackReason; + RelayPolicy = relayPolicy; + RelayDecisionReason = relayDecisionReason; } public UpstreamConnection( @@ -32,13 +36,17 @@ public UpstreamConnection( Stream stream, string contentType, int statusCode = 200, - string relayMode = UpstreamRelayModes.FfmpegHlsToMpegTs) + string relayMode = UpstreamRelayModes.FfmpegHlsToMpegTs, + string? relayPolicy = null, + string? relayDecisionReason = null) { _process = process; Stream = stream; _contentType = contentType; _statusCode = statusCode; RelayMode = relayMode; + RelayPolicy = relayPolicy; + RelayDecisionReason = relayDecisionReason; } public HttpResponseMessage? Response => _response; @@ -53,6 +61,10 @@ public UpstreamConnection( public string? RelayFallbackReason { get; } + public string? RelayPolicy { get; } + + public string? RelayDecisionReason { get; } + public async ValueTask DisposeAsync() { await Stream.DisposeAsync(); diff --git a/src/M3Undle.Web/Streaming/Upstream/UpstreamStreamConnector.cs b/src/M3Undle.Web/Streaming/Upstream/UpstreamStreamConnector.cs index 8a47e09..1623011 100644 --- a/src/M3Undle.Web/Streaming/Upstream/UpstreamStreamConnector.cs +++ b/src/M3Undle.Web/Streaming/Upstream/UpstreamStreamConnector.cs @@ -8,6 +8,7 @@ using M3Undle.Web.Streaming.Compatibility; using M3Undle.Web.Streaming.Configuration; using M3Undle.Web.Streaming.Models; +using M3Undle.Web.Streaming.Observability; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; @@ -16,6 +17,7 @@ namespace M3Undle.Web.Streaming.Upstream; public sealed class UpstreamStreamConnector( IHttpClientFactory httpClientFactory, IServiceScopeFactory scopeFactory, + IStreamChannelHealthProfileService healthProfileService, IOptions reconnectOptions, IOptions hlsOptions, IOptions cleanRelayOptions, @@ -25,7 +27,10 @@ public sealed class UpstreamStreamConnector( private readonly GeneratedHlsOptions _hlsOptions = hlsOptions.Value; private readonly CleanRelayOptions _cleanRelayOptions = cleanRelayOptions.Value; - public async Task ConnectAsync(StreamSourceDescriptor source, CancellationToken ct) + public async Task ConnectAsync( + StreamSourceDescriptor source, + StreamChannelRecoveryPolicy? recoveryPolicy, + CancellationToken ct) { using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -79,10 +84,18 @@ public async Task ConnectAsync(StreamSourceDescriptor source hlsCandidates[0]); } + var relayDecision = recoveryPolicy is null + ? ResolveLegacyRelayDecision(provider.CleanRelayMode) + : healthProfileService.GetRelayPolicyDecision(provider.CleanRelayMode, recoveryPolicy); string? relayFallbackReason = null; - if (CleanRelayModes.IsRemux(provider.CleanRelayMode)) + if (string.Equals(relayDecision.SelectedRelayMode, UpstreamRelayModes.FfmpegCleanRemux, StringComparison.Ordinal)) { - var cleanRelayConnection = await TryFfmpegCleanRemuxRelayAsync(effectiveStreamUrl, provider, source.DisplayName, ct); + logger.LogInformation( + "Clean remux selected for '{DisplayName}'. ProviderRelayPolicy={ProviderRelayPolicy} Reason={RelayDecisionReason}", + source.DisplayName, + relayDecision.ProviderRelayPolicy, + relayDecision.Reason); + var cleanRelayConnection = await TryFfmpegCleanRemuxRelayAsync(effectiveStreamUrl, provider, source.DisplayName, relayDecision, ct); if (cleanRelayConnection is not null) return cleanRelayConnection; @@ -189,7 +202,9 @@ public async Task ConnectAsync(StreamSourceDescriptor source response, stream, relayMode: UpstreamRelayModes.Direct, - relayFallbackReason: relayFallbackReason); + relayFallbackReason: relayFallbackReason, + relayPolicy: relayDecision.ProviderRelayPolicy, + relayDecisionReason: relayDecision.Reason); response = null; // ownership transferred to UpstreamConnection return connection; } @@ -213,6 +228,17 @@ public async Task ConnectAsync(StreamSourceDescriptor source } } + public Task ConnectAsync(StreamSourceDescriptor source, CancellationToken ct) + => ConnectAsync(source, recoveryPolicy: null, ct); + + private static StreamRelayPolicyDecision ResolveLegacyRelayDecision(string? cleanRelayMode) + { + var normalized = CleanRelayModes.Normalize(cleanRelayMode); + return CleanRelayModes.IsRemux(normalized) + ? StreamRelayPolicyDecision.CleanRemux(normalized, "Provider relay policy is On; clean remux is forced for this provider.") + : StreamRelayPolicyDecision.Direct(normalized, "Provider relay policy is Off; direct relay is forced for this provider."); + } + internal static string RewriteUrlForMpegTs(string url) { if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) @@ -320,6 +346,7 @@ public UpstreamFailureKind Classify(Exception ex, int? statusCode = null) string inputUrl, Provider provider, string displayName, + StreamRelayPolicyDecision relayDecision, CancellationToken ct) { var ffmpegPath = ResolveCleanRelayFfmpegPath(); @@ -339,6 +366,8 @@ public UpstreamFailureKind Classify(Exception ex, int? statusCode = null) startupTimeout: TimeSpan.FromSeconds(_cleanRelayOptions.StartupTimeoutSeconds), startupBufferBytes: _cleanRelayOptions.MaxStartupBytes, relayMode: UpstreamRelayModes.FfmpegCleanRemux, + relayPolicy: relayDecision.ProviderRelayPolicy, + relayDecisionReason: relayDecision.Reason, relayDescription: "clean remux relay", includeCleanRepairFlags: true, isHlsInput: false, @@ -361,7 +390,9 @@ public UpstreamFailureKind Classify(Exception ex, int? statusCode = null) string relayDescription, bool includeCleanRepairFlags, bool isHlsInput, - CancellationToken ct) + CancellationToken ct, + string? relayPolicy = null, + string? relayDecisionReason = null) { if (string.IsNullOrWhiteSpace(ffmpegPath)) return null; @@ -394,8 +425,6 @@ public UpstreamFailureKind Classify(Exception ex, int? statusCode = null) { info.ArgumentList.Add("-err_detect"); info.ArgumentList.Add("ignore_err"); - info.ArgumentList.Add("-use_wallclock_as_timestamps"); - info.ArgumentList.Add("1"); info.ArgumentList.Add("-avoid_negative_ts"); info.ArgumentList.Add("make_zero"); } @@ -434,6 +463,11 @@ public UpstreamFailureKind Classify(Exception ex, int? statusCode = null) info.ArgumentList.Add("0"); info.ArgumentList.Add("-muxdelay"); info.ArgumentList.Add("0"); + if (includeCleanRepairFlags) + { + info.ArgumentList.Add("-bsf:v"); + info.ArgumentList.Add("dump_extra=freq=keyframe"); + } info.ArgumentList.Add("-mpegts_flags"); info.ArgumentList.Add("+resend_headers"); info.ArgumentList.Add("-f"); @@ -520,7 +554,9 @@ public UpstreamFailureKind Classify(Exception ex, int? statusCode = null) process, new PrefixedStream(startupBuffer.AsMemory(0, startupBytes), stdout), "video/mp2t", - relayMode: relayMode); + relayMode: relayMode, + relayPolicy: relayPolicy, + relayDecisionReason: relayDecisionReason); } private static void TryKillProcess(Process process) diff --git a/src/M3Undle.Web/appsettings.Development.json b/src/M3Undle.Web/appsettings.Development.json index a448254..257fa95 100644 --- a/src/M3Undle.Web/appsettings.Development.json +++ b/src/M3Undle.Web/appsettings.Development.json @@ -4,7 +4,8 @@ "Default": "Debug", "Override": { "Microsoft.AspNetCore": "Warning", - "Microsoft.EntityFrameworkCore": "Warning" + "Microsoft.EntityFrameworkCore": "Warning", + "Microsoft.Extensions.Localization": "Warning" } } }, diff --git a/src/M3Undle.Web/appsettings.json b/src/M3Undle.Web/appsettings.json index 3888887..0de7983 100644 --- a/src/M3Undle.Web/appsettings.json +++ b/src/M3Undle.Web/appsettings.json @@ -97,7 +97,14 @@ "ContentStallTimeout": "00:00:08", "OutageWindow": "00:01:15", "StrikeCooldown": "00:05:00", + "ProxyAuthFallbackCooldown": "00:00:30", + "RateLimitFallbackCooldown": "00:01:00", + "UpstreamServerErrorFallbackCooldown": "00:00:30", + "TransportFallbackCooldown": "00:00:15", "ConnectTimeout": "00:00:15", + "RecoveryOutputHoldLimit": "00:00:03", + "RecoverySafeStartSearchLimitBytes": 2097152, + "AllowPacketBoundaryRecoveryFallback": true, "FixedStepBackoffSeconds": [ 0, 1, 2, 5, 10, 15, 30 ] }, "GeneratedHls": { diff --git a/src/M3Undle.Web/wwwroot/app.css b/src/M3Undle.Web/wwwroot/app.css index fe8f57c..abe5dc2 100644 --- a/src/M3Undle.Web/wwwroot/app.css +++ b/src/M3Undle.Web/wwwroot/app.css @@ -1,5 +1,31 @@ html, body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + overflow-x: hidden; +} + +/* MudBlazor sets overflow-y:auto on .mud-drawer but omits overflow-x. + Without this, nav item text wider than the drawer leaks sideways. */ +.mud-drawer { + overflow-x: hidden; +} + +.mud-navmenu .mud-nav-link.active:not(.mud-nav-link-disabled), +.mud-navmenu .mud-nav-link.mud-nav-link-active:not(.mud-nav-link-disabled) { + border-left: 3px solid #1FB6A6 !important; + background: linear-gradient(90deg, rgba(31, 182, 166, 0.30) 0%, rgba(31, 182, 166, 0.16) 60%, rgba(31, 182, 166, 0.08) 100%) !important; + box-shadow: inset 0 0 0 1px rgba(31, 182, 166, 0.25); + color: #E9FFFC !important; +} + +.mud-navmenu .mud-nav-link:not(.active):not(.mud-nav-link-active) { + border-left: 3px solid transparent !important; +} + +.mud-navmenu .mud-nav-link.active .mud-nav-link-icon, +.mud-navmenu .mud-nav-link.mud-nav-link-active .mud-nav-link-icon { + background-color: rgba(31, 182, 166, 0.24); + border-radius: 999px; + padding: 4px; } html[data-prehydrate-theme="dark"] { @@ -77,10 +103,23 @@ h1:focus { content: "An error has occurred." } +.provider-row-highlight { + background-color: rgba(31, 182, 166, 0.12) !important; + outline: 1px solid rgba(31, 182, 166, 0.4); +} + .darker-border-checkbox.form-check-input { border-color: #929292; } +/* When hovering the event? chip, highlight the group settings button in the same row */ +.ppv-settings-btn { + transition: color 0.15s ease; +} +.mud-paper:has(.ppv-hint-chip:hover) .ppv-settings-btn { + color: var(--mud-palette-warning) !important; +} + .form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { color: var(--bs-secondary-color); text-align: end; diff --git a/src/M3Undle.Web/wwwroot/app.js b/src/M3Undle.Web/wwwroot/app.js index 444a7bc..cb41e3a 100644 --- a/src/M3Undle.Web/wwwroot/app.js +++ b/src/M3Undle.Web/wwwroot/app.js @@ -1,3 +1,13 @@ +window.scrollToId = (id) => { + const el = document.getElementById(id); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); +}; + +window.scrollToClass = (className) => { + const el = document.querySelector('.' + className); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); +}; + window.scrollToBottom = (id) => { const el = document.getElementById(id); if (el) el.scrollTop = el.scrollHeight; @@ -35,6 +45,48 @@ window.m3undleCopyText = async (text) => { } }; +// Mapped channels panel: attaches drag-to-resize on the left edge handle. +// Returns a handle with a dispose() method to remove document listeners. +window.initMappingPanelResize = (handleId, panelId, contentId, minWidth, maxWidth) => { + const handle = document.getElementById(handleId); + if (!handle) return { dispose: () => {} }; + + let dragging = false; + + const onMouseDown = (e) => { + dragging = true; + e.preventDefault(); + }; + + const onMouseMove = (e) => { + if (!dragging) return; + const width = Math.max(minWidth, Math.min(maxWidth, window.innerWidth - e.clientX)); + const panel = document.getElementById(panelId); + const content = document.getElementById(contentId); + if (panel) panel.style.width = width + 'px'; + if (content) content.style.marginRight = width + 'px'; + }; + + const onMouseUp = (e) => { + if (!dragging) return; + dragging = false; + const width = Math.max(minWidth, Math.min(maxWidth, window.innerWidth - e.clientX)); + try { localStorage.setItem('m3undle:mapping-panel-width', width.toString()); } catch {} + }; + + handle.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + + return { + dispose: () => { + handle.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + }; +}; + // Attaches a scroll listener to the log container. // Calls dotnetRef.OnScrollPositionChanged(atBottom) whenever the user scrolls. // Returns an object with a dispose() method that removes the listener. diff --git a/src/WebVersion.props b/src/WebVersion.props index b5e9274..ffd48e6 100644 --- a/src/WebVersion.props +++ b/src/WebVersion.props @@ -1,6 +1,6 @@ - 1.0.0-alpha.6 + 1.0.0-alpha.7 1.0.0.0 1.0.0.0 diff --git a/tests/M3Undle.Core.Tests/AppBuildInfoTests.cs b/tests/M3Undle.Core.Tests/AppBuildInfoTests.cs index b237454..015c5d4 100644 --- a/tests/M3Undle.Core.Tests/AppBuildInfoTests.cs +++ b/tests/M3Undle.Core.Tests/AppBuildInfoTests.cs @@ -8,7 +8,7 @@ public sealed class AppBuildInfoTests [TestMethod] public void ToDisplayString_IncludesBuildNumber_WhenPresent() { - var buildInfo = new AppBuildInfo("test-version", "2026-03-27T12:00:00Z", "42"); + var buildInfo = new AppBuildInfo("test-version", "2026-03-27T12:00:00Z", "42", null); Assert.AreEqual( "test-version (build 42, built 2026-03-27T12:00:00Z)", diff --git a/tests/M3Undle.Web.Tests/Api/OpenApiEndpointTests.cs b/tests/M3Undle.Web.Tests/Api/OpenApiEndpointTests.cs index 6e3a09c..024dc98 100644 --- a/tests/M3Undle.Web.Tests/Api/OpenApiEndpointTests.cs +++ b/tests/M3Undle.Web.Tests/Api/OpenApiEndpointTests.cs @@ -63,6 +63,11 @@ public async Task Development_OpenApiDocument_IsExposedWithExpectedMetadata() } Assert.IsTrue(foundProvidersListOperation, "Expected tagged/summary metadata for providers list operation."); + Assert.IsTrue( + paths.TryGetProperty("/api/v1/settings/streaming", out var streamingSettingsPath) + && streamingSettingsPath.TryGetProperty("get", out _) + && streamingSettingsPath.TryGetProperty("put", out _), + "Expected streaming settings API operations in management OpenAPI."); } [TestMethod] diff --git a/tests/M3Undle.Web.Tests/Api/XtreamEndpointTests.cs b/tests/M3Undle.Web.Tests/Api/XtreamEndpointTests.cs index 7e02271..def6e2f 100644 --- a/tests/M3Undle.Web.Tests/Api/XtreamEndpointTests.cs +++ b/tests/M3Undle.Web.Tests/Api/XtreamEndpointTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using M3Undle.Web.Api; using M3Undle.Web.Application; using M3Undle.Web.Data; using M3Undle.Web.Security; @@ -62,6 +63,42 @@ public async Task GetPhp_PostFormCredentials_ReturnsM3uPlaylist() StringAssert.Contains(body, "Alpha"); } + [TestMethod] + public void BuildGeneratedXtreamHlsManifestRedirectUrl_UsesXtreamPathCredentials() + { + var context = CreateXtreamRouteContext( + scheme: "http", + host: "toontown-tv-srv1:8080", + pathBase: "/iptv", + username: "john@example.com", + password: "doe pass"); + + var url = XtreamEndpoints.BuildGeneratedXtreamHlsManifestRedirectUrl(context, "session 1"); + + Assert.AreEqual( + "/iptv/hls/generated/john%40example.com/doe%20pass/session%201/index.m3u8", + url); + Assert.IsFalse(url.Contains("username=", StringComparison.Ordinal)); + Assert.IsFalse(url.Contains("password=", StringComparison.Ordinal)); + } + + [TestMethod] + public void BuildGeneratedXtreamHlsAssetBaseUrl_UsesXtreamPathCredentials() + { + var context = CreateXtreamRouteContext( + scheme: "http", + host: "toontown-tv-srv1:8080", + pathBase: string.Empty, + username: "john@example.com", + password: "doe pass"); + + var url = XtreamEndpoints.BuildGeneratedXtreamHlsAssetBaseUrl(context, "session 1"); + + Assert.AreEqual( + "http://toontown-tv-srv1:8080/hls/generated/john%40example.com/doe%20pass/session%201", + url); + } + private sealed class XtreamApiFactory : WebApplicationFactory, IAsyncDisposable { private readonly string _tempDataDir = Path.Combine(Path.GetTempPath(), $"m3undle-xtream-endpoints-{Guid.NewGuid():N}"); @@ -103,6 +140,22 @@ public override async ValueTask DisposeAsync() } } + private static DefaultHttpContext CreateXtreamRouteContext( + string scheme, + string host, + string pathBase, + string username, + string password) + { + var context = new DefaultHttpContext(); + context.Request.Scheme = scheme; + context.Request.Host = HostString.FromUriComponent(host); + context.Request.PathBase = pathBase; + context.Request.RouteValues["xtreamUser"] = username; + context.Request.RouteValues["xtreamPass"] = password; + return context; + } + private sealed class StubAccessResolver : IAccessResolver { public ValueTask ResolveAsync(HttpContext context, CancellationToken cancellationToken) diff --git a/tests/M3Undle.Web.Tests/Application/ChannelMappingPageServiceTests.cs b/tests/M3Undle.Web.Tests/Application/ChannelMappingPageServiceTests.cs index 7729519..1eb9a77 100644 --- a/tests/M3Undle.Web.Tests/Application/ChannelMappingPageServiceTests.cs +++ b/tests/M3Undle.Web.Tests/Application/ChannelMappingPageServiceTests.cs @@ -313,6 +313,7 @@ public async ValueTask DisposeAsync() private sealed class TestRefreshTrigger : IRefreshTrigger { public bool IsRefreshing => false; + public DateTime? RefreshStartedAt => null; public bool TriggerRefresh() => true; public bool TriggerBuildOnly() => true; public void CancelRefresh() { } diff --git a/tests/M3Undle.Web.Tests/Application/EventServiceTests.cs b/tests/M3Undle.Web.Tests/Application/EventServiceTests.cs index 54aee9f..be4d174 100644 --- a/tests/M3Undle.Web.Tests/Application/EventServiceTests.cs +++ b/tests/M3Undle.Web.Tests/Application/EventServiceTests.cs @@ -155,16 +155,67 @@ public void BadgePresentation_MapsHighestSeverityToExpectedColor() Assert.AreEqual(Color.Error, SystemEventBadgePresentation.ColorFor(new SystemEventSummary(1, SystemEventSeverity.Error))); } + [TestMethod] + public void SystemEventPanelGrouping_CollapsesSameTypeAcrossAllTimeBuckets() + { + var now = new DateTime(2026, 05, 10, 12, 0, 0, DateTimeKind.Utc); + var events = new[] + { + NewEvent("latest", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddMinutes(-12)), + NewEvent("17h-a", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddHours(-17)), + NewEvent("17h-b", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddHours(-17).AddMinutes(-20)), + NewEvent("21h-a", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddHours(-21)), + NewEvent("21h-b", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddHours(-21).AddMinutes(-5)), + NewEvent("21h-c", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddHours(-21).AddMinutes(-10)), + NewEvent("21h-d", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddHours(-21).AddMinutes(-30)), + }; + + var grouped = SystemEventPanelGrouping.Group(events, now, SystemEventPanelGrouping.RelativeTime); + + Assert.HasCount(1, grouped); + Assert.AreEqual("12m ago", grouped[0].RelativeTime); // representative = newest (first in list) + Assert.AreEqual(7, grouped[0].TotalOccurrences); + Assert.AreEqual(7, grouped[0].EventIds.Count); + } + + [TestMethod] + public void SystemEventPanelGrouping_MergesSameTypeAcrossGapsKeepsDifferentTypeSeparate() + { + var now = new DateTime(2026, 05, 10, 12, 0, 0, DateTimeKind.Utc); + var events = new[] + { + NewEvent("restart-a", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddHours(-1)), + NewEvent("login", SystemEventTypes.LoginFailed, SystemEventSeverity.Warning, now.AddHours(-1)), + NewEvent("restart-b", SystemEventTypes.AppRestarted, SystemEventSeverity.Info, now.AddHours(-1)), + }; + + var grouped = SystemEventPanelGrouping.Group(events, now, SystemEventPanelGrouping.RelativeTime); + + Assert.HasCount(2, grouped); + var restartGroup = grouped.First(g => g.Event.EventType == SystemEventTypes.AppRestarted); + var loginGroup = grouped.First(g => g.Event.EventType == SystemEventTypes.LoginFailed); + Assert.AreEqual(2, restartGroup.TotalOccurrences); + Assert.AreEqual(1, loginGroup.TotalOccurrences); + CollectionAssert.AreEquivalent(new[] { "restart-a", "restart-b" }, restartGroup.EventIds.ToArray()); + } + private static SystemEvent NewEvent(string id, string eventType, SystemEventSeverity severity, DateTime occurredAt) => new() { SystemEventId = id, EventType = eventType, Severity = severity.ToString(), - Title = id, + Title = TitleFor(eventType), OccurredAt = occurredAt, OccurrenceCount = 1, }; + private static string TitleFor(string eventType) => eventType switch + { + SystemEventTypes.AppRestarted => "Application started (v1.0.0-alpha.7)", + SystemEventTypes.LoginFailed => "Login failed", + _ => eventType, + }; + private static async Task AssertThrowsAsync(Func action) where TException : Exception { diff --git a/tests/M3Undle.Web.Tests/Application/LineupStatusServiceTests.cs b/tests/M3Undle.Web.Tests/Application/LineupStatusServiceTests.cs index 5b895cd..9f1fe09 100644 --- a/tests/M3Undle.Web.Tests/Application/LineupStatusServiceTests.cs +++ b/tests/M3Undle.Web.Tests/Application/LineupStatusServiceTests.cs @@ -325,6 +325,53 @@ await SeedAsync(fixture.Connection, db => Assert.AreEqual(LineupSwitchStates.Requested, status.Lineup.SwitchState); } + [TestMethod] + public async Task GetStatusAsync_WhenActiveSnapshotHasNoLiveChannels_ReturnsNoActiveSnapshot() + { + await using var fixture = await CreateFixtureAsync(); + await SeedAsync(fixture.Connection, db => + { + var now = DateTime.UtcNow; + + db.Profiles.Add(MakeProfile("profile-active", "Active Profile", enabled: true, isActive: true, now)); + db.Providers.Add(MakeProvider("provider-active", "Provider Active", enabled: true, now)); + db.ProfileProviders.Add(new ProfileProvider + { + ProfileId = "profile-active", + ProviderId = "provider-active", + Priority = 1, + Enabled = true, + }); + + db.Snapshots.Add(new Snapshot + { + SnapshotId = "snapshot-vod-only", + ProfileId = "profile-active", + CreatedUtc = now, + Status = "active", + PlaylistPath = "playlist.m3u", + XmltvPath = "guide.xml", + ChannelIndexPath = "channels.json", + StatusJsonPath = "status.json", + ChannelCountPublished = 30, + LiveChannelCount = 0, + VodChannelCount = 20, + SeriesChannelCount = 10, + }); + }); + + var service = new LineupStatusService( + fixture.Services.GetRequiredService(), + new TestRefreshTrigger()); + + var status = await service.GetStatusAsync(CancellationToken.None); + + Assert.AreEqual(LineupStatusCodes.NoActiveSnapshot, status.Status); + Assert.IsNotNull(status.Lineup); + Assert.AreEqual(LineupStatusCodes.NoActiveSnapshot, status.Lineup.Status); + Assert.AreEqual("profile-active", status.Lineup.ActiveProfile?.ProfileId); + } + private static async Task SeedAsync(SqliteConnection connection, Action seed) { var options = new DbContextOptionsBuilder() @@ -394,6 +441,7 @@ private sealed class TestRefreshTrigger : IRefreshTrigger public bool IsRefreshingValue { get; set; } public bool IsRefreshing => IsRefreshingValue; + public DateTime? RefreshStartedAt => null; public bool TriggerRefresh() => true; diff --git a/tests/M3Undle.Web.Tests/Application/ProfilesPageServiceTests.cs b/tests/M3Undle.Web.Tests/Application/ProfilesPageServiceTests.cs index 22e43b0..9c84db2 100644 --- a/tests/M3Undle.Web.Tests/Application/ProfilesPageServiceTests.cs +++ b/tests/M3Undle.Web.Tests/Application/ProfilesPageServiceTests.cs @@ -339,6 +339,7 @@ private sealed class TestRefreshTrigger : IRefreshTrigger public int TriggerRefreshCallCount { get; private set; } public bool IsRefreshing => IsRefreshingValue; + public DateTime? RefreshStartedAt => null; public bool TriggerRefresh() { diff --git a/tests/M3Undle.Web.Tests/Application/ProviderPageServiceValidationTests.cs b/tests/M3Undle.Web.Tests/Application/ProviderPageServiceValidationTests.cs index f0f2352..ae32cdd 100644 --- a/tests/M3Undle.Web.Tests/Application/ProviderPageServiceValidationTests.cs +++ b/tests/M3Undle.Web.Tests/Application/ProviderPageServiceValidationTests.cs @@ -276,6 +276,7 @@ public async ValueTask DisposeAsync() private sealed class TestRefreshTrigger(bool isRefreshing = false) : IRefreshTrigger { public bool IsRefreshing { get; private set; } = isRefreshing; + public DateTime? RefreshStartedAt => null; public bool TriggerRefresh() { IsRefreshing = true; diff --git a/tests/M3Undle.Web.Tests/Authentication/ClientEndpointAccessResolverTests.cs b/tests/M3Undle.Web.Tests/Authentication/ClientEndpointAccessResolverTests.cs index 277a59e..f025f1e 100644 --- a/tests/M3Undle.Web.Tests/Authentication/ClientEndpointAccessResolverTests.cs +++ b/tests/M3Undle.Web.Tests/Authentication/ClientEndpointAccessResolverTests.cs @@ -208,9 +208,10 @@ private static ClientEndpointAccessResolver BuildResolver( private sealed class StubEndpointSecurityService(bool enabled, EndpointBindingState? binding = null) : IEndpointSecurityService { public ValueTask IsEnabledAsync(CancellationToken cancellationToken) => ValueTask.FromResult(enabled); + public ValueTask IsXtreamEnabledAsync(CancellationToken cancellationToken) => ValueTask.FromResult(true); public Task GetSettingsAsync(CancellationToken cancellationToken) - => Task.FromResult(new EndpointSecuritySettings(enabled, null, false, null, null)); + => Task.FromResult(new EndpointSecuritySettings(enabled, null, false, null, null, XtreamCompatibilityEnabled: true)); public Task GetBindingAsync(string credentialId, CancellationToken cancellationToken) => Task.FromResult(binding); @@ -219,7 +220,7 @@ public Task UpdateAsync(UpdateEndpointSecurityComm => Task.FromResult(new EndpointSecurityUpdateResult( Succeeded: true, Error: null, - Settings: new EndpointSecuritySettings(false, null, false, null, null))); + Settings: new EndpointSecuritySettings(false, null, false, null, null, XtreamCompatibilityEnabled: true))); } private sealed class StubCredentialValidator(AccessCredential? credential = null) : ICredentialValidator diff --git a/tests/M3Undle.Web.Tests/Authentication/EndpointSecurityServiceTests.cs b/tests/M3Undle.Web.Tests/Authentication/EndpointSecurityServiceTests.cs index 4e261f1..9a8f355 100644 --- a/tests/M3Undle.Web.Tests/Authentication/EndpointSecurityServiceTests.cs +++ b/tests/M3Undle.Web.Tests/Authentication/EndpointSecurityServiceTests.cs @@ -29,7 +29,8 @@ public async Task UpdateAsync_EnableSecurity_CreatesCredentialAndBinding() Username: "iptv-user", Password: "secret-pass", ActiveProfileId: null, - VirtualTunerId: "tuner-a"), CancellationToken.None); + VirtualTunerId: "tuner-a", + XtreamCompatibilityEnabled: true), CancellationToken.None); Assert.IsTrue(result.Succeeded, result.Error); @@ -64,14 +65,16 @@ await service.UpdateAsync(new UpdateEndpointSecurityCommand( Username: "original-user", Password: "original-pass", ActiveProfileId: null, - VirtualTunerId: null), CancellationToken.None); + VirtualTunerId: null, + XtreamCompatibilityEnabled: true), CancellationToken.None); var result = await service.UpdateAsync(new UpdateEndpointSecurityCommand( Enabled: true, Username: "updated-user", Password: "updated-pass", ActiveProfileId: null, - VirtualTunerId: null), CancellationToken.None); + VirtualTunerId: null, + XtreamCompatibilityEnabled: true), CancellationToken.None); Assert.IsTrue(result.Succeeded, result.Error); Assert.AreEqual("updated-user", result.Settings.Username); @@ -122,7 +125,8 @@ public async Task UpdateAsync_WhenMultipleCredentialsExist_ReturnsFail() Username: null, Password: null, ActiveProfileId: null, - VirtualTunerId: null), CancellationToken.None); + VirtualTunerId: null, + XtreamCompatibilityEnabled: true), CancellationToken.None); Assert.IsFalse(result.Succeeded); Assert.IsTrue(result.Error!.Contains("Multiple", StringComparison.OrdinalIgnoreCase)); diff --git a/tests/M3Undle.Web.Tests/Persistence/Alpha5SchemaMigrationTests.cs b/tests/M3Undle.Web.Tests/Persistence/Alpha5SchemaMigrationTests.cs deleted file mode 100644 index f8e79e6..0000000 --- a/tests/M3Undle.Web.Tests/Persistence/Alpha5SchemaMigrationTests.cs +++ /dev/null @@ -1,144 +0,0 @@ -using M3Undle.Web.Data; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace M3Undle.Web.Tests.Persistence; - -[TestClass] -public sealed class Alpha5SchemaMigrationTests -{ - private const string PreAlpha5SchemaMigration = "20260314145015_Alpha4_Schema"; - private const string Alpha5SchemaMigration = "20260322000000_Alpha5_Schema"; - - [TestMethod] - public async Task Alpha5SchemaMigration_BackfillsActiveProfile_FromLegacyProviderActiveLink() - { - await using var connection = new SqliteConnection("Data Source=:memory:"); - await connection.OpenAsync(); - await using var db = CreateDb(connection); - var migrator = db.Database.GetService(); - - await migrator.MigrateAsync(PreAlpha5SchemaMigration); - - var now = DateTime.UtcNow; - await db.Database.ExecuteSqlRawAsync( - """ - INSERT INTO profiles (profile_id, name, enabled, output_name, merge_mode, created_utc, updated_utc) - VALUES ({0}, {1}, 1, {2}, {3}, {4}, {4}); - """, - "profile-1", "Profile 1", "m3undle", "replace", now); - - await db.Database.ExecuteSqlRawAsync( - """ - INSERT INTO providers (provider_id, name, playlist_url, enabled, is_active, needs_env_var_substitution, timeout_seconds, created_utc, updated_utc) - VALUES ({0}, {1}, {2}, 1, 1, 0, 20, {3}, {3}); - """, - "provider-1", "Provider 1", "http://example.com/playlist.m3u", now); - - await db.Database.ExecuteSqlRawAsync( - """ - INSERT INTO profile_providers (profile_id, provider_id, priority, enabled) - VALUES ({0}, {1}, 1, 1); - """, - "profile-1", "provider-1"); - - await migrator.MigrateAsync(Alpha5SchemaMigration); - - var activeProfileId = await db.Profiles - .AsNoTracking() - .Where(x => x.IsActive) - .Select(x => x.ProfileId) - .SingleOrDefaultAsync(); - Assert.AreEqual("profile-1", activeProfileId); - - await using var command = connection.CreateCommand(); - command.CommandText = "SELECT COUNT(1) FROM pragma_table_info('providers') WHERE name = 'is_active'"; - var count = Convert.ToInt32(await command.ExecuteScalarAsync()); - Assert.AreEqual(0, count); - } - - [TestMethod] - public async Task Alpha5SchemaMigration_EnforcesSingleActiveProfile_UniquePartialIndex() - { - await using var connection = new SqliteConnection("Data Source=:memory:"); - await connection.OpenAsync(); - await using var db = CreateDb(connection); - var migrator = db.Database.GetService(); - - await migrator.MigrateAsync(Alpha5SchemaMigration); - - var now = DateTime.UtcNow; - await db.Database.ExecuteSqlRawAsync( - """ - INSERT INTO profiles (profile_id, name, enabled, is_active, output_name, merge_mode, created_utc, updated_utc) - VALUES ({0}, {1}, 1, 1, {2}, {3}, {4}, {4}); - """, - "profile-1", "Profile 1", "m3undle", "replace", now); - - try - { - await db.Database.ExecuteSqlRawAsync( - """ - INSERT INTO profiles (profile_id, name, enabled, is_active, output_name, merge_mode, created_utc, updated_utc) - VALUES ({0}, {1}, 1, 1, {2}, {3}, {4}, {4}); - """, - "profile-2", "Profile 2", "m3undle", "replace", now); - Assert.Fail("Expected SqliteException for duplicate active profile."); - } - catch (SqliteException) - { - // Expected path. - } - } - - [TestMethod] - public async Task StartupRepair_RecoversPartialAlpha5Schema_BeforeMigrateRetries() - { - await using var connection = new SqliteConnection("Data Source=:memory:"); - await connection.OpenAsync(); - await using var db = CreateDb(connection); - var migrator = db.Database.GetService(); - - await migrator.MigrateAsync(PreAlpha5SchemaMigration); - - await db.Database.ExecuteSqlRawAsync("DROP INDEX \"idx_providers_is_active\";"); - await db.Database.ExecuteSqlRawAsync("ALTER TABLE \"providers\" ADD COLUMN \"force_mpegts\" INTEGER NOT NULL DEFAULT 0;"); - await db.Database.ExecuteSqlRawAsync("ALTER TABLE \"snapshots\" ADD COLUMN \"change_class\" TEXT NULL;"); - await db.Database.ExecuteSqlRawAsync("ALTER TABLE \"site_settings\" ADD COLUMN \"generated_hls_enabled\" INTEGER NOT NULL DEFAULT 1;"); - await db.Database.ExecuteSqlRawAsync("ALTER TABLE \"site_settings\" ADD COLUMN \"generated_hls_ffmpeg_path\" TEXT NULL;"); - await db.Database.ExecuteSqlRawAsync("ALTER TABLE \"site_settings\" ADD COLUMN \"generated_hls_settings_restart_required\" INTEGER NOT NULL DEFAULT 0;"); - await db.Database.ExecuteSqlRawAsync("ALTER TABLE \"site_settings\" ADD COLUMN \"hdhr_advertised_base_url\" TEXT NULL;"); - - await StartupMigrationRepair.RepairAlpha5PartialSchemaAsync(db); - await migrator.MigrateAsync(); - - await using var historyCommand = connection.CreateCommand(); - historyCommand.CommandText = """ - SELECT COUNT(*) - FROM "__EFMigrationsHistory" - WHERE "MigrationId" = '20260322000000_Alpha5_Schema'; - """; - Assert.AreEqual(1, Convert.ToInt32(await historyCommand.ExecuteScalarAsync())); - - await using var columnCommand = connection.CreateCommand(); - columnCommand.CommandText = "SELECT COUNT(*) FROM pragma_table_info('site_settings') WHERE name = 'hdhr_advertised_base_url';"; - Assert.AreEqual(1, Convert.ToInt32(await columnCommand.ExecuteScalarAsync())); - - await using var tableCommand = connection.CreateCommand(); - tableCommand.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'profile_custom_groups';"; - Assert.AreEqual(1, Convert.ToInt32(await tableCommand.ExecuteScalarAsync())); - } - - private static ApplicationDbContext CreateDb(SqliteConnection connection) - { - var options = new DbContextOptionsBuilder() - .UseSqlite(connection) - .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) - .Options; - return new ApplicationDbContext(options); - } -} diff --git a/tests/M3Undle.Web.Tests/Persistence/MigrationBaselineTests.cs b/tests/M3Undle.Web.Tests/Persistence/MigrationBaselineTests.cs new file mode 100644 index 0000000..35090dd --- /dev/null +++ b/tests/M3Undle.Web.Tests/Persistence/MigrationBaselineTests.cs @@ -0,0 +1,40 @@ +using M3Undle.Web.Data; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace M3Undle.Web.Tests.Persistence; + +[TestClass] +public sealed class MigrationBaselineTests +{ + [TestMethod] + public async Task ApplicationDbContext_HasSingleAlphaMigrationBaseline() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + await using var db = CreateDb(connection); + + var migrations = db.Database.GetService().Migrations; + Assert.AreEqual(1, migrations.Count); + Assert.IsTrue(migrations.Single().Key.EndsWith("_Alpha_Schema", StringComparison.Ordinal)); + + await db.Database.MigrateAsync(); + + await using var historyCommand = connection.CreateCommand(); + historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\";"; + Assert.AreEqual(1, Convert.ToInt32(await historyCommand.ExecuteScalarAsync())); + } + + private static ApplicationDbContext CreateDb(SqliteConnection connection) + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) + .Options; + return new ApplicationDbContext(options); + } +} diff --git a/tests/M3Undle.Web.Tests/Settings/HdHomeRunSettingsServiceTests.cs b/tests/M3Undle.Web.Tests/Settings/HdHomeRunSettingsServiceTests.cs index 29c96b6..7b58814 100644 --- a/tests/M3Undle.Web.Tests/Settings/HdHomeRunSettingsServiceTests.cs +++ b/tests/M3Undle.Web.Tests/Settings/HdHomeRunSettingsServiceTests.cs @@ -20,13 +20,12 @@ public async Task GetSettingsAsync_ReturnsDefaultTunerCountWhenNoOverrideSet() Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory); // TunerCount option is 4 — no override in DB var tunerResolver = new HdHomeRunTunerCountResolver(Options.Create(new HdHomeRunOptions { TunerCount = 4 }), fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); var state = await service.GetSettingsAsync(); Assert.IsNull(state.Saved.TunerCountOverride, "No override should be saved by default."); @@ -39,12 +38,11 @@ public async Task UpdateAsync_SetsTunerCountOverride_AndReadsBackCorrectly() Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory); var tunerResolver = new HdHomeRunTunerCountResolver(Options.Create(new HdHomeRunOptions { TunerCount = 4 }), fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); var result = await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, @@ -53,7 +51,8 @@ public async Task UpdateAsync_SetsTunerCountOverride_AndReadsBackCorrectly() AdvertisedBaseUrl: null, DiscoveryEnabled: true, SsdpEnabled: false, - SiliconDustDiscoveryEnabled: false)); + SiliconDustDiscoveryEnabled: false, + AllowedNetworks: null)); Assert.IsTrue(result.Succeeded); Assert.IsNull(result.Error); @@ -71,24 +70,25 @@ public async Task UpdateAsync_RejectsInvalidTunerCountOverride() Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory); var tunerResolver = new HdHomeRunTunerCountResolver(Options.Create(new HdHomeRunOptions { TunerCount = 4 }), fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); var tooLow = await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, TunerCountOverride: 0, FriendlyName: null, AdvertisedBaseUrl: null, DiscoveryEnabled: true, - SsdpEnabled: false, SiliconDustDiscoveryEnabled: false)); + SsdpEnabled: false, SiliconDustDiscoveryEnabled: false, + AllowedNetworks: null)); Assert.IsFalse(tooLow.Succeeded, "Override of 0 should be rejected."); var tooHigh = await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, TunerCountOverride: 33, FriendlyName: null, AdvertisedBaseUrl: null, DiscoveryEnabled: true, - SsdpEnabled: false, SiliconDustDiscoveryEnabled: false)); + SsdpEnabled: false, SiliconDustDiscoveryEnabled: false, + AllowedNetworks: null)); Assert.IsFalse(tooHigh.Succeeded, "Override of 33 should be rejected."); } @@ -99,24 +99,25 @@ public async Task UpdateAsync_ClearsTunerCountOverride_WhenSetToNull() Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory); var tunerResolver = new HdHomeRunTunerCountResolver(Options.Create(new HdHomeRunOptions { TunerCount = 4 }), fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); // Set an override first await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, TunerCountOverride: 2, FriendlyName: null, AdvertisedBaseUrl: null, DiscoveryEnabled: true, - SsdpEnabled: false, SiliconDustDiscoveryEnabled: false)); + SsdpEnabled: false, SiliconDustDiscoveryEnabled: false, + AllowedNetworks: null)); // Clear it var result = await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, TunerCountOverride: null, FriendlyName: null, AdvertisedBaseUrl: null, DiscoveryEnabled: true, - SsdpEnabled: false, SiliconDustDiscoveryEnabled: false)); + SsdpEnabled: false, SiliconDustDiscoveryEnabled: false, + AllowedNetworks: null)); Assert.IsTrue(result.Succeeded); Assert.IsNull(result.Settings.TunerCountOverride, "Override should be cleared."); @@ -128,11 +129,10 @@ public async Task UpdateAsync_SetsFriendlyName_AndReadsBackCorrectly() Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory); var tunerResolver = new HdHomeRunTunerCountResolver(Options.Create(new HdHomeRunOptions { TunerCount = 4 }), fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); var result = await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, @@ -141,7 +141,8 @@ public async Task UpdateAsync_SetsFriendlyName_AndReadsBackCorrectly() AdvertisedBaseUrl: null, DiscoveryEnabled: true, SsdpEnabled: true, - SiliconDustDiscoveryEnabled: true)); + SiliconDustDiscoveryEnabled: true, + AllowedNetworks: null)); Assert.IsTrue(result.Succeeded); Assert.AreEqual("My IPTV Box", result.Settings.FriendlyName); @@ -157,22 +158,23 @@ public async Task UpdateAsync_ClearsFriendlyName_WhenSetToNull() Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var options = Options.Create(new HdHomeRunOptions { TunerCount = 4, FriendlyName = "Configured Default" }); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory, options); var tunerResolver = new HdHomeRunTunerCountResolver(options, fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, TunerCountOverride: null, FriendlyName: "Override Name", AdvertisedBaseUrl: null, DiscoveryEnabled: true, - SsdpEnabled: true, SiliconDustDiscoveryEnabled: true)); + SsdpEnabled: true, SiliconDustDiscoveryEnabled: true, + AllowedNetworks: null)); var result = await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, TunerCountOverride: null, FriendlyName: null, AdvertisedBaseUrl: null, DiscoveryEnabled: true, - SsdpEnabled: true, SiliconDustDiscoveryEnabled: true)); + SsdpEnabled: true, SiliconDustDiscoveryEnabled: true, + AllowedNetworks: null)); Assert.IsTrue(result.Succeeded); Assert.IsNull(result.Settings.FriendlyName, "DB override should be cleared."); @@ -185,17 +187,17 @@ public async Task UpdateAsync_RejectsFriendlyNameExceeding128Characters() Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory); var tunerResolver = new HdHomeRunTunerCountResolver(Options.Create(new HdHomeRunOptions { TunerCount = 4 }), fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); var result = await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, TunerCountOverride: null, FriendlyName: new string('A', 129), AdvertisedBaseUrl: null, DiscoveryEnabled: true, - SsdpEnabled: true, SiliconDustDiscoveryEnabled: true)); + SsdpEnabled: true, SiliconDustDiscoveryEnabled: true, + AllowedNetworks: null)); Assert.IsFalse(result.Succeeded, "FriendlyName over 128 characters should be rejected."); Assert.IsNotNull(result.Error); @@ -207,12 +209,11 @@ public async Task GetSettingsAsync_ResolvedFriendlyName_FallsBackToConfiguredOpt Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var options = Options.Create(new HdHomeRunOptions { TunerCount = 4, FriendlyName = "Custom Config Name" }); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory, options); var tunerResolver = new HdHomeRunTunerCountResolver(options, fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); var state = await service.GetSettingsAsync(); @@ -229,11 +230,10 @@ public async Task UpdateAsync_RejectsLoopbackAdvertisedBaseUrl() Environment.SetEnvironmentVariable("M3UNDLE_HDHR_ENABLED", null); await using var fixture = await CreateFixtureAsync(); - await using var db = fixture.CreateDbContext(); var deviceService = CreateDeviceService(fixture.ScopeFactory, fixture.TempDataDirectory); var tunerResolver = new HdHomeRunTunerCountResolver(Options.Create(new HdHomeRunOptions { TunerCount = 4 }), fixture.ScopeFactory); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); var loopbackUrls = new[] { @@ -248,7 +248,8 @@ public async Task UpdateAsync_RejectsLoopbackAdvertisedBaseUrl() var result = await service.UpdateAsync(new UpdateHdhrSettingsCommand( Enabled: true, TunerCountOverride: null, FriendlyName: null, AdvertisedBaseUrl: url, DiscoveryEnabled: true, - SsdpEnabled: true, SiliconDustDiscoveryEnabled: true)); + SsdpEnabled: true, SiliconDustDiscoveryEnabled: true, + AllowedNetworks: null)); Assert.IsFalse(result.Succeeded, $"Loopback URL '{url}' should be rejected."); Assert.IsNotNull(result.Error, $"Error message expected for '{url}'."); @@ -284,7 +285,7 @@ public async Task GetSettingsAsync_AppliedSnapshot_UsesStartupLatchedRuntimeValu settings.HdhrTunerCountOverride = 7; await db.SaveChangesAsync(); - var service = new HdHomeRunSettingsService(db, deviceService, tunerResolver); + var service = new HdHomeRunSettingsService(fixture.ScopeFactory, deviceService, tunerResolver); var state = await service.GetSettingsAsync(); Assert.IsFalse(state.Saved.Enabled); diff --git a/tests/M3Undle.Web.Tests/Settings/StreamingOptionsValidatorTests.cs b/tests/M3Undle.Web.Tests/Settings/StreamingOptionsValidatorTests.cs index e098476..19d650f 100644 --- a/tests/M3Undle.Web.Tests/Settings/StreamingOptionsValidatorTests.cs +++ b/tests/M3Undle.Web.Tests/Settings/StreamingOptionsValidatorTests.cs @@ -141,6 +141,22 @@ public void ReconnectOptions_ConnectTimeoutZero_Fails() Assert.IsFalse(result.Succeeded); } + [TestMethod] + public void ReconnectOptions_RecoveryOutputHoldLimitZero_Fails() + { + var options = new ReconnectOptions { RecoveryOutputHoldLimit = TimeSpan.Zero }; + var result = new ReconnectOptionsValidator().Validate(null, options); + Assert.IsFalse(result.Succeeded); + } + + [TestMethod] + public void ReconnectOptions_RecoverySafeStartSearchLimitBelowPacketSize_Fails() + { + var options = new ReconnectOptions { RecoverySafeStartSearchLimitBytes = 187 }; + var result = new ReconnectOptionsValidator().Validate(null, options); + Assert.IsFalse(result.Succeeded); + } + // ── GeneratedHlsOptionsValidator ────────────────────────────────────────── [TestMethod] diff --git a/tests/M3Undle.Web.Tests/Snapshots/SnapshotHandlingTests.cs b/tests/M3Undle.Web.Tests/Snapshots/SnapshotHandlingTests.cs index 9f53d5d..cfa30f9 100644 --- a/tests/M3Undle.Web.Tests/Snapshots/SnapshotHandlingTests.cs +++ b/tests/M3Undle.Web.Tests/Snapshots/SnapshotHandlingTests.cs @@ -418,6 +418,153 @@ public async Task SnapshotBuilder_FetchSucceeds_PromotesSnapshotToActive() } } + [TestMethod] + public async Task SnapshotBuilder_InitialProviderSync_BaselinesReviewState() + { + await using var fixture = await CreateFixtureAsync(); + + await using (var setup = fixture.CreateDbContext()) + { + setup.Profiles.Add(NewProfile("profile-1")); + setup.Providers.Add(NewProvider("provider-1")); + setup.ProfileProviders.Add(NewProfileProvider("provider-1", "profile-1")); + await setup.SaveChangesAsync(); + } + + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + await using (var db = fixture.CreateDbContext()) + { + await CreateBuilder(db, HttpStatusCode.OK, SampleM3u, tempDir).RunAsync(CancellationToken.None); + } + + await using var verify = fixture.CreateDbContext(); + var filter = await verify.ProfileGroupFilters + .Include(x => x.ProviderGroup) + .SingleAsync(x => x.ProfileId == "profile-1" && x.ProviderGroup.RawName == "News"); + + Assert.IsFalse(filter.IsNew); + Assert.AreEqual(LineupReviewSemantics.GroupModeManualReview, filter.ChannelMode); + Assert.AreEqual(LineupReviewSemantics.TrackingPolicyReview, filter.TrackingPolicy); + Assert.IsFalse(filter.TrackNewChannels, "TrackNewChannels should default to false so no channel rows are created until user opts in."); + + // With TrackNewChannels = false (default), no channel rows should be created. + var rows = await verify.ProfileGroupChannelFilters + .Where(x => x.ProfileGroupFilterId == filter.ProfileGroupFilterId) + .ToListAsync(); + + Assert.HasCount(0, rows); + Assert.AreEqual(0, await verify.ProfileGroupChannelFilters + .CountAsync(x => x.State == LineupReviewSemantics.ChannelStatePending)); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + + [TestMethod] + public async Task SnapshotBuilder_SubsequentRefresh_QueuesOnlyNewGroupsAndChannels() + { + await using var fixture = await CreateFixtureAsync(); + + await using (var setup = fixture.CreateDbContext()) + { + setup.Profiles.Add(NewProfile("profile-1")); + setup.Providers.Add(NewProvider("provider-1")); + setup.ProfileProviders.Add(NewProfileProvider("provider-1", "profile-1")); + await setup.SaveChangesAsync(); + } + + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + await using (var db1 = fixture.CreateDbContext()) + { + await CreateBuilder(db1, HttpStatusCode.OK, SampleM3u, tempDir).RunAsync(CancellationToken.None); + } + + await using (var db2 = fixture.CreateDbContext()) + { + await CreateBuilder(db2, HttpStatusCode.OK, SampleNewGroupAndChannelM3u, tempDir).RunAsync(CancellationToken.None); + } + + await using var verify = fixture.CreateDbContext(); + var filters = await verify.ProfileGroupFilters + .Include(x => x.ProviderGroup) + .Where(x => x.ProfileId == "profile-1") + .ToListAsync(); + + Assert.IsFalse(filters.Single(x => x.ProviderGroup.RawName == "News").IsNew); + Assert.IsTrue(filters.Single(x => x.ProviderGroup.RawName == "Sports").IsNew); + + // With TrackNewChannels = false (default on all groups), no channel rows are queued. + var rows = await verify.ProfileGroupChannelFilters.ToListAsync(); + Assert.HasCount(0, rows); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + + [TestMethod] + public async Task SnapshotBuilder_RefreshesActiveProfileProvidersBeforeStandbyProviders() + { + await using var fixture = await CreateFixtureAsync(); + + await using (var setup = fixture.CreateDbContext()) + { + var activeProfile = NewProfile("profile-active"); + activeProfile.IsActive = true; + + var standbyProvider = NewProvider("provider-standby"); + standbyProvider.Name = "aaa-standby"; + standbyProvider.PlaylistUrl = "http://standby.example/playlist.m3u"; + standbyProvider.XmltvUrl = "http://standby.example/xmltv.xml"; + + var activeProvider = NewProvider("provider-active"); + activeProvider.Name = "zzz-active"; + activeProvider.PlaylistUrl = "http://active.example/playlist.m3u"; + activeProvider.XmltvUrl = "http://active.example/xmltv.xml"; + + var unlinkedProvider = NewProvider("provider-unlinked"); + unlinkedProvider.Name = "aaa-unlinked"; + unlinkedProvider.PlaylistUrl = "http://unlinked.example/playlist.m3u"; + unlinkedProvider.XmltvUrl = "http://unlinked.example/xmltv.xml"; + + setup.Profiles.AddRange( + activeProfile, + NewProfile("profile-standby")); + setup.Providers.AddRange( + standbyProvider, + activeProvider, + unlinkedProvider); + setup.ProfileProviders.AddRange( + NewProfileProvider("provider-standby", "profile-standby"), + NewProfileProvider("provider-active", "profile-active")); + await setup.SaveChangesAsync(); + } + + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + try + { + var handler = new TrackingHttpMessageHandler(); + await using var db = fixture.CreateDbContext(); + var builder = CreateBuilder(db, HttpStatusCode.OK, SampleM3u, tempDir, handler: handler); + await builder.RunAsync(CancellationToken.None); + + CollectionAssert.AreEqual( + new[] { "active.example", "standby.example" }, + handler.PlaylistRequestHosts); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); + } + } + [TestMethod] public async Task SnapshotBuilder_ProviderBackOnline_PublishesOnlyOnceForCurrentFailure() { @@ -607,13 +754,25 @@ public async Task SnapshotBuilder_IncludesVodAndSeries_WhenEnabled_EvenIfGroupsP seriesFilter.Decision = "exclude"; seriesFilter.UpdatedUtc = DateTime.UtcNow; - var newsChannels = await edit.ProfileGroupChannelFilters - .Where(x => x.ProfileGroupFilterId == newsFilter.ProfileGroupFilterId) + // Since TrackNewChannels = false by default, no channel rows were auto-created. + // Directly insert included rows for the live news channels. + var nowEdit = DateTime.UtcNow; + var newsChannelIds = await edit.ProviderChannels + .AsNoTracking() + .Where(x => x.ProviderGroupId == newsFilter.ProviderGroupId && x.Active && x.ContentType == "live") + .Select(x => x.ProviderChannelId) .ToListAsync(); - foreach (var cf in newsChannels) + foreach (var channelId in newsChannelIds) { - cf.State = LineupReviewSemantics.ChannelStateIncluded; - cf.UpdatedUtc = DateTime.UtcNow; + edit.ProfileGroupChannelFilters.Add(new ProfileGroupChannelFilter + { + ProfileGroupChannelFilterId = Guid.NewGuid().ToString(), + ProfileGroupFilterId = newsFilter.ProfileGroupFilterId, + ProviderChannelId = channelId, + State = LineupReviewSemantics.ChannelStateIncluded, + CreatedUtc = nowEdit, + UpdatedUtc = nowEdit, + }); } await edit.SaveChangesAsync(); @@ -702,13 +861,25 @@ public async Task SnapshotBuilder_PreservesExactDuplicateLiveRows() filter.IsNew = false; filter.UpdatedUtc = DateTime.UtcNow; - var foxChannels = await edit.ProfileGroupChannelFilters - .Where(x => x.ProfileGroupFilterId == filter.ProfileGroupFilterId) + // Since TrackNewChannels = false by default, no channel rows were auto-created. + // Directly insert included rows for the live fox channels. + var nowEdit = DateTime.UtcNow; + var foxChannelIds = await edit.ProviderChannels + .AsNoTracking() + .Where(x => x.ProviderGroupId == filter.ProviderGroupId && x.Active && x.ContentType == "live") + .Select(x => x.ProviderChannelId) .ToListAsync(); - foreach (var cf in foxChannels) + foreach (var channelId in foxChannelIds) { - cf.State = LineupReviewSemantics.ChannelStateIncluded; - cf.UpdatedUtc = DateTime.UtcNow; + edit.ProfileGroupChannelFilters.Add(new ProfileGroupChannelFilter + { + ProfileGroupChannelFilterId = Guid.NewGuid().ToString(), + ProfileGroupFilterId = filter.ProfileGroupFilterId, + ProviderChannelId = channelId, + State = LineupReviewSemantics.ChannelStateIncluded, + CreatedUtc = nowEdit, + UpdatedUtc = nowEdit, + }); } await edit.SaveChangesAsync(); @@ -761,19 +932,31 @@ public async Task SnapshotBuilder_PreservesSameChannelAcrossDifferentGroups() .Where(x => x.ProfileId == "profile-1" && (x.ProviderGroup.RawName == "USA FOX" || x.ProviderGroup.RawName == "USA ABC")) .ToListAsync(); + // Since TrackNewChannels = false by default, no channel rows were auto-created. + // Directly insert included rows for each group's live channels. + var nowEdit = DateTime.UtcNow; foreach (var filter in filters) { filter.Decision = LineupReviewSemantics.GroupDecisionInclude; filter.IsNew = false; - filter.UpdatedUtc = DateTime.UtcNow; + filter.UpdatedUtc = nowEdit; - var existingChannelFilters = await edit.ProfileGroupChannelFilters - .Where(x => x.ProfileGroupFilterId == filter.ProfileGroupFilterId) + var channelIds = await edit.ProviderChannels + .AsNoTracking() + .Where(x => x.ProviderGroupId == filter.ProviderGroupId && x.Active && x.ContentType == "live") + .Select(x => x.ProviderChannelId) .ToListAsync(); - foreach (var cf in existingChannelFilters) + foreach (var channelId in channelIds) { - cf.State = LineupReviewSemantics.ChannelStateIncluded; - cf.UpdatedUtc = DateTime.UtcNow; + edit.ProfileGroupChannelFilters.Add(new ProfileGroupChannelFilter + { + ProfileGroupChannelFilterId = Guid.NewGuid().ToString(), + ProfileGroupFilterId = filter.ProfileGroupFilterId, + ProviderChannelId = channelId, + State = LineupReviewSemantics.ChannelStateIncluded, + CreatedUtc = nowEdit, + UpdatedUtc = nowEdit, + }); } } @@ -989,6 +1172,15 @@ await builder.BuildOnlyAsync( "#EXTINF:-1 tvg-id=\"cnn.us\" tvg-name=\"CNN\" group-title=\"News\",CNN US HD\n" + "http://example.com/stream/cnn\n"; + private const string SampleNewGroupAndChannelM3u = + "#EXTM3U\n" + + "#EXTINF:-1 tvg-id=\"cnn.us\" tvg-name=\"CNN\" group-title=\"News\",CNN US\n" + + "http://example.com/stream/cnn\n" + + "#EXTINF:-1 tvg-id=\"cnn.intl\" tvg-name=\"CNN International\" group-title=\"News\",CNN International\n" + + "http://example.com/stream/cnn-intl\n" + + "#EXTINF:-1 tvg-id=\"espn.us\" tvg-name=\"ESPN\" group-title=\"Sports\",ESPN\n" + + "http://example.com/stream/espn\n"; + private const string SampleMixedM3u = "#EXTM3U\n" + "#EXTINF:-1 group-title=\"News\",Live News\n" + @@ -1024,10 +1216,11 @@ private static SnapshotBuilder CreateBuilder( HttpStatusCode statusCode, string content, string tempDir, - IEventService? eventService = null) + IEventService? eventService = null, + HttpMessageHandler? handler = null) { - var handler = new FakeHttpMessageHandler(statusCode, content); - var factory = new FakeHttpClientFactory(handler); + var effectiveHandler = handler ?? new FakeHttpMessageHandler(statusCode, content); + var factory = new FakeHttpClientFactory(effectiveHandler); var envSvc = new EnvironmentVariableService(NullLogger.Instance); var fetcher = new ProviderFetcher( factory, @@ -1226,6 +1419,30 @@ protected override Task SendAsync(HttpRequestMessage reques } } + private sealed class TrackingHttpMessageHandler : HttpMessageHandler + { + public List PlaylistRequestHosts { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (request.RequestUri?.AbsolutePath.EndsWith("/playlist.m3u", StringComparison.OrdinalIgnoreCase) == true) + PlaylistRequestHosts.Add(request.RequestUri.Host); + + var content = request.RequestUri?.AbsolutePath.EndsWith("/xmltv.xml", StringComparison.OrdinalIgnoreCase) == true + ? "" + : request.RequestUri?.AbsolutePath.EndsWith("/playlist.m3u", StringComparison.OrdinalIgnoreCase) == true + ? SampleM3u + : "{}"; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(content), + }); + } + } + private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); diff --git a/tests/M3Undle.Web.Tests/Status/ReadinessEndpointTests.cs b/tests/M3Undle.Web.Tests/Status/ReadinessEndpointTests.cs index 03bed9a..c80835a 100644 --- a/tests/M3Undle.Web.Tests/Status/ReadinessEndpointTests.cs +++ b/tests/M3Undle.Web.Tests/Status/ReadinessEndpointTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text.Json; +using M3Undle.Web.Application; using M3Undle.Web.Data; using M3Undle.Web.Data.Entities; using M3Undle.Web.Tests.TestSupport; @@ -10,6 +11,7 @@ using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace M3Undle.Web.Tests.Status; @@ -27,6 +29,8 @@ public async Task HealthReady_WhenNoActiveProfile_Returns503WithNoActiveProfileR Assert.AreEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode); using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.AreEqual("no active profile", json.RootElement.GetProperty("reason").GetString()); + var reasons = json.RootElement.GetProperty("reasons").EnumerateArray() .Select(x => x.GetString()) .Where(x => !string.IsNullOrWhiteSpace(x)) @@ -35,6 +39,19 @@ public async Task HealthReady_WhenNoActiveProfile_Returns503WithNoActiveProfileR CollectionAssert.Contains(reasons, "no active profile"); } + [TestMethod] + public async Task M3UndleReadyHealthData_ReasonsToString_ReturnsReadableReasons() + { + await using var factory = new ReadinessApiFactory(); + var healthChecks = factory.Services.GetRequiredService(); + + var report = await healthChecks.CheckHealthAsync( + registration => registration.Name == "m3undle_ready"); + + var data = report.Entries["m3undle_ready"].Data; + Assert.AreEqual("no active profile", data["reasons"].ToString()); + } + [TestMethod] public async Task HealthReady_WhenSnapshotExistsOnlyForInactiveProfile_Returns503ForActiveProfileSnapshot() { @@ -65,6 +82,8 @@ await factory.SeedAsync(db => Assert.AreEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode); using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.AreEqual("no active snapshot for active profile", json.RootElement.GetProperty("reason").GetString()); + var reasons = json.RootElement.GetProperty("reasons").EnumerateArray() .Select(x => x.GetString()) .Where(x => !string.IsNullOrWhiteSpace(x)) @@ -104,6 +123,61 @@ await factory.SeedAsync(db => Assert.IsTrue(json.RootElement.GetProperty("ready").GetBoolean()); } + [TestMethod] + public async Task HealthReady_WhenRefreshInProgressAndActiveSnapshotExists_ReturnsReadyTrue() + { + await using var factory = new ReadinessApiFactory(isRefreshing: true); + await factory.SeedAsync(db => + { + var now = DateTime.UtcNow; + db.Profiles.Add(MakeProfile("profile-active", isActive: true, enabled: true, now)); + db.Snapshots.Add(new Snapshot + { + SnapshotId = "snapshot-active", + ProfileId = "profile-active", + CreatedUtc = now, + Status = "active", + PlaylistPath = "playlist.m3u", + XmltvPath = "guide.xml", + ChannelIndexPath = "channel_index.ndjson", + StatusJsonPath = "status.json", + ChannelCountPublished = 22, + LiveChannelCount = 22, + }); + }); + + using var client = factory.CreateClient(); + using var response = await client.GetAsync("/health/ready"); + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + Assert.IsTrue(json.RootElement.GetProperty("ready").GetBoolean()); + } + + [TestMethod] + public async Task HealthReady_WhenRefreshInProgressAndNoActiveSnapshot_Returns503WithSnapshotReason() + { + await using var factory = new ReadinessApiFactory(isRefreshing: true); + await factory.SeedAsync(db => + { + var now = DateTime.UtcNow; + db.Profiles.Add(MakeProfile("profile-active", isActive: true, enabled: true, now)); + }); + + using var client = factory.CreateClient(); + using var response = await client.GetAsync("/health/ready"); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, response.StatusCode); + + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var reasons = json.RootElement.GetProperty("reasons").EnumerateArray() + .Select(x => x.GetString()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToList(); + + CollectionAssert.Contains(reasons, "no active snapshot for active profile"); + CollectionAssert.Contains(reasons, "refresh in progress"); + } + private static Profile MakeProfile(string profileId, bool isActive, bool enabled, DateTime now) => new() { ProfileId = profileId, @@ -116,7 +190,7 @@ await factory.SeedAsync(db => UpdatedUtc = now, }; - private sealed class ReadinessApiFactory : WebApplicationFactory, IAsyncDisposable + private sealed class ReadinessApiFactory(bool isRefreshing = false) : WebApplicationFactory, IAsyncDisposable { private readonly string _tempDataDir = Path.Combine(Path.GetTempPath(), $"m3undle-readiness-{Guid.NewGuid():N}"); @@ -141,6 +215,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddDbContext(options => options.UseSqlite(WebApplicationFactoryTestCleanup.CreateSqliteConnectionString(_tempDataDir)) .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))); + + foreach (var descriptor in services.Where(d => d.ServiceType == typeof(IRefreshTrigger)).ToList()) + services.Remove(descriptor); + + services.AddSingleton(new TestRefreshTrigger(isRefreshing)); }); } @@ -158,4 +237,18 @@ public override async ValueTask DisposeAsync() await WebApplicationFactoryTestCleanup.DeleteDirectoryWhenUnlockedAsync(_tempDataDir); } } + + private sealed class TestRefreshTrigger(bool isRefreshing) : IRefreshTrigger + { + public bool IsRefreshing { get; } = isRefreshing; + public DateTime? RefreshStartedAt => null; + + public bool TriggerRefresh() => false; + + public bool TriggerBuildOnly() => false; + + public void CancelRefresh() + { + } + } } diff --git a/tests/M3Undle.Web.Tests/Streaming/ChannelSessionIntegrationTests.cs b/tests/M3Undle.Web.Tests/Streaming/ChannelSessionIntegrationTests.cs index 0fb954f..3cb6f6b 100644 --- a/tests/M3Undle.Web.Tests/Streaming/ChannelSessionIntegrationTests.cs +++ b/tests/M3Undle.Web.Tests/Streaming/ChannelSessionIntegrationTests.cs @@ -138,6 +138,7 @@ public async Task Session_UpstreamStall_TriggersReconnect() OutageWindow = TimeSpan.FromSeconds(30), ConnectTimeout = TimeSpan.FromSeconds(5), FixedStepBackoffSeconds = [0], + RecoverySafeStartSearchLimitBytes = 188, // one-packet limit so fallback triggers immediately }); var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); @@ -211,6 +212,10 @@ public async Task Session_Reconnect_ResetsBytesSinceReconnect() OutageWindow = TimeSpan.FromSeconds(30), ConnectTimeout = TimeSpan.FromSeconds(5), FixedStepBackoffSeconds = [0], + // One-packet search limit so the fallback triggers immediately — avoids + // hold-limit expiry races under slow CI timers (Task.Delay(5) ≈ 15ms on + // Windows, 174 packets × 15ms ≈ 2.6 s is close to the default 3 s limit). + RecoverySafeStartSearchLimitBytes = 188, }); var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); @@ -329,6 +334,11 @@ public async Task Session_OutageWindowExhausted_RecordsStrike() Assert.AreEqual(SessionState.Faulted, session.State); Assert.IsTrue(fixture.StrikeStore.IsCoolingDown(fixture.Source.SessionKey, out _)); + var cooldownEvents = fixture.DiagnosticsStore.Query(kind: StreamDiagnosticEventKind.CooldownRecorded); + Assert.IsTrue(cooldownEvents.Any(x => + x.ProviderId == fixture.Source.ProviderId + && x.ProviderChannelId == fixture.Source.ProviderChannelId + && x.RetryAfterSeconds is > 0)); } [TestMethod] @@ -341,7 +351,7 @@ public async Task Session_ProviderProxyAuthRequired_RecordsCooldownAndRejectsIni { ReadStallTimeout = TimeSpan.FromSeconds(30), OutageWindow = TimeSpan.FromSeconds(30), - StrikeCooldown = TimeSpan.FromSeconds(20), + StrikeCooldown = TimeSpan.FromSeconds(300), ConnectTimeout = TimeSpan.FromSeconds(2), FixedStepBackoffSeconds = [0], }); @@ -352,16 +362,44 @@ public async Task Session_ProviderProxyAuthRequired_RecordsCooldownAndRejectsIni Assert.AreEqual(StreamAdmissionFailureKind.Cooldown, ex.FailureKind); Assert.AreEqual(StatusCodes.Status503ServiceUnavailable, ex.StatusCode); - Assert.AreEqual(20, ex.RetryAfterSeconds); + Assert.AreEqual(30, ex.RetryAfterSeconds); Assert.IsTrue(fixture.StrikeStore.IsCoolingDown(fixture.Source.SessionKey, out var remaining)); Assert.IsGreaterThan(TimeSpan.Zero, remaining); + Assert.IsLessThanOrEqualTo(TimeSpan.FromSeconds(30), remaining); + Assert.AreEqual(1, handler.ConnectionCount); + } + + [TestMethod] + public async Task Session_RateLimitedWithoutRetryAfter_UsesRateLimitFallbackCooldown() + { + var handler = FakeStreamingHandler.ReturnStatus((HttpStatusCode)429); + await using var fixture = await SessionFixture.CreateAsync( + handler, + reconnectOptions: new ReconnectOptions + { + ReadStallTimeout = TimeSpan.FromSeconds(30), + OutageWindow = TimeSpan.FromSeconds(30), + StrikeCooldown = TimeSpan.FromSeconds(300), + ConnectTimeout = TimeSpan.FromSeconds(2), + FixedStepBackoffSeconds = [0], + }); + + var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + var ex = await AssertThrowsAsync( + () => session.AttachSubscriberAsync(new DefaultHttpContext(), CancellationToken.None)); + + Assert.AreEqual(StreamAdmissionFailureKind.Cooldown, ex.FailureKind); + Assert.AreEqual(30, ex.RetryAfterSeconds); + Assert.IsTrue(fixture.StrikeStore.IsCoolingDown(fixture.Source.SessionKey, out var remaining)); + Assert.IsGreaterThan(TimeSpan.FromSeconds(30), remaining); + Assert.IsLessThanOrEqualTo(TimeSpan.FromSeconds(60), remaining); Assert.AreEqual(1, handler.ConnectionCount); } [TestMethod] public async Task Session_RateLimited_UsesProviderRetryAfterForCooldown() { - // Provider Retry-After (12s) > StrikeCooldown (5s): provider value should win. + // Provider Retry-After should be honored directly instead of being raised to StrikeCooldown. var handler = FakeStreamingHandler.ReturnStatus((HttpStatusCode)429, response => response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(12))); await using var fixture = await SessionFixture.CreateAsync( @@ -370,7 +408,7 @@ public async Task Session_RateLimited_UsesProviderRetryAfterForCooldown() { ReadStallTimeout = TimeSpan.FromSeconds(30), OutageWindow = TimeSpan.FromSeconds(30), - StrikeCooldown = TimeSpan.FromSeconds(5), + StrikeCooldown = TimeSpan.FromSeconds(300), ConnectTimeout = TimeSpan.FromSeconds(2), FixedStepBackoffSeconds = [0], }); @@ -382,7 +420,7 @@ public async Task Session_RateLimited_UsesProviderRetryAfterForCooldown() Assert.AreEqual(StreamAdmissionFailureKind.Cooldown, ex.FailureKind); Assert.AreEqual(12, ex.RetryAfterSeconds); Assert.IsTrue(fixture.StrikeStore.IsCoolingDown(fixture.Source.SessionKey, out var remaining)); - Assert.IsGreaterThan(TimeSpan.FromSeconds(5), remaining); + Assert.IsGreaterThan(TimeSpan.Zero, remaining); Assert.IsLessThanOrEqualTo(TimeSpan.FromSeconds(12), remaining); Assert.AreEqual(1, handler.ConnectionCount); @@ -401,7 +439,7 @@ await WaitUntilAsync( public async Task Session_RateLimited_ProviderRetryAfterExceedsStrikeCooldown_UsesProviderRetryAfter() { // Provider says wait 30s; StrikeCooldown is only 10s. - // ResolveCooldownDuration should use max(10, 30) = 30s so we don't retry too early. + // Provider Retry-After is honored even when it exceeds the fallback cooldown cap. var handler = FakeStreamingHandler.ReturnStatus((HttpStatusCode)429, response => response.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(30))); await using var fixture = await SessionFixture.CreateAsync( @@ -520,24 +558,27 @@ public async Task Session_MpegTsInternalLateJoiner_StartsFromSafeSnapshot() var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); var firstContext = CreateResponseCaptureContext(); - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + // Use CancellationToken.None for attaching so subscriber lifetime is not tied to + // the polling wait below. Subscribers are torn down explicitly via CompleteAsync. + var firstSubscriber = await session.AttachSubscriberAsync(firstContext.Context, CancellationToken.None); - var firstSubscriber = await session.AttachSubscriberAsync(firstContext.Context, timeout.Token); await WaitUntilAsync( () => fixture.DiagnosticsStore.Query( sessionId: session.SessionId, kind: StreamDiagnosticEventKind.MpegTsSafeStartSelected).Count > 0, - TimeSpan.FromSeconds(5)); + TimeSpan.FromSeconds(10)); + Assert.IsTrue( + fixture.DiagnosticsStore.Query(sessionId: session.SessionId, kind: StreamDiagnosticEventKind.MpegTsSafeStartSelected).Count > 0, + "Timed out waiting for MpegTsSafeStartSelected — safe snapshot not yet available."); var lateContext = CreateResponseCaptureContext(); - var lateSubscriber = await session.AttachSubscriberAsync(lateContext.Context, timeout.Token, isInternal: true); - await WaitUntilAsync(() => lateSubscriber.BytesSent >= 188 * 4, TimeSpan.FromSeconds(5)); + var lateSubscriber = await session.AttachSubscriberAsync(lateContext.Context, CancellationToken.None, isInternal: true); + await WaitUntilAsync(() => lateSubscriber.BytesSent >= 188 * 4, TimeSpan.FromSeconds(10)); - timeout.Cancel(); await lateSubscriber.CompleteAsync(SubscriberDisconnectReason.ClientAborted); await firstSubscriber.CompleteAsync(SubscriberDisconnectReason.ClientAborted); - await lateSubscriber.Completion.WaitAsync(TimeSpan.FromSeconds(2)); - await firstSubscriber.Completion.WaitAsync(TimeSpan.FromSeconds(2)); + await lateSubscriber.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + await firstSubscriber.Completion.WaitAsync(TimeSpan.FromSeconds(5)); var data = lateContext.Body.ToArray(); Assert.IsGreaterThanOrEqualTo(188 * 4, data.Length); @@ -603,6 +644,68 @@ await WaitUntilAsync( await session.DisposeAsync(); } + [TestMethod] + public async Task Session_AutoRelay_UnstableChannel_SelectsCleanRemuxAtStartup() + { + var handler = FakeStreamingHandler.ReturnStatus(HttpStatusCode.InternalServerError); + await using var fixture = await SessionFixture.CreateAsync( + handler, + cleanRelayMode: "auto", + ffmpegPath: FakeFfmpegBinary.LocateExecutable(), + streamUrl: "http://fake/stream?ffmpegMode=relay-ts-sequence&delayMs=1"); + await fixture.SeedHealthEventsAsync( + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "ClientAbortAfterRecovery", + EventUtc = DateTime.UtcNow.AddMinutes(-5), + ClientAbortAfterRecovery = true, + }, + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "ClientAbortAfterRecovery", + EventUtc = DateTime.UtcNow.AddMinutes(-4), + ClientAbortAfterRecovery = true, + }, + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "RecoveryOutputResumed", + EventUtc = DateTime.UtcNow.AddMinutes(-3), + SafeStartKind = "H264Idr", + }); + + var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var subscriber = await session.AttachSubscriberAsync(new DefaultHttpContext(), timeout.Token); + + await WaitUntilAsync( + () => fixture.Registry.TryGetSession(session.SessionId)?.RelayMode == UpstreamRelayModes.FfmpegCleanRemux, + TimeSpan.FromSeconds(5)); + + var snapshot = fixture.Registry.TryGetSession(session.SessionId); + Assert.IsNotNull(snapshot); + Assert.AreEqual(StreamChannelHealthProfile.Unstable, snapshot.HealthProfile); + Assert.AreEqual("auto", snapshot.RelayPolicy); + Assert.AreEqual(UpstreamRelayModes.FfmpegCleanRemux, snapshot.RelayMode); + StringAssert.Contains(snapshot.RelayDecisionReason!, "Unstable"); + Assert.AreEqual(0, handler.ConnectionCount, "Direct HTTP must not be used for an unstable Auto channel when clean remux starts."); + + timeout.Cancel(); + await subscriber.CompleteAsync(SubscriberDisconnectReason.ClientAborted); + await session.DisposeAsync(); + } + [TestMethod] public async Task Session_CleanRelayReconnect_ResetsSafeStartAndRecovers() { @@ -781,6 +884,431 @@ await WaitUntilAsync( await session.DisposeAsync(); } + [TestMethod] + public async Task Session_MpegTsReconnect_HoldsExistingSubscriberUntilSafeStart() + { + var unsafeRecoveredPacket = FakeStreamingHandler.ValidTsPacket(0xDD); + var recoveredSequence = new[] { unsafeRecoveredPacket } + .Concat(MpegTsSafeStartupSequence()) + .ToArray(); + var safeChunk = FakeStreamingHandler.ValidTsPacket(0xCC); + var handler = FakeStreamingHandler.StreamForever(safeChunk); + handler.QueueNext(ct => FakeStreamingHandler.WriteNChunksThenStall(FakeStreamingHandler.ValidTsPacket(0xA1), 3, ct)); + handler.QueueNext(ct => FakeStreamingHandler.WriteSequenceThenForever(recoveredSequence, safeChunk, ct)); + + await using var fixture = await SessionFixture.CreateAsync( + handler, + reconnectOptions: new ReconnectOptions + { + ReadStallTimeout = TimeSpan.FromMilliseconds(200), + RecoveryOutputHoldLimit = TimeSpan.FromSeconds(2), + RecoverySafeStartSearchLimitBytes = 8 * 188, + OutageWindow = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(5), + FixedStepBackoffSeconds = [0], + }); + + var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + var capture = CreateResponseCaptureContext(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var subscriber = await session.AttachSubscriberAsync(capture.Context, cts.Token); + + await WaitUntilAsync( + () => fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryOutputResumed).Count > 0, + TimeSpan.FromSeconds(8)); + await WaitUntilAsync(() => subscriber.BytesSent >= 188 * 7, TimeSpan.FromSeconds(5)); + + cts.Cancel(); + await subscriber.CompleteAsync(SubscriberDisconnectReason.ClientAborted); + await subscriber.Completion.WaitAsync(TimeSpan.FromSeconds(2)); + + var data = capture.Body.ToArray(); + Assert.IsGreaterThanOrEqualTo(188 * 6, data.Length); + Assert.AreEqual(-1, IndexOf(data, unsafeRecoveredPacket), "Unsafe post-reconnect packet must be suppressed."); + Assert.IsGreaterThanOrEqualTo(0, IndexOf(data, PatPacket(100)), "Recovered output should resume from PAT/PMT safe start."); + + var resumed = fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryOutputResumed).First(); + Assert.IsGreaterThan(0, resumed.BytesSuppressed.GetValueOrDefault()); + Assert.IsGreaterThan(0, resumed.OutputHeldMs.GetValueOrDefault()); + + await session.DisposeAsync(); + } + + [TestMethod] + public async Task Session_MpegTsReconnect_UnsafeRecoveryForcesControlledClose() + { + var handler = FakeStreamingHandler.StreamForever(FakeStreamingHandler.ValidTsPacket(0xDD)); + handler.QueueNext(ct => FakeStreamingHandler.WriteNChunksThenStall(FakeStreamingHandler.ValidTsPacket(0xA1), 3, ct)); + + await using var fixture = await SessionFixture.CreateAsync( + handler, + reconnectOptions: new ReconnectOptions + { + ReadStallTimeout = TimeSpan.FromMilliseconds(200), + RecoveryOutputHoldLimit = TimeSpan.FromSeconds(2), + RecoverySafeStartSearchLimitBytes = 4 * 188, + AllowPacketBoundaryRecoveryFallback = false, + OutageWindow = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(5), + FixedStepBackoffSeconds = [0], + }); + + var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + var capture = CreateResponseCaptureContext(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var subscriber = await session.AttachSubscriberAsync(capture.Context, cts.Token); + + await WaitUntilAsync( + () => fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryForcedRetune).Count > 0, + TimeSpan.FromSeconds(8)); + await subscriber.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.AreEqual(SessionState.Faulted, session.State); + Assert.IsTrue(fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryFailedUnsafe).Any()); + Assert.IsTrue(fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.SubscriberRemoved).Any(x => + x.DisconnectReason == SubscriberDisconnectReason.SessionClosed)); + + var data = capture.Body.ToArray(); + Assert.AreEqual(188 * 3, data.Length, "Only pre-reconnect bytes should reach the existing subscriber."); + Assert.AreEqual(-1, IndexOf(data, FakeStreamingHandler.ValidTsPacket(0xDD))); + } + + [TestMethod] + public async Task Session_MpegTsReconnect_UnstableChannel_DisallowsPacketBoundaryFallback() + { + var handler = FakeStreamingHandler.StreamForever(FakeStreamingHandler.ValidTsPacket(0xDD)); + handler.QueueNext(ct => FakeStreamingHandler.WriteNChunksThenStall(FakeStreamingHandler.ValidTsPacket(0xA1), 3, ct)); + + await using var fixture = await SessionFixture.CreateAsync( + handler, + bufferOptions: new BufferOptions + { + ReadChunkSizeBytes = 188, + SubscriberQueueCapacity = 128, + MaxBytesPerSession = 16 * 188, + MaxBytesHardCap = 4 * 1024 * 1024, + }, + reconnectOptions: new ReconnectOptions + { + ReadStallTimeout = TimeSpan.FromMilliseconds(200), + RecoveryOutputHoldLimit = TimeSpan.FromSeconds(2), + RecoverySafeStartSearchLimitBytes = 4 * 188, + AllowPacketBoundaryRecoveryFallback = true, + OutageWindow = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(5), + FixedStepBackoffSeconds = [0], + }); + await fixture.SeedHealthEventsAsync( + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "ClientAbortAfterRecovery", + EventUtc = DateTime.UtcNow.AddMinutes(-10), + ClientAbortAfterRecovery = true, + }, + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "ClientAbortAfterRecovery", + EventUtc = DateTime.UtcNow.AddMinutes(-5), + ClientAbortAfterRecovery = true, + }); + + var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + var capture = CreateResponseCaptureContext(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var subscriber = await session.AttachSubscriberAsync(capture.Context, cts.Token); + + await WaitUntilAsync( + () => fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryForcedRetune).Count > 0, + TimeSpan.FromSeconds(8)); + await subscriber.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.AreEqual(SessionState.Faulted, session.State); + Assert.IsFalse(fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryOutputResumed).Any(x => + string.Equals(x.SafeStartKind, "FallbackPacketBoundary", StringComparison.Ordinal))); + Assert.IsTrue(fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryForcedRetune).Any()); + } + + [TestMethod] + public async Task Session_MpegTsReconnect_ControlledDownstreamRetune_ClosesSharedSessionWithoutResuming() + { + var preReconnectPacket = FakeStreamingHandler.ValidTsPacket(0xA1); + var safeChunk = FakeStreamingHandler.ValidTsPacket(0xCC); + var recoveredSequence = MpegTsSafeStartupSequence(); + var handler = FakeStreamingHandler.StreamForever(safeChunk); + handler.QueueNext(ct => FakeStreamingHandler.WriteNChunksThenStall(preReconnectPacket, 3, ct)); + handler.QueueNext(ct => FakeStreamingHandler.WriteSequenceThenForever(recoveredSequence, safeChunk, ct)); + + await using var fixture = await SessionFixture.CreateAsync( + handler, + bufferOptions: new BufferOptions + { + ReadChunkSizeBytes = 188, + SubscriberQueueCapacity = 128, + MaxBytesPerSession = 64 * 188, + MaxBytesHardCap = 4 * 1024 * 1024, + }, + reconnectOptions: new ReconnectOptions + { + ReadStallTimeout = TimeSpan.FromMilliseconds(200), + RecoveryOutputHoldLimit = TimeSpan.FromSeconds(2), + RecoverySafeStartSearchLimitBytes = 64 * 188, + AllowPacketBoundaryRecoveryFallback = true, + OutageWindow = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(5), + FixedStepBackoffSeconds = [0], + }); + await fixture.SeedHealthEventsAsync( + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "RecoveryOutputResumed", + EventUtc = DateTime.UtcNow.AddMinutes(-15), + SafeStartKind = "H264Idr", + }, + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "ClientAbortAfterRecovery", + EventUtc = DateTime.UtcNow.AddMinutes(-10), + ClientAbortAfterRecovery = true, + }, + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "ClientAbortAfterRecovery", + EventUtc = DateTime.UtcNow.AddMinutes(-5), + ClientAbortAfterRecovery = true, + }); + + var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + var capture = CreateResponseCaptureContext(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var subscriber = await session.AttachSubscriberAsync(capture.Context, cts.Token); + + await WaitUntilAsync( + () => fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.ControlledDownstreamRetune).Count > 0, + TimeSpan.FromSeconds(8)); + await subscriber.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + await WaitUntilAsync(() => !fixture.Manager.TryGet(session.Key, out _), TimeSpan.FromSeconds(5)); + + Assert.AreEqual(SessionState.Closed, session.State); + Assert.IsTrue(fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.SubscriberRemoved).Any(x => + x.DisconnectReason == SubscriberDisconnectReason.Retuned)); + Assert.IsFalse(fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryOutputResumed).Any()); + Assert.IsFalse(fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.ClientAbortAfterRecovery).Any()); + + var replacement = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + Assert.AreNotEqual(session.SessionId, replacement.SessionId); + + await replacement.DisposeAsync(); + } + + [TestMethod] + public async Task Session_MpegTsReconnect_ControlledDownstreamRetune_InternalRelaySubscriberKeepsSessionAlive() + { + var preReconnectPacket = FakeStreamingHandler.ValidTsPacket(0xA1); + var safeChunk = FakeStreamingHandler.ValidTsPacket(0xCC); + var recoveredSequence = MpegTsSafeStartupSequence(); + var handler = FakeStreamingHandler.StreamForever(safeChunk); + handler.QueueNext(ct => FakeStreamingHandler.WriteNChunksThenStall(preReconnectPacket, 3, ct)); + handler.QueueNext(ct => FakeStreamingHandler.WriteSequenceThenForever(recoveredSequence, safeChunk, ct)); + + await using var fixture = await SessionFixture.CreateAsync( + handler, + bufferOptions: new BufferOptions + { + ReadChunkSizeBytes = 188, + SubscriberQueueCapacity = 128, + MaxBytesPerSession = 64 * 188, + MaxBytesHardCap = 4 * 1024 * 1024, + }, + reconnectOptions: new ReconnectOptions + { + ReadStallTimeout = TimeSpan.FromMilliseconds(200), + RecoveryOutputHoldLimit = TimeSpan.FromSeconds(2), + RecoverySafeStartSearchLimitBytes = 64 * 188, + AllowPacketBoundaryRecoveryFallback = true, + OutageWindow = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(5), + FixedStepBackoffSeconds = [0], + }); + await fixture.SeedHealthEventsAsync( + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "RecoveryOutputResumed", + EventUtc = DateTime.UtcNow.AddMinutes(-15), + SafeStartKind = "H264Idr", + }, + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "ClientAbortAfterRecovery", + EventUtc = DateTime.UtcNow.AddMinutes(-10), + ClientAbortAfterRecovery = true, + }, + new StreamChannelHealthEvent + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = "ClientAbortAfterRecovery", + EventUtc = DateTime.UtcNow.AddMinutes(-5), + ClientAbortAfterRecovery = true, + }); + + var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + var externalCapture = CreateResponseCaptureContext(); + var internalCapture = CreateResponseCaptureContext(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var externalSubscriber = await session.AttachSubscriberAsync(externalCapture.Context, cts.Token); + var internalSubscriber = await session.AttachSubscriberAsync(internalCapture.Context, cts.Token, isInternal: true); + + await WaitUntilAsync( + () => fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryOutputResumed).Count > 0, + TimeSpan.FromSeconds(8)); + + Assert.AreEqual(SessionState.Live, session.State); + Assert.IsTrue(fixture.Manager.TryGet(session.Key, out var activeSession)); + Assert.AreSame(session, activeSession); + Assert.IsFalse(externalSubscriber.IsCompleted); + Assert.IsFalse(internalSubscriber.IsCompleted); + Assert.IsFalse(fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.ControlledDownstreamRetune).Any()); + + await externalSubscriber.CompleteAsync(SubscriberDisconnectReason.ClientAborted); + await internalSubscriber.CompleteAsync(SubscriberDisconnectReason.SessionClosed); + } + + [TestMethod] + public async Task Session_MpegTsReconnect_NewExternalSubscriberDuringRecovery_DoesNotReceiveStalePreReconnectData() + { + // A new external subscriber that attaches while the session is holding output + // must receive data from the live edge (post-reconnect safe start), not stale + // pre-reconnect bytes still in the ring buffer. + var preReconnectPacket = FakeStreamingHandler.ValidTsPacket(0xA1); + var unsafeRecoveredPacket = FakeStreamingHandler.ValidTsPacket(0xDD); + var safeChunk = FakeStreamingHandler.ValidTsPacket(0xCC); + // Recovery sequence: one unsafe prefix packet followed by the full safe-start + // sequence. After the safe-start is confirmed the session streams safeChunk + // forever — 0xDD never reappears so the assertion below is deterministic. + var recoveredSequence = new[] { unsafeRecoveredPacket } + .Concat(MpegTsSafeStartupSequence()) + .ToArray(); + var handler = FakeStreamingHandler.StreamForever(safeChunk); // default (connection 3+) + handler.QueueNext(ct => FakeStreamingHandler.WriteNChunksThenStall(preReconnectPacket, 3, ct)); // connection 1 + handler.QueueNext(ct => FakeStreamingHandler.WriteSequenceThenForever(recoveredSequence, safeChunk, ct)); // connection 2 + + await using var fixture = await SessionFixture.CreateAsync( + handler, + reconnectOptions: new ReconnectOptions + { + ReadStallTimeout = TimeSpan.FromMilliseconds(200), + RecoveryOutputHoldLimit = TimeSpan.FromSeconds(3), + RecoverySafeStartSearchLimitBytes = 16 * 188, + OutageWindow = TimeSpan.FromSeconds(30), + ConnectTimeout = TimeSpan.FromSeconds(5), + FixedStepBackoffSeconds = [0], + }); + + var session = await fixture.Manager.GetOrCreateAsync(fixture.Source, CancellationToken.None); + + // First subscriber attaches before the stall. + var firstCapture = CreateResponseCaptureContext(); + using var firstCts = new CancellationTokenSource(TimeSpan.FromSeconds(12)); + var firstSubscriber = await session.AttachSubscriberAsync(firstCapture.Context, firstCts.Token); + await WaitUntilAsync(() => firstSubscriber.BytesSent > 0, TimeSpan.FromSeconds(5)); + + // Wait for the session to enter HoldingOutput state (reconnect + hold active). + await WaitUntilAsync( + () => fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryStarted).Count > 0, + TimeSpan.FromSeconds(8)); + + // Late external subscriber attaches during HoldingOutput. + var lateCapture = CreateResponseCaptureContext(); + using var lateCts = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + var lateSubscriber = await session.AttachSubscriberAsync(lateCapture.Context, lateCts.Token); + + // Wait for recovery to resume so both subscribers receive data. + await WaitUntilAsync( + () => fixture.DiagnosticsStore.Query( + sessionId: session.SessionId, + kind: StreamDiagnosticEventKind.RecoveryOutputResumed).Count > 0, + TimeSpan.FromSeconds(8)); + await WaitUntilAsync(() => lateSubscriber.BytesSent >= 188, TimeSpan.FromSeconds(5)); + + firstCts.Cancel(); + lateCts.Cancel(); + await firstSubscriber.CompleteAsync(SubscriberDisconnectReason.ClientAborted); + await lateSubscriber.CompleteAsync(SubscriberDisconnectReason.ClientAborted); + await firstSubscriber.Completion.WaitAsync(TimeSpan.FromSeconds(2)); + await lateSubscriber.Completion.WaitAsync(TimeSpan.FromSeconds(2)); + + var lateData = lateCapture.Body.ToArray(); + + Assert.IsGreaterThanOrEqualTo(188, lateData.Length, "Late subscriber must have received at least one packet."); + Assert.AreEqual(0x47, lateData[0], "Late subscriber data must begin with a TS sync byte."); + Assert.AreEqual(-1, IndexOf(lateData, preReconnectPacket), + "Late subscriber must not receive pre-reconnect bytes."); + Assert.AreEqual(-1, IndexOf(lateData, unsafeRecoveredPacket), + "Late subscriber must not receive unsafe post-reconnect bytes suppressed during hold."); + + await session.DisposeAsync(); + } + [TestMethod] public async Task Session_MpegTs_PatPmtOnlyStream_SelectsPatPmtSafeStart() { @@ -2332,6 +2860,32 @@ public static Task StreamForeverSequenceResponse(IReadOnlyL return Task.FromResult(CreateStreamingResponse(pipe.Reader.AsStream())); } + public static Task WriteSequenceThenForever(IReadOnlyList once, byte[] forever, CancellationToken ct) + { + var pipe = new Pipe(); + _ = Task.Run(async () => + { + try + { + foreach (var chunk in once) + { + await pipe.Writer.WriteAsync(chunk, ct); + await Task.Delay(5, ct); + } + + while (!ct.IsCancellationRequested) + { + await pipe.Writer.WriteAsync(forever, ct); + await Task.Delay(5, ct); + } + } + catch (OperationCanceledException) { } + catch (Exception) { } + finally { pipe.Writer.Complete(); } + }); + return Task.FromResult(CreateStreamingResponse(pipe.Reader.AsStream())); + } + public static Task WriteNChunksThenStall(byte[] chunk, int n, CancellationToken ct) { var pipe = new Pipe(); @@ -2500,8 +3054,11 @@ public static async Task CreateAsync( var httpClientFactory = new FakeHttpClientFactory(handler); var scopeFactory = serviceProvider.GetRequiredService(); + var healthProfileService = new StreamChannelHealthProfileService( + scopeFactory, + NullLogger.Instance); var connector = new UpstreamStreamConnector( - httpClientFactory, scopeFactory, reconnectOpts, + httpClientFactory, scopeFactory, healthProfileService, reconnectOpts, Options.Create(new GeneratedHlsOptions { FfmpegPath = ffmpegPath ?? string.Empty }), Options.Create(cleanRelayOptions ?? new CleanRelayOptions()), NullLogger.Instance); @@ -2513,7 +3070,8 @@ public static async Task CreateAsync( var diagnosticsStore = new StreamingDiagnosticsStore(proxyOpts); var manager = new ChannelSessionManager( bufOpts, proxyOpts, reconnectOpts, connector, strikeStore, admissionBackoffStore, registry, - diagnosticsStore, new NullEventService(), NullLoggerFactory.Instance, timeProvider ?? TimeProvider.System); + diagnosticsStore, NoopStreamChannelHealthEventRecorder.Instance, healthProfileService, new NullEventService(), + NullLoggerFactory.Instance, timeProvider ?? TimeProvider.System); var source = new StreamSourceDescriptor( ProfileId: "profile-1", @@ -2529,6 +3087,14 @@ public static async Task CreateAsync( return new SessionFixture(connection, serviceProvider, handler, strikeStore, registry, diagnosticsStore, manager, source); } + public async Task SeedHealthEventsAsync(params StreamChannelHealthEvent[] events) + { + using var scope = _serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.StreamChannelHealthEvents.AddRange(events); + await db.SaveChangesAsync(); + } + public async ValueTask DisposeAsync() { await Manager.ResetAllAsync(); diff --git a/tests/M3Undle.Web.Tests/Streaming/GeneratedHlsSessionManagerTests.cs b/tests/M3Undle.Web.Tests/Streaming/GeneratedHlsSessionManagerTests.cs index 0a74502..6001c1a 100644 --- a/tests/M3Undle.Web.Tests/Streaming/GeneratedHlsSessionManagerTests.cs +++ b/tests/M3Undle.Web.Tests/Streaming/GeneratedHlsSessionManagerTests.cs @@ -30,6 +30,34 @@ public async Task StartAsync_WhenFfmpegIsMissing_DisablesGeneratedHls() } } + [TestMethod] + public async Task StartAsync_WhenWorkDirectoryCannotBePrepared_DisablesGeneratedHls() + { + var root = CreateTempDir(); + try + { + await using var ffmpeg = FakeFfmpegBinary.Create(writeManifest: true); + var workDirectoryPath = Path.Combine(root, "hls-work"); + File.WriteAllText(workDirectoryPath, "not a directory"); + + await using var manager = CreateManager( + root, + ffmpeg.ExePath, + startupTimeoutSeconds: 1, + workDirectory: workDirectoryPath); + + await manager.StartAsync(CancellationToken.None); + + Assert.IsFalse(manager.FfmpegAvailable); + Assert.IsFalse(manager.IsEffectivelyEnabled); + StringAssert.Contains(manager.FfmpegUnavailableReason!, "not writable"); + } + finally + { + TryDelete(root); + } + } + [TestMethod] public async Task CreateSessionAsync_WithFakeFfmpeg_ReturnsHandleAndServesManifestAsset() { @@ -504,12 +532,13 @@ private static GeneratedHlsSessionManager CreateManager( string root, string ffmpegPath, int startupTimeoutSeconds, - StreamingRegistry? registry = null) + StreamingRegistry? registry = null, + string? workDirectory = null) { var options = Options.Create(new GeneratedHlsOptions { Enabled = true, - Directory = Path.Combine(root, "generated-hls"), + Directory = workDirectory ?? Path.Combine(root, "generated-hls"), FfmpegPath = ffmpegPath, SegmentDurationSeconds = 1, PlaylistSize = 2, diff --git a/tests/M3Undle.Web.Tests/Streaming/HlsProxyEndpointSecurityTests.cs b/tests/M3Undle.Web.Tests/Streaming/HlsProxyEndpointSecurityTests.cs index c893c63..bec95df 100644 --- a/tests/M3Undle.Web.Tests/Streaming/HlsProxyEndpointSecurityTests.cs +++ b/tests/M3Undle.Web.Tests/Streaming/HlsProxyEndpointSecurityTests.cs @@ -123,13 +123,17 @@ private sealed class StubEndpointSecurityService : IEndpointSecurityService public ValueTask IsEnabledAsync(CancellationToken cancellationToken) => ValueTask.FromResult(false); + public ValueTask IsXtreamEnabledAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(true); + public Task GetSettingsAsync(CancellationToken cancellationToken) => Task.FromResult(new EndpointSecuritySettings( Enabled: false, Username: null, HasCredential: false, ActiveProfileId: "profile-1", - VirtualTunerId: "hdhr-main")); + VirtualTunerId: "hdhr-main", + XtreamCompatibilityEnabled: true)); public Task GetBindingAsync(string credentialId, CancellationToken cancellationToken) => Task.FromResult(new EndpointBindingState("profile-1", "hdhr-main")); diff --git a/tests/M3Undle.Web.Tests/Streaming/StreamChannelHealthEventRecorderTests.cs b/tests/M3Undle.Web.Tests/Streaming/StreamChannelHealthEventRecorderTests.cs new file mode 100644 index 0000000..a09f19c --- /dev/null +++ b/tests/M3Undle.Web.Tests/Streaming/StreamChannelHealthEventRecorderTests.cs @@ -0,0 +1,180 @@ +using M3Undle.Web.Data; +using M3Undle.Web.Streaming.Models; +using M3Undle.Web.Streaming.Observability; +using M3Undle.Web.Streaming.Subscribers; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace M3Undle.Web.Tests.Streaming; + +[TestClass] +public sealed class StreamChannelHealthEventRecorderTests +{ + [TestMethod] + public async Task Record_PersistsRecoveryOutputResumedHealthEvent() + { + await using var fixture = await RecorderFixture.CreateAsync(); + + await fixture.Recorder.StartAsync(CancellationToken.None); + fixture.Recorder.Record(CreateEvent( + StreamDiagnosticEventKind.RecoveryOutputResumed, + safeStartKind: "H264Idr", + outputHeldMs: 11.5, + recoveryDurationMs: 1250, + bytesSuppressed: 8084)); + await fixture.Recorder.FlushAsync(); + await fixture.Recorder.StopAsync(CancellationToken.None); + + await using var db = fixture.CreateDbContext(); + var healthEvent = await db.StreamChannelHealthEvents.SingleAsync(); + Assert.AreEqual("RecoveryOutputResumed", healthEvent.EventKind); + Assert.AreEqual("provider-1", healthEvent.ProviderId); + Assert.AreEqual("channel-1", healthEvent.ProviderChannelId); + Assert.AreEqual("FfmpegCleanRemux", healthEvent.RelayMode); + Assert.AreEqual("H264Idr", healthEvent.SafeStartKind); + Assert.IsNull(healthEvent.SafeStartWaitMs); + Assert.AreEqual(11.5, healthEvent.OutputHeldMs); + Assert.AreEqual(1250, healthEvent.RecoveryDurationMs); + Assert.AreEqual(8084, healthEvent.BytesSuppressed); + Assert.IsFalse(healthEvent.ForcedRetune); + } + + [TestMethod] + public async Task Record_PersistsClientAbortAfterRecoveryCorrelation() + { + await using var fixture = await RecorderFixture.CreateAsync(); + + await fixture.Recorder.StartAsync(CancellationToken.None); + fixture.Recorder.Record(CreateEvent( + StreamDiagnosticEventKind.ClientAbortAfterRecovery, + disconnectReason: SubscriberDisconnectReason.ClientAborted, + clientAbortAfterRecoveryDelayMs: 244_566)); + await fixture.Recorder.FlushAsync(); + await fixture.Recorder.StopAsync(CancellationToken.None); + + await using var db = fixture.CreateDbContext(); + var healthEvent = await db.StreamChannelHealthEvents.SingleAsync(); + Assert.AreEqual("ClientAbortAfterRecovery", healthEvent.EventKind); + Assert.IsTrue(healthEvent.ClientAbortAfterRecovery); + Assert.AreEqual("ClientAborted", healthEvent.ClientDisconnectReason); + Assert.AreEqual(244_566, healthEvent.ClientAbortAfterRecoveryDelayMs); + } + + [TestMethod] + public async Task Record_PersistsCleanWatchDuration() + { + await using var fixture = await RecorderFixture.CreateAsync(); + + await fixture.Recorder.StartAsync(CancellationToken.None); + fixture.Recorder.Record(CreateEvent( + StreamDiagnosticEventKind.CleanWatchCompleted, + cleanWatchDurationMs: 45_000)); + await fixture.Recorder.FlushAsync(); + await fixture.Recorder.StopAsync(CancellationToken.None); + + await using var db = fixture.CreateDbContext(); + var healthEvent = await db.StreamChannelHealthEvents.SingleAsync(); + Assert.AreEqual("CleanWatchCompleted", healthEvent.EventKind); + Assert.AreEqual(45_000, healthEvent.CleanWatchDurationMs); + } + + [TestMethod] + public async Task Record_IgnoresNonHealthDiagnosticEvent() + { + await using var fixture = await RecorderFixture.CreateAsync(); + + await fixture.Recorder.StartAsync(CancellationToken.None); + fixture.Recorder.Record(CreateEvent(StreamDiagnosticEventKind.SubscriberAttached)); + await fixture.Recorder.FlushAsync(); + await fixture.Recorder.StopAsync(CancellationToken.None); + + await using var db = fixture.CreateDbContext(); + Assert.AreEqual(0, await db.StreamChannelHealthEvents.CountAsync()); + } + + private static StreamDiagnosticEvent CreateEvent( + StreamDiagnosticEventKind kind, + string? safeStartKind = null, + double? outputHeldMs = null, + double? recoveryDurationMs = null, + long? bytesSuppressed = null, + SubscriberDisconnectReason? disconnectReason = null, + double? clientAbortAfterRecoveryDelayMs = null, + double? cleanWatchDurationMs = null) + => new( + EventId: Guid.NewGuid().ToString("N"), + TimestampUtc: DateTimeOffset.UtcNow, + Kind: kind, + SessionId: "session-1", + ProviderId: "provider-1", + ProviderChannelId: "channel-1", + DisplayName: "ABC", + RequestedRoute: "/live/key/1.ts", + RouteClassification: "shared_hls", + UpstreamFailureKind: kind == StreamDiagnosticEventKind.UpstreamFailure ? UpstreamFailureKind.TimeoutOrStall : null, + ReconnectAttempt: 1, + OutputHeldMs: outputHeldMs, + RecoveryDurationMs: recoveryDurationMs, + SafeStartKind: safeStartKind, + BytesSuppressed: bytesSuppressed, + ClientAbortAfterRecoveryDelayMs: clientAbortAfterRecoveryDelayMs, + CleanWatchDurationMs: cleanWatchDurationMs, + DisconnectReason: disconnectReason, + RelayMode: "FfmpegCleanRemux"); + + private sealed class RecorderFixture : IAsyncDisposable + { + private readonly SqliteConnection _connection; + private readonly ServiceProvider _serviceProvider; + private readonly DbContextOptions _options; + + private RecorderFixture( + SqliteConnection connection, + ServiceProvider serviceProvider, + DbContextOptions options, + StreamChannelHealthEventRecorder recorder) + { + _connection = connection; + _serviceProvider = serviceProvider; + _options = options; + Recorder = recorder; + } + + public StreamChannelHealthEventRecorder Recorder { get; } + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + var services = new ServiceCollection(); + services.AddDbContext(_ => _.UseSqlite(connection)); + var serviceProvider = services.BuildServiceProvider(); + + await using (var db = new ApplicationDbContext(options)) + await db.Database.EnsureCreatedAsync(); + + var recorder = new StreamChannelHealthEventRecorder( + serviceProvider.GetRequiredService(), + NullLogger.Instance); + + return new RecorderFixture(connection, serviceProvider, options, recorder); + } + + public ApplicationDbContext CreateDbContext() => new(_options); + + public async ValueTask DisposeAsync() + { + await Recorder.StopAsync(CancellationToken.None); + Recorder.Dispose(); + await _serviceProvider.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/M3Undle.Web.Tests/Streaming/StreamChannelHealthProfileServiceTests.cs b/tests/M3Undle.Web.Tests/Streaming/StreamChannelHealthProfileServiceTests.cs new file mode 100644 index 0000000..768588c --- /dev/null +++ b/tests/M3Undle.Web.Tests/Streaming/StreamChannelHealthProfileServiceTests.cs @@ -0,0 +1,387 @@ +using M3Undle.Web.Data; +using M3Undle.Web.Data.Entities; +using M3Undle.Web.Streaming.Configuration; +using M3Undle.Web.Streaming.Observability; +using M3Undle.Web.Streaming.Upstream; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace M3Undle.Web.Tests.Streaming; + +[TestClass] +public sealed class StreamChannelHealthProfileServiceTests +{ + [TestMethod] + public async Task GetRecoveryPolicyAsync_RepeatedAbortAfterRecovery_DerivesUnstablePolicy() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true), + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true)); + + var policy = await fixture.Service.GetRecoveryPolicyAsync( + "provider-1", + "channel-1", + new ReconnectOptions + { + RecoveryOutputHoldLimit = TimeSpan.FromSeconds(3), + RecoverySafeStartSearchLimitBytes = 512 * 1024, + AllowPacketBoundaryRecoveryFallback = true, + }); + + Assert.AreEqual(StreamChannelHealthProfile.Unstable, policy.Profile); + Assert.IsFalse(policy.AllowPacketBoundaryRecoveryFallback); + Assert.IsTrue(policy.RecoveryOutputHoldLimit >= TimeSpan.FromSeconds(5)); + Assert.IsTrue(policy.RecoverySafeStartSearchLimitBytes >= 2 * 1024 * 1024); + } + + [TestMethod] + public async Task GetRecoveryPolicyAsync_NoHealthEvents_UsesConfiguredFallbackPolicy() + { + await using var fixture = await ProfileFixture.CreateAsync(); + + var policy = await fixture.Service.GetRecoveryPolicyAsync( + "provider-1", + "channel-1", + new ReconnectOptions + { + RecoveryOutputHoldLimit = TimeSpan.FromSeconds(3), + RecoverySafeStartSearchLimitBytes = 512 * 1024, + AllowPacketBoundaryRecoveryFallback = true, + }); + + Assert.AreEqual(StreamChannelHealthProfile.Stable, policy.Profile); + Assert.IsTrue(policy.AllowPacketBoundaryRecoveryFallback); + Assert.AreEqual(TimeSpan.FromSeconds(3), policy.RecoveryOutputHoldLimit); + Assert.AreEqual(512 * 1024, policy.RecoverySafeStartSearchLimitBytes); + } + + [TestMethod] + public async Task GetRelayPolicyDecision_AutoUnstable_SelectsCleanRemux() + { + await using var fixture = await ProfileFixture.CreateAsync(); + var policy = new StreamChannelRecoveryPolicy( + StreamChannelHealthProfile.Unstable, + TimeSpan.FromSeconds(5), + 2 * 1024 * 1024, + AllowPacketBoundaryRecoveryFallback: false, + RequireDownstreamRetune: true, + DownstreamRetuneReason: "test", + Reason: "unstable test profile"); + + var decision = fixture.Service.GetRelayPolicyDecision("auto", policy); + + Assert.AreEqual("auto", decision.ProviderRelayPolicy); + Assert.AreEqual(UpstreamRelayModes.FfmpegCleanRemux, decision.SelectedRelayMode); + StringAssert.Contains(decision.Reason, "Unstable"); + } + + [TestMethod] + public async Task GetRelayPolicyDecision_OffUnstable_ForcesDirect() + { + await using var fixture = await ProfileFixture.CreateAsync(); + var policy = new StreamChannelRecoveryPolicy( + StreamChannelHealthProfile.Unstable, + TimeSpan.FromSeconds(5), + 2 * 1024 * 1024, + AllowPacketBoundaryRecoveryFallback: false, + RequireDownstreamRetune: true, + DownstreamRetuneReason: "test", + Reason: "unstable test profile"); + + var decision = fixture.Service.GetRelayPolicyDecision("off", policy); + + Assert.AreEqual("off", decision.ProviderRelayPolicy); + Assert.AreEqual(UpstreamRelayModes.Direct, decision.SelectedRelayMode); + } + + [TestMethod] + public async Task GetEvidenceAsync_WithCleanWatchAfterAdverseEvent_ExposesLastCleanWatchUtc() + { + var cleanWatchTime = DateTime.UtcNow - TimeSpan.FromMinutes(10); + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true, age: TimeSpan.FromHours(2)), + CreateHealthEvent("CleanWatchCompleted", cleanWatchDurationMs: TimeSpan.FromMinutes(35).TotalMilliseconds, age: TimeSpan.FromMinutes(10))); + + var evidence = await fixture.Service.GetEvidenceAsync( + "provider-1", + "channel-1", + new ReconnectOptions()); + + Assert.IsNotNull(evidence.LastCleanWatchUtc); + Assert.IsNotNull(evidence.LastAdverseEventUtc); + Assert.IsTrue(evidence.LastCleanWatchUtc > evidence.LastAdverseEventUtc, + "LastCleanWatchUtc should be more recent than LastAdverseEventUtc"); + Assert.AreEqual(1, evidence.CleanWatchEvents); + } + + [TestMethod] + public async Task GetEvidenceAsync_NoCleanWatchAfterAdverseEvent_LastCleanWatchUtcIsNull() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("CleanWatchCompleted", cleanWatchDurationMs: TimeSpan.FromMinutes(60).TotalMilliseconds, age: TimeSpan.FromHours(2)), + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true), + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true)); + + var evidence = await fixture.Service.GetEvidenceAsync( + "provider-1", + "channel-1", + new ReconnectOptions()); + + Assert.IsNull(evidence.LastCleanWatchUtc, + "Clean watch before the adverse event should not count — LastCleanWatchUtc must be null"); + Assert.AreEqual(0, evidence.CleanWatchEvents); + } + + [TestMethod] + public async Task GetRecoveryPolicyAsync_CleanWatchAfterAdverseEvent_DecaysUnstableToCautious() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true, age: TimeSpan.FromHours(2)), + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true, age: TimeSpan.FromHours(2)), + CreateHealthEvent("CleanWatchCompleted", cleanWatchDurationMs: TimeSpan.FromMinutes(30).TotalMilliseconds)); + + var policy = await fixture.Service.GetRecoveryPolicyAsync( + "provider-1", + "channel-1", + new ReconnectOptions()); + var evidence = await fixture.Service.GetEvidenceAsync( + "provider-1", + "channel-1", + new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthProfile.Cautious, policy.Profile); + Assert.AreEqual(1, evidence.CleanWatchEvents); + Assert.AreEqual(TimeSpan.FromMinutes(30), evidence.CleanWatchDuration); + Assert.AreEqual(StreamChannelHealthProfile.Cautious, evidence.RecoveryPolicy.Profile); + } + + [TestMethod] + public async Task GetRecoveryPolicyAsync_CleanWatchBeforeAdverseEvent_DoesNotDecayUnstable() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("CleanWatchCompleted", cleanWatchDurationMs: TimeSpan.FromMinutes(60).TotalMilliseconds, age: TimeSpan.FromHours(2)), + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true), + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true)); + + var evidence = await fixture.Service.GetEvidenceAsync( + "provider-1", + "channel-1", + new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthProfile.Unstable, evidence.RecoveryPolicy.Profile); + Assert.AreEqual(0, evidence.CleanWatchEvents); + Assert.AreEqual(TimeSpan.Zero, evidence.CleanWatchDuration); + } + + [TestMethod] + public async Task GetEvidenceAsync_NoEvents_TrendIsUnknown() + { + await using var fixture = await ProfileFixture.CreateAsync(); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Unknown, evidence.Trend.Trend); + } + + [TestMethod] + public async Task GetEvidenceAsync_OnlyCleanWatchInRecentWindow_TrendIsStable() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("CleanWatchCompleted", cleanWatchDurationMs: TimeSpan.FromMinutes(20).TotalMilliseconds, age: TimeSpan.FromMinutes(30))); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Stable, evidence.Trend.Trend); + } + + [TestMethod] + public async Task GetEvidenceAsync_ForcedRetuneInRecentWindow_TrendIsWorsening() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("ForcedRetune", forcedRetune: true, age: TimeSpan.FromMinutes(20))); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Worsening, evidence.Trend.Trend); + StringAssert.Contains(evidence.Trend.Reason, "forced retune"); + } + + [TestMethod] + public async Task GetEvidenceAsync_ClientAbortInRecentWindow_TrendIsWorsening() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("ClientAbortAfterRecovery", clientAbortAfterRecovery: true, age: TimeSpan.FromMinutes(10))); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Worsening, evidence.Trend.Trend); + } + + [TestMethod] + public async Task GetEvidenceAsync_AdverseOnlyInComparisonWindow_TrendIsImproving() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(90)), + CreateHealthEvent("CleanWatchCompleted", cleanWatchDurationMs: TimeSpan.FromMinutes(35).TotalMilliseconds, age: TimeSpan.FromMinutes(20))); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Improving, evidence.Trend.Trend); + Assert.AreEqual(0, evidence.Trend.RecentAdverseCount); + Assert.AreEqual(1, evidence.Trend.ComparisonAdverseCount); + } + + [TestMethod] + public async Task GetEvidenceAsync_MoreAdverseInRecentThanComparison_TrendIsWorsening() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(90)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(30)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(25)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(20))); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Worsening, evidence.Trend.Trend); + Assert.AreEqual(3, evidence.Trend.RecentAdverseCount); + Assert.AreEqual(1, evidence.Trend.ComparisonAdverseCount); + } + + [TestMethod] + public async Task GetEvidenceAsync_FewerAdverseInRecentWithCleanWatch_TrendIsImproving() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(90)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(85)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(80)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(30)), + CreateHealthEvent("CleanWatchCompleted", cleanWatchDurationMs: TimeSpan.FromMinutes(20).TotalMilliseconds, age: TimeSpan.FromMinutes(10))); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Improving, evidence.Trend.Trend); + Assert.AreEqual(1, evidence.Trend.RecentAdverseCount); + Assert.AreEqual(3, evidence.Trend.ComparisonAdverseCount); + } + + [TestMethod] + public async Task GetEvidenceAsync_EqualAdverseInBothWindows_TrendIsStable() + { + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(90)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(85)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(30)), + CreateHealthEvent("UpstreamFailure", age: TimeSpan.FromMinutes(25))); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Stable, evidence.Trend.Trend); + Assert.AreEqual(2, evidence.Trend.RecentAdverseCount); + Assert.AreEqual(2, evidence.Trend.ComparisonAdverseCount); + } + + [TestMethod] + public async Task GetEvidenceAsync_CleanWatchBeforeForcedRetune_TrendIsWorsening() + { + // Clean watch before the adverse event should not flip the trend to Improving + await using var fixture = await ProfileFixture.CreateAsync(); + await fixture.SeedAsync( + CreateHealthEvent("CleanWatchCompleted", cleanWatchDurationMs: TimeSpan.FromMinutes(20).TotalMilliseconds, age: TimeSpan.FromMinutes(40)), + CreateHealthEvent("ForcedRetune", forcedRetune: true, age: TimeSpan.FromMinutes(15))); + + var evidence = await fixture.Service.GetEvidenceAsync("provider-1", "channel-1", new ReconnectOptions()); + + Assert.AreEqual(StreamChannelHealthTrend.Worsening, evidence.Trend.Trend); + Assert.AreEqual(TimeSpan.Zero, evidence.Trend.CleanWatchSinceLastAdverse); + } + + private static StreamChannelHealthEvent CreateHealthEvent( + string eventKind, + bool clientAbortAfterRecovery = false, + bool forcedRetune = false, + string? safeStartKind = null, + double? cleanWatchDurationMs = null, + TimeSpan? age = null) + => new() + { + StreamChannelHealthEventId = Guid.NewGuid().ToString("N"), + ProviderId = "provider-1", + ProviderChannelId = "channel-1", + DisplayName = "Test Channel", + EventKind = eventKind, + EventUtc = DateTime.UtcNow - (age ?? TimeSpan.FromMinutes(5)), + ClientAbortAfterRecovery = clientAbortAfterRecovery, + ForcedRetune = forcedRetune, + SafeStartKind = safeStartKind, + CleanWatchDurationMs = cleanWatchDurationMs, + }; + + private sealed class ProfileFixture : IAsyncDisposable + { + private readonly SqliteConnection _connection; + private readonly ServiceProvider _serviceProvider; + + private ProfileFixture( + SqliteConnection connection, + ServiceProvider serviceProvider, + StreamChannelHealthProfileService service) + { + _connection = connection; + _serviceProvider = serviceProvider; + Service = service; + } + + public StreamChannelHealthProfileService Service { get; } + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + + var services = new ServiceCollection(); + services.AddDbContext(_ => _.UseSqlite(connection)); + var serviceProvider = services.BuildServiceProvider(); + + using (var scope = serviceProvider.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + } + + var service = new StreamChannelHealthProfileService( + serviceProvider.GetRequiredService(), + NullLogger.Instance); + + return new ProfileFixture(connection, serviceProvider, service); + } + + public async Task SeedAsync(params StreamChannelHealthEvent[] events) + { + using var scope = _serviceProvider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.StreamChannelHealthEvents.AddRange(events); + await db.SaveChangesAsync(); + } + + public async ValueTask DisposeAsync() + { + await _serviceProvider.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/tests/M3Undle.Web.Tests/Streaming/UpstreamStreamConnectorTests.cs b/tests/M3Undle.Web.Tests/Streaming/UpstreamStreamConnectorTests.cs index d602f0c..0061f43 100644 --- a/tests/M3Undle.Web.Tests/Streaming/UpstreamStreamConnectorTests.cs +++ b/tests/M3Undle.Web.Tests/Streaming/UpstreamStreamConnectorTests.cs @@ -9,6 +9,7 @@ using M3Undle.Web.Data.Entities; using M3Undle.Web.Streaming.Configuration; using M3Undle.Web.Streaming.Models; +using M3Undle.Web.Streaming.Observability; using M3Undle.Web.Streaming.Upstream; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -328,6 +329,11 @@ public async Task ConnectAsync_WhenCleanRelayOn_PassesUserAgentAndHeadersToFfmpe StringAssert.Contains(argsText, "-headers"); StringAssert.Contains(argsText, "X-Auth-Token: abc123"); StringAssert.Contains(argsText, "-reconnect_streamed"); + StringAssert.Contains(argsText, "-avoid_negative_ts"); + StringAssert.Contains(argsText, "make_zero"); + Assert.IsFalse( + argsText.Contains("-use_wallclock_as_timestamps", StringComparison.Ordinal), + "Clean remux must not stamp live packets with wall-clock time; downstream HLS remuxers can preserve that as a large timeline jump."); } finally { @@ -459,6 +465,7 @@ public static async Task CreateAsync( var connector = new UpstreamStreamConnector( new FakeHttpClientFactory(handler), serviceProvider.GetRequiredService(), + NoopStreamChannelHealthProfileService.Instance, Options.Create(new ReconnectOptions { ConnectTimeout = TimeSpan.FromSeconds(5),