diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..d5193563c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,28 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "jbcontext index --silent", + "async": true + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "jbcontext index --silent", + "async": true + } + ] + } + ] + } +} diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..d17fc9aba --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "jbcontext": { + "type": "stdio", + "command": "jbcontext", + "args": [ + "mcp" + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index adb38d5c0..29bb811fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,8 +72,237 @@ `emby`, or `jellyfin`; Plex use cases should use the HDHomeRun integration instead. Existing generated TMDB marker paths remain read-compatible, but `style: plex` is no longer accepted in configuration. +- **`web_ui.auth.token_ttl_mins: 0` no longer means "never expire".** A configured `0` used to mint tokens with a + ~100-year lifetime — a permanent bearer credential written as if it were a configuration convenience. `0` now falls + back to the 24-hour default, and anything above the 30-day ceiling (`43200` minutes) is clamped; both log a warning + naming the effective lifetime. Deployments that relied on `0` for long-lived tokens must set an explicit value within + the ceiling, and their clients will start re-authenticating. + +- **A missing, malformed or wrong-scheme `Authorization` header now answers `401`, not `403`.** The auth extractors all + returned `403 Forbidden` for a request that never authenticated at all, and sent no `WWW-Authenticate` challenge — so + a `401` from this server was never a well-formed `401`. Responses now carry the challenge for the scheme the endpoint + wanted. Only an unresolvable peer address stays a `400`. Clients that branch on `403` to mean "not signed in" need to + handle `401`. + +- **Notification event ids replace `MsgKind`.** `messaging.notify_on` is now a list of glob patterns over dotted + `domain.event` ids — `*`, `recording.*`, `provider.*.expired`, and a leading `!` to exclude, so + `["*", "!system.info"]` reads the way it looks. Every legacy `MsgKind` name (`info`, `stats`, `disk_alert`, …) is + still accepted and resolves to its canonical id, but the config is **rewritten in canonical form the next time it is + saved**. Existing template filenames keep working; template maps are now keyed by event id wire name. + +- **Secrets are masked in config API responses.** `GET` of the main config previously returned `config.yml` in full to + any client holding `ConfigRead`, including the Telegram bot token, the Pushover token and user key, and any + `Authorization` header configured on the REST channel. Those — plus the new ntfy token, Gotify token and REST signing + secret — are now masked on the way out. A save restores any secret the client echoes back still masked, so a Web UI + round-trip cannot overwrite a real token with the mask; a genuinely changed secret still writes through. Tooling that + read provider credentials out of the config endpoint can no longer do so. + +- **A failed local library scan is now `library.scan.failed`, not `library.scan.completed`.** The taxonomy mapped + `LibraryScanProgress` to the completion id whatever the summary said, so the failure path reached operators as + "A local library scan finished" at info severity. Failure now takes its own id at error severity, discriminated on + the status the payload already carries. A `notify_on` glob such as `library.*` or `library.scan.*` picks the new id + up; a subscription naming `library.scan.completed` exactly will stop being told about failed scans and needs the new + id added. + ## 🌟 New Features +- **Ten events for the failures that used to be silent**: the registry described states nothing emitted, and several + subsystems reported their start and their success but never their own failure. The taxonomy is now 42 events (up + from 32) and gains two domains, `scheduled_task.*` and `notification.*`. + - **`system.started` / `system.shutdown`** had been registered — and documented — since the registry was written, + with nothing in the tree emitting either, so an operator who subscribed got silence. One payload carries both + kinds, so a subscriber can ask for restarts alone: the running version and the bound address on start, the signal + name on stop. Placement is the whole design: the start event is published *after* the notification bridge + subscribes, because anything published before it reaches nobody, and the stop event *before* the service tokens are + cancelled, because that stops the outbox that would carry it. Neither reaches the WebSocket — there is no panel + that renders them, and the Web UI has necessarily disconnected by the time the second one fires. + - **`provider.fetch.failed`** reports what kind of fetch failure it was. `ProviderErrorKind` already classified every + provider failure across all three families and already exposed `is_retryable()` and `needs_operator()` — the two + questions an operator actually asks — and nothing consumed either, so every fetch failure was counted, logged and + treated identically. The event carries the classification, the worst error's text, how many there were, and whether + any of the playlist came through anyway. Severity follows the classification rather than the registry: a `Config` + failure will not fix itself and is an error, everything else may and is a warning. Input name and error text go + through `sanitize_sensitive_info`, since both can carry a provider URL with credentials in it. + - **`provider.pool.exhausted` and `provider.priority.fallback`** — two moments the lineup manager knew about and told + nobody. `ActiveProvider` reported that connection counts moved; nothing reported that a stream was *refused* + because every provider behind the input was full. The per-provider current/max-plus-expiry snapshot that + `log_exhausted_pool_snapshot` built and then discarded unless debug logging happened to be on is now built + unconditionally on that (already slow) path, and the debug line and the event render from the same structured data. + Priority fallback — `acquire` walking priority groups high to low and silently falling through when the preferred + ones are at capacity — is reported on transition rather than per allocation, because the fall-through happens on + every request while the primary is full and one event per stream start would bury the thing worth hearing; a move + back towards group zero is a recovery and says so. Input and provider names are sanitized. + - **`user.connection.denied`**: `ActiveUser` reports connects and disconnects, and a refusal is neither, so the one + outcome a user actually complains about was the one nothing published — the admission ladder modelled it fully and + handed it to the caller and nobody else. It carries the user, the address the request was attributed to, and the + limit that was reached, and it takes `UserRead` rather than the system-wide read: "who was turned away" is the same + question as "who signed in". Only the strategy path emits; an explicit `Terminate` also resolves to exhausted, but + that is a requested teardown, not a denial. + - **`playlist.watch.disabled` / `playlist.watch.unmatched`**: `watch` had one event for everything it knows and three + ways to stop working without saying so — every pattern failing to compile, which disabled the feature on a typo + behind a single `warn!`; the target carrying the reserved default name; and a watch state file that could not be + read or written, which either re-baselined the group (losing the change it should have reported) or dropped it + entirely. All three now report the reason and, where there is one, the underlying error. The first needed the config + layer to stop discarding the distinction: an empty `Some` is now load-bearing and means "configured and unusable", + which is not the same as "not configured". `playlist.watch.unmatched` covers the fourth silence — a pattern matching + no group looks exactly like a group that has not changed, so a typo in `watch` was invisible. + - **`playlist.groups.changed`**: `watch` tracked channels inside named groups and was blind to the group set itself, + in both directions. A group appearing was silent — no baseline file, so one was written, nothing was emitted, and + the group's entire channel list read as "not new" from then on — and a group vanishing was worse, since it is absent + from the refreshed playlist and no code path observed the disappearance at all. The target's group titles are now + diffed against a persisted index before the per-group fan-out, so it sees every group rather than only the ones the + watch patterns name; the question is which groups exist, not what is inside the watched ones. The index sits beside + the per-group directory (`.groups.bin`, not `/__groups.bin`) so it cannot collide with a group whose + sanitized title matches, and first sight writes the baseline silently — announcing every existing group as new on + the first refresh after an upgrade would be noise. Gated on `target.watch` being configured, so it costs nothing for + targets that never asked to be watched. + - **`metadata.update.failed` for an input whose tasks burn through their retries**: the completion event only fires + when a cycle drains *with changes*, and an exhausted task only reached a `debug!`, so an input whose resolves fail + every time emitted a start and then nothing for as long as it stayed broken — on the bus, indistinguishable from one + still working through a long queue. The worker now counts the tasks that exhaust their retries during a cycle and + reports the input, the count, whether anything resolved anyway, and the last error. Reported alongside the + completion rather than instead of it: a cycle can both produce changes and exhaust tasks, and the completion is what + triggers the downstream playlist update. Per cycle rather than per task, since a provider that has stopped answering + fails every item behind it. + - **`scheduled_task.failed`**: the playlist update and the library scan report their own outcomes, but the GeoIP + refresh had no terminal event of its own — it logged one line and moved on — so an operator running on a stale + database never found out. It carries the task type and the cron expression that triggered it. The task is typed as + `ScheduleTaskType` rather than a free string, so a task added to that enum cannot be reported under a name nothing + recognises. A disabled GeoIP update stays silent: the task ran and correctly found nothing to do, which is not a + failure. + - **`notification.dead_lettered`** was registered and documented; the outbox detected the condition, bumped + `health().dead_lettered` and logged to `notification::audit`, and nothing subscribing to the bus could learn that a + notification had been permanently lost. It is now emitted at the point the outbox gives up, carrying the event id, + the attempt count, the channels that never accepted it, and when it was first enqueued. It is deliberately *not* + notifiable and deliberately absent from `NOTIFIABLE_KINDS`, so the bridge is not even woken for it: this event + exists because delivery failed, and enqueueing a notice about it into the same outbox against the same channels that + just failed is the loop its registry entry warns about. Operators still get the audit line and the counter, and + plugins see it on the bus. Emitted at the attempts-exhausted site only — a notification every channel rejects as + permanent is also dropped, but that path cannot yet be told apart from a clean delivery. + - **`library.scan.failed`** — see Breaking Changes. `EventKind` gains it alongside the progress kind rather than + reusing it: progress is high-frequency and a scan failure is not, so a subscriber that only wants failures should + not have to take the tick firehose to get them. + +- **Open-world notification system**: adding a notification channel or a notification event is no longer a change + across ten sites in three crates. + - **Events are ids, not an enum.** An event is a dotted `domain.event` string with a registered severity and + description. 42 events are registered today, spanning `system.*`, `playlist.*`, `recording.*`, `provider.*`, + `config.*`, `library.*`, `metadata.*`, `user.*`, `auth.*`, `stream.*`, `scheduled_task.*` and `notification.*`. The + Web UI event picker is driven by the registry, so an event added in the backend appears in the UI without a + frontend change, and template discovery + iterates the registry instead of a hardcoded eight-variant list that silently made new kinds undiscoverable. + - **Four new channels**: `ntfy` (self-hosted push, no account or bot token), `gotify`, `slack` (real Block Kit + header/section/context blocks rather than a re-used Discord embed), and `command`, which runs a local program with + the event JSON on stdin. The command channel executes the binary directly rather than through a shell, so there are + no quoting rules and no shell-injection surface from event content; a missing binary is a permanent failure, while a + non-zero exit or timeout is retried. + - **Webhook HMAC signing**: the REST channel takes an optional `signing_secret` and sends an HMAC-SHA256 of + `{timestamp}.{body}` as `X-Tuliprox-Signature`. The timestamp is inside the signed payload, so a captured request + cannot be replayed with a fresh header. Verified against the RFC 4231 test vector. + - **Per-channel routing**: each channel accepts an optional `routing` block (`notify_on`, `min_severity`, + `quiet_hours`, `max_per_hour`, `dedup_window_secs`), so "critical to Pushover, everything to Discord" is now + expressible. An absent block inherits the global subscription, so existing configs are unaffected. Quiet hours + **defer** rather than drop — an overnight outage nobody hears about afterwards is worse than one that arrives late — + and the hourly ceiling emits one "further notifications suppressed" line when it trips so the silence is + distinguishable from a dead notifier. + - **Durable delivery for every notification.** The outbox moved out of the recording supervisor, is no longer gated on + the recording config, and starts unconditionally once the listener is bound. Playlist stats, watch changes, disk + alerts and provider warnings previously fanned out and discarded every outcome, so a transient `502` lost them + permanently. Entries key pending channels by stable string id, so an outbox written by a build that knows a newer + channel no longer fails to deserialize and take every pending notification down with it. Entries left in + `recording_notification_outbox.json` are adopted into `notification_outbox.json` exactly once. + - **Failures are classified.** `408`/`429`/`5xx` are transient; other `4xx` are permanent and dead-letter immediately + instead of burning every attempt on a request that will fail identically forever. A provider's `Retry-After` — both + legal header forms, with a past HTTP-date clamped to "retry now" — wins over our own backoff rather than retrying + straight back into the rate limit. + - **Every event renders on every channel.** One notification envelope carries id, severity, timestamp, instance, dedup + key, title, body and the typed payload, with `title` and `body` always populated. Pushover gains template support, + sends `title` separately and maps severity onto its own priority scale — it previously pushed raw `serde_json` dumps + of watch changes and playlist stats to phones. Every channel's severity maps onto the target's own priority scale + rather than being dropped. + - **A test endpoint**: `POST /api/v1/config/messaging/test` renders and optionally sends a chosen event to a chosen + channel and returns the per-channel outcome *and* the exact rendered body. `preview: true` renders without sending, + so a template can be iterated without spamming a channel. It deliberately bypasses `notify_on` and the suppression + window — the operator asked for this one explicitly. + - **Typed provider account events**: `provider.account.status_changed`, `.expiring` and `.expired` replace account + status and expiry warnings that previously landed in the generic info/error buckets, so subscribing to "my account + is about to expire" no longer means also receiving every processing error. All three carry a dedup key, since they + are re-evaluated on every playlist refresh. + - Section 5 of the operator documentation is rewritten for this model: the glob grammar, a table of all registered + events with their default severities (checked against the registry by a test in both directions), per-channel + routing, delivery semantics, the new channels, and the uniform `event.*` template context alongside every legacy + key. The table sits between generated-block markers and is checked against the registry by a test in both + directions, so a registered event missing from the table — or a table row for an event that no longer exists — is a + test failure rather than stale documentation. + +- **Event bus as the single event backbone**: the WebSocket bus and the notification layer used to be two disconnected + worlds with their own emitters. They are now one taxonomy that plugins, notifications and the Web UI all read. + - The notification pipeline subscribes to the bus, so every bus event — playlist updates, config changes, library + scans, user connections, metadata updates, recording changes — can be notified on, and every future event comes + along with it. Everything defaults to unsubscribed, so an upgrade does not start messaging anyone until `notify_on` + asks for it. High-frequency variants (progress ticks, download deltas, periodic system info) are deliberately not + notifiable; their terminal counterparts are what get through. + - Nine notification-only lifecycle events moved onto the bus (disk alerts, config reload failures, playlist watch + changes, the three recording lifecycle events, and the three provider account events), so they now reach plugins and + subscribers rather than only operators on mail. + - **New events**: `user.created` / `.updated` / `.deleted` for API-proxy user CRUD, `stream.probe.failed` for ffprobe + failures, `config.reload_failed`, and the auth audit events below. User events carry username, target and state — + never the password or token; probe failures carry a sanitized URL and are deduplicated per input, so a provider + outage notifies once instead of once per channel behind it. + - **`GET /api/v1/events/stats`** (behind `system.read`, like `/status`) reports the bus counters — emissions per kind, + emissions with no subscriber, and the size of every gap a lagging subscriber was told about — plus a 256-entry ring + of recent events with their kind, uptime and outcome, including the ones that were coalesced. "Why did my + notification not fire?" was otherwise unanswerable without a debug build. + - **State snapshots on connect**: events that describe current state rather than an occurrence (the system-info and + downloads samples) are retained, so a Web UI session that connects between samples gets them immediately instead of + showing empty panels for up to three seconds. Occurrences are never replayed. + - **Graceful shutdown**: the stream-meter registry is flushed at shutdown, so a stream still running when the server + stops no longer loses its last window's transferred bytes. + - **Plugin subscription seam**: a plugin manifest's `events.*` list resolves to a subscription mask, with unknown names + reported rather than silently narrowing what the plugin asked for, and every event defines its own JSON payload. + +- **Authentication hardening**: sign-in throttling, token revocation, and an audit trail. + - **Login throttling**: `/auth/token` used to verify an argon2 hash, answer `401` and forget, so a password list could + be worked against it as fast as the hash function allows. Failures are now counted on two dimensions — client + address, which stops one host grinding a list, and username, which stops a distributed attack converging on one + account. Three free attempts, then 2s doubling to a 15-minute ceiling, answered as `429` with `Retry-After`. The + check runs *before* the argon2 verify, and a correct password clears the block immediately. Usernames are + canonicalised the way the rest of the auth path compares them, so `Alice` and `alice` share one budget. + - **Token revocation**: the tokens this server mints are stateless JWTs, so a leaked one previously stayed valid until + it expired — there was no way to end a session or respond to a compromise short of rotating the signing secret, + which kills every session at once. `POST /auth/revoke/{username}` ends one principal's sessions across both identity + namespaces and `POST /auth/revoke` ends everyone's; both require `UserWrite`. Revocation is a per-subject watermark + ("everything issued at or before this instant is dead") rather than a deny-list, so it is bounded in size and can + express "sign out everywhere" and "revoke everything issued before the breach". It is persisted — a revocation that + stopped applying at the next restart would be a security control in name only — and a revocation file that will not + parse is a startup error rather than an empty store. The refresh endpoint checks revocation too. + - **Auth audit events**: sign-ins, rejected sign-ins, throttled sign-ins and permission denials reach the bus as + `auth.sign_in.succeeded` / `.failed` / `.throttled` and `auth.permission.denied` — previously they went to a log line + and nowhere else, so the events that matter most for spotting an intrusion were the ones nothing could subscribe to. + Each is a separate event id, so a subscriber can ask for the failures without being woken by every successful + sign-in. The record holds a username, an address and an outcome; the password and the token are not in the type at + all rather than being redacted at each render site, because these records reach Telegram, webhooks and shell + commands. Notifications dedupe per principal, address and outcome, so a password-guessing run is one piece of news + rather than one per attempt. They require `UserRead`, not `SystemRead`, and are not pushed to the Web UI socket. + +- **Providers remember what they already told us**: Stalker capability knowledge — whether a portal implements + `get_all_channels`, which handshake recipe worked, which of several endpoint candidates answered — was discovered and + then thrown away, so every refresh re-probed endpoints already known to `404` and replayed a chain whose answer was + known. Replaying a full handshake chain against a portal with stale credentials looks, from the provider's side, a lot + like credential stuffing. The snapshot is a hint rather than a contract: every claim carries the instant it was + observed and expires after a day, a remembered endpoint is moved to the front of the candidate list rather than + replacing it, and a clock that has run backwards leaves the snapshot alone. A JSON-file store (one file per input, + written through the workspace atomic-write helper, ignoring a corrupt file rather than failing) is included for + persisting it across restarts; the composition root does not load or write it yet, so today the memory lasts for the + life of a client. + +- **Streaming provider catalogs**: `get_live_streams` and friends buffered an entire provider catalog into memory before + the caller saw a single row. They now have a streaming variant that hands over batches as they arrive, matching the + shape the bulk-EPG path already had. The trade is made explicit rather than hidden: the accumulating sink can still + restart pagination on the next endpoint candidate after a mid-catalog failure (which is why a truncated catalog is + never returned as success), while the streaming sink reports that it can no longer restart once a page has been + released, and an error there means the delivered batches are an incomplete prefix. + - **DVR Feature**: a full digital video recorder built around a queue-mutation boundary with a typed `QueueMutationError`, atomic edit/quota rollback, O(1) edit writes via a remembered `RecordingLocation`, server-side conflict preview (`POST /api/v1/recording/conflicts/preview`), and a `ConflictSeverity` of `NoKnownConflict` / `PossibleCapacityWait` / @@ -544,6 +773,75 @@ ## ⚙️ Optimizations +- **The effective admission strategy list is carried as `Arc<[AdmissionStrategy]>`**: `GraceResolutionContext` is + stored on `StreamInfo` and travels with every clone of it, so a `Vec` field meant reallocating the + list on each clone — and `get_effective_admission_strategies` handed back a fresh `Vec` that the context builder then + cloned again. The list is immutable once resolved, so the context clone is now a refcount bump and the one allocation + left is the `Arc::from` at resolution time. The strategy loop still takes a plain `&[AdmissionStrategy]`, reached by + deref, so slicing the remaining strategies is unchanged. + +- **Watch pattern matching walks the groups once**: matching re-tested every configured pattern per group inside a + filter. It now walks the groups once and records which patterns hit — which is also what makes + `playlist.watch.unmatched` possible. + +- **Notification templates are resolved and compiled once**: `resolve_template` wrapped every template value in an + input source and ran a full download attempt — once per message, per channel. A `file://` template was re-read from + disk and an `http://` one re-fetched over the network for every notification, and an *inline* Handlebars string paid + for a download attempt too. The source is now classified once (inline / file / URL), local files revalidate on mtime + so an edit applies immediately, remote documents cache on a 5-minute TTL, and compiled templates are kept in a + registry so rendering is no longer a re-parse. When a remote template cannot be refreshed the cached copy is served + rather than silently degrading to the built-in text. Config validation can now compile-check a template body without + sending, surfacing a malformed template at load instead of leaving a per-send error and a plausible-looking fallback. + +- **Notification sends are concurrent and bounded**: the outbox awaited channels in sequence and the shared HTTP client + sets no request timeout, so one webhook host that accepted a connection and never answered could stall every pending + notification — including the recording ones the outbox exists to protect. Sends now run concurrently per channel with + a 30-second request timeout. The channel set (and the `reqwest::Client` behind it) is built once and cached rather + than reconstructed on every send. + +- **Filtered event subscriptions**: every subscriber used to receive all event kinds and filter afterwards — after the + broadcast channel had already cloned the message for it. A subscriber now declares a one-word mask and is never woken + for what it did not ask for. The notification bridge is the first user: the four kinds it drops are the bulk of the + traffic during a playlist refresh. The two largest payloads (the system-info and downloads samples) are carried behind + `Arc`, costing a refcount bump per receiver instead of a deep copy, and the last subscriber standing pays nothing. + +- **Event coalescing for payload-free nudges**: the recording-changed nudge is emitted from six routes, twice + back-to-back where deleting a recording also changes the rules, and once per item in a bulk operation — each one + making every Web UI session re-fetch the same snapshot. Nudges that carry no payload are now coalesced in a 250 ms + window measured from the last admitted send, so a sustained stream is throttled to one per window rather than one per + burst and a later user action always produces a visible refresh. An event carrying a payload is never coalesced, + however repetitive, because a dropped tick loses the message it carried. + +- **Static dispatch through the messaging, event and processing paths**: the notification layer's + `Vec>` and its boxed future per send, the event sink, the metadata update sink, and the + playlist-update bootstrap were all trait objects behind heap-allocated futures. All of them are now static: channels + dispatch through an enum the compiler turns into a direct call, and the sinks are type parameters whose absent case + is a zero-sized no-op that compiles away entirely. The send path allocates nothing, and emitting an event on the + playlist pipeline is a direct call rather than a vtable hop. Same delivery semantics, same 46 messaging tests. + +- **Playlist repository iterators no longer box**: the hottest traversal in the repository returned + `Box` from three methods on a trait that is never used as a trait object, paying one allocation + per call plus one uninlinable indirect call per playlist item — and the cluster skip-set filter boxed a second time on + top. The adapter chains are replaced by named state machines and an enum per source kind, so a filtered traversal now + allocates nothing at all. Two async methods drop `BoxFuture` for plain `async fn` at the same time. + +- **One shared M3U/Xtream playlist backend**: the two raw-playlist iterators were transcriptions of one design — the + same two read locks, the same blocking producer feeding a bounded channel, the same sorted-index reader — differing + only in item type, storage subdirectory and error constructor. Those differences are now associated types and consts + on a zero-sized marker, so 151 lines of duplicated logic became one implementation with entirely static dispatch. The + one behavioural difference that was preserved rather than normalised (M3U holds its read lock for the consumer's + lifetime, Xtream never did) is now named in the type rather than implicit. + +- **Redundant `Arc` clones dropped from the Xtream per-item parse loop**: four fields wrapped a clone around an accessor + that already returns an owned value, so each parsed Xtream stream paid four redundant atomic increment/decrement pairs + on the playlist parse path. The workspace now has zero `Arc::clone(&x.y())` sites. + +- **Typed field access on playlist item headers**: the mapper, sort and counter paths reached fields by string name, + walking a chain of ~20 case-insensitive comparisons and returning an owned `Arc` — so reading `chno` or `type` + did a heap allocation *and* an interner write lock, on every read, per item, per rule. Field access is now keyed on a + typed enum with a borrowing read that never forces an allocation, and mapper counter fields are parsed once at config + load instead of re-parsed per channel inside the counter loop. + - **BPlusTree store path rewritten to stream to disk**: `BPlusTree::store` no longer buffers a full `Vec<[u8; PAGE_SIZE]>` (up to ~270 MiB for large playlists) before writing. A new `PageSink` hands out page ids and writes each finished page positionally to the destination file. Pages are written out of @@ -620,6 +918,109 @@ ## 🐛 Fixes +- **Admission: a request that ended up denied anyway could leave several other streams killed behind it.** Eviction is + destructive and is never rolled back, but the strategy loop would kick a target, find the retry still denied, and + move straight on to the next eviction strategy. The loop now samples `user_connections` either side of the kick. A + kick that reduces the count is real progress and later strategies still run, which keeps the over-limit case — a + hot-swapped config that lowered `max_connections` — converging; a kick that frees nothing skips further `Evict` + decisions for the rest of the walk. `Grace` strategies are still evaluated either way. +- **Admission: a request that lost the queue race walked the eviction strategies on a stale count.** The resolver read + the admission state, then queued on the per-user gate, then used the snapshot it had taken *before* it queued — so it + could evict a live connection to free a slot the winner had already released. The state is re-read after the gate + (and after the empty-strategy check, so the uncontended path costs nothing extra) and the grace context captures the + fresh kind. This does not close the wider check-then-register window: the slot is registered by the caller outside + this gate, so two requests at `max_connections - 1` can still both be admitted. +- **Admission: a suppressed eviction misaligned the strategy index and replayed a grace strategy.** The loop counted + with a manual index incremented at the end of the body, and the eviction-reentry suppression arm exits via `continue` + and so skipped it, handing every later strategy an index one too low. That index is what + `GraceResolutionContext.strategy_index` stores, and the post-grace fallback resumes at `strategy_index + 1`: with + `[EvictUserOldest, GraceHoldStream]` and the eviction suppressed, the grace recorded index 0, so the fallback slice + restarted at the grace strategy itself and replayed it instead of moving past it. Now counted with + `iter().enumerate()`, so the index cannot drift from the item. +- **Events: watch payloads carried synthesized prose where a plugin expected channel titles.** The watch handler + truncated its own lists by pushing a sentence into them — "... 42 more added entries omitted" beside real channel + titles, or "5000 entries added. Detailed list suppressed" replacing the list outright. That was legible to the text + template and to nothing else: the payload is serialized straight to JSON for plugins, and a plugin has no way to tell + a sentinel from a channel actually named that. The subject line had the same problem from the other side — it read + `added.len()`, so a suppressed change of five thousand announced itself as "1 channel(s) added". `WatchChanges` now + carries `added_total`, `removed_total` and `truncated`; the lists stay pure channel titles, the counts stay true + whatever the lists carry, and the plain-text renderer spells the omission out itself. `WatchChanges::new` sets the + totals from the lists, so a caller that is not truncating cannot get them out of step. +- **Events: a failed local library scan reported itself as finished.** The failure path emitted the same progress + variant with `status: Error`, and the taxonomy mapped that variant to the completion id unconditionally, so a failure + reached operators as "A local library scan finished" at info severity. The id is now discriminated on the status the + payload already carries — the way playlist updates have always discriminated on their state — and severity comes from + the registry with no second table. The emitter was already reporting the status correctly; nothing downstream was + reading it. See Breaking Changes for the subscription consequence. + +- **Messaging: an edited bot token, webhook URL or template did not take effect until a restart.** The channel set and + the compiled templates are cached so a notification does not rebuild every channel — and a fresh HTTP client with + them — on every send, but nothing invalidated those caches on config reload. They are now invalidated when the config + reloads. +- **Messaging: a successful playlist refresh with statistics notified twice.** The run summary went straight to the + notification layer as a second message while the bus carried the bare outcome, and both resolved to + `playlist.update.completed` — so one refresh produced two notifications, neither carrying the other's content, and a + bus subscriber saw an outcome with no detail. The outcome, per-source statistics and aggregated error text are now one + event emitted once at the end of the run. The WebSocket frame is unchanged, so the Web UI sees what it always did. +- **Messaging: disk alerts were gated on somebody being subscribed by mail.** The emission itself checked the + subscription, so a plugin or any other consumer watching for disk pressure saw nothing unless an operator happened to + want the same event on the same channel. The notification layer already drops unsubscribed events, so the check is + gone. +- **Auth: changing a password invalidated nothing.** `pwd_version` was minted into every web token and checked in + exactly one place — the refresh endpoint — so a token issued against the old password kept working on every guarded + route until it expired. Together with `token_ttl_mins: 0` meaning ~100 years, a leaked token was effectively a + permanent credential. The check now runs on every request whose principal is a web user, and rejects `pwd_version: 0` + rather than treating it as "skip", which is how the refresh endpoint's own copy could be bypassed. Users will be + signed out after a password change, which is the intent. +- **Auth: revoking a permission had no effect until the token expired.** The permission check read the snapshot minted + into the token. The effective set is now the intersection of the claim with what the live config grants, so a + revocation takes effect on the next request. A new *grant* still requires a refresh, because a token must never end up + with more authority than it was issued with. Three permission paths that had drifted apart — one with no schema gate, + no subject gate and no password-version check at all — now share one implementation. +- **Auth: the configured JWT issuer was never validated.** Token validation checked expiry and nothing else, so `iss` + was decoration. It is now checked, including on the WebSocket paths, which previously carried a bare secret across + task boundaries and so had no issuer to check against. +- **Auth: an access token minted for one purpose was valid everywhere.** Internal access tokens signed only a timestamp + and a TTL, so any valid token verified at every place a token was accepted. The capability scope is now mixed into the + keyed hash and is a compile-time constant on both sides, never caller-supplied. The token string format is unchanged. +- **Auth: renaming a user orphaned their recordings.** The JWT subject was synthesised from the display name + (`web:{username}` / `api:{username}`), so a rename reassigned every recording the old subject owned to a principal + that does not exist. Subjects now come from the identity registry, which was already built with persistence, + bootstrap and a rename that preserves the id, and was simply never wired into the server. A corrupt registry refuses + to start rather than inventing replacement ids. +- **Auth: passwords typed at the terminal were left in memory.** The interactive password generator left two plaintext + `String`s sitting after it returned; they are now wiped, the same discipline already applied to a password arriving + over HTTP. A dead duplicate of the credential type that was never declared in its crate root has been removed. +- **Notifications: a typo'd webhook burned every retry attempt.** Delivery outcomes could not distinguish "retry me" + from "this URL is malformed and will fail identically forever", so a permanent failure ran the full exponential + backoff before dead-lettering, and a `429` was retried straight back into the rate limit it had just hit. +- **Notifications: a newly added event kind was silently undiscoverable.** Template discovery iterated a hardcoded + variant list rather than the event registry, so a new kind's templates were never found — a failure with no error + message. It now iterates the registry, and still finds legacy template filenames. +- **Events: several event kinds reached no WebSocket subscriber at all.** The wire mapping was a hundred-line match + nested three deep inside the socket loop, where a kind reaching no arm looked exactly like a kind deliberately + ignored — and the test meant to catch that had been failing since the disk-alert event joined the bus. The mapping is + now a pure function with tests that iterate every event kind, so a variant added later fails the tests instead of + silently reaching nobody. +- **Events: a stream ending at shutdown lost its last window of transferred bytes.** The meter sampler was cancelled in + `Drop`, which cannot await. The registry is now flushed explicitly at shutdown, after the connection manager, so the + final batch reports what the streams actually transferred. +- **Events: the bus dropped events under load with nothing to show for it.** Capacity was hardcoded at 10 for + everything, which a playlist refresh routinely outruns — the evidence was already in the tree, in a dedicated lag arm + in the notification bridge and an entire resync recovery path in the WebSocket. Capacity is now configurable and + defaults to 256, and drops are counted and reported at `GET /api/v1/events/stats`. +- **Config: mapper and counter field names were rejected for their casing.** The allow-list was compared + case-sensitively while the field accessor compared case-insensitively, so a mapper naming `NAME` was rejected at + config load even though writing it would have worked. Both now resolve through the same typed parse. Every previously + valid config stays valid; some previously rejected ones are now accepted and behave correctly. +- **Config: a config report listed only the first bad rule.** Sort rules and target renames aggregate every child's + error again, so a config with three bad rules reports all three in one pass rather than one round-trip at a time. +- **Stalker: a `403` was retryable or not depending on which layer noticed it.** The same refusal arrived as either a + token rejection or a bad status, and callers were matching on variants to answer questions the variants were never + organised around. Errors now carry a classification, and auth failures are deliberately not retryable so nothing loops + on a rejected token. Provider redaction is also unified: the three unrelated answers to "what must never reach a log + line" (error URLs, the debug-dump writer's inline key list, and the Xtream sanitizer) are now one module with one + key list, and the JSON redaction walk catches nested keys, which the debug-dump writer's own copy never did. - **Web UI: disk-space alerts could not be enabled from Config → Messaging.** The messaging form only included the `messaging.disk_alert` block when a threshold field was touched, and the save-time cleanup dropped the block whenever all thresholds still equalled their defaults — even though the block's presence is what enables the alerts. The @@ -775,6 +1176,18 @@ slots only from providers with active connections, so an enabled alias without connections was invisible and its spare capacity ignored. Slots are now derived from the configured enabled members (missing live counts default to zero), matching the `/ready` endpoint. +- **Xtream: a VOD document could carry a blank `container_extension`, and players appended `.null` to the playback + URL.** The field is filled from the provider's `get_vod_streams` response, where a missing or null value collapses + to an empty string, and only a per-item `get_vod_info` fetch or an ffprobe run fills it in afterwards. A provider + that omits it therefore left every VOD document carrying `""`, and a client building + `.` has nothing to append — several render the blank as the literal string `null` + and go on to request `813563.null`. `get_vod_info` already fell back to the extension carried by the item URL; the + four document builders that did not — the stream-list document, both no-properties paths, and the resolved info + document, which delegates to a `StreamProperties` method with no URL to consult — now share that fallback. A + non-empty provider value still wins, and an item whose URL carries no extension either still reports the blank. + `create_vod_info_from_item` had the fallback but kept the leading dot that `extract_extension_from_url` returns, so + a URL-derived extension was published as `.mkv` and would have built `813563..mkv`; it is stripped now. Series + episodes are unchanged: their properties carry no provider URL at that layer. ## ⚙️ New Settings @@ -820,10 +1233,37 @@ `custom_stream_response_enabled` is `false`. Must be a 4xx or 5xx code (`ConfigDto::prepare()` rejects anything else; `0` is silently clamped to the default `502`). Operators can match the code to their Nginx `proxy_intercept_errors on;` rules. + - Added `event_channel_capacity` (`u32`, default `256`, clamped to at least `1`): capacity of the internal event + broadcast channel. It was previously hardcoded at `10`, which a playlist refresh routinely outruns — a subscriber + that awaits I/O per event falls behind within one target. Drops are visible at `GET /api/v1/events/stats`. - **config.yml (`reverse_proxy.stream`)**: - Added `admission_strategies` (optional list): ordered list of admission strategy rules. Available strategies: `evict_user_same_ip_oldest`, `evict_user_same_ip_latest`, `evict_user_oldest`, `evict_user_latest`, `grace_instant_stream`, `grace_hold_stream`. +- **config.yml (`messaging`)**: + - `notify_on` is now a list of glob patterns over dotted event ids (`*`, `recording.*`, `provider.*.expired`, and a + leading `!` to exclude). Legacy `MsgKind` names still parse and are normalized on the next save. + - Added an `ntfy` channel: `url`, `topic`, optional `token`, optional `templates`, optional `routing`. + - Added a `gotify` channel: `url`, `token`, optional `templates`, optional `routing`. + - Added a `slack` channel: `url` (incoming webhook), optional `templates`, optional `routing`. + - Added a `command` channel: `program`, optional `args`, optional `timeout_secs`, optional `templates`, optional + `routing`. The program is executed directly, not through a shell, and receives the event JSON on stdin. + - Added `rest.signing_secret` (optional): enables HMAC-SHA256 signing of `{timestamp}.{body}`, sent as + `X-Tuliprox-Signature`. + - Added an optional per-channel `routing` block on all eight channels. An absent block inherits the global + subscription: + - `notify_on` (list of glob patterns): overrides the global subscription for this channel. + - `min_severity` (`info` | `warn` | `error` | `critical`): drops anything below it. + - `quiet_hours` (`HH:MM-HH:MM`, local time): notifications inside the window are **deferred** by the outbox, never + dropped. An entry is only held while every still-pending channel is asleep. + - `max_per_hour` (`u32`): circuit breaker. On reaching it the channel sends one "suppressing further notifications" + message and then goes quiet for the rest of the hour. + - `dedup_window_secs` (`u64`): suppresses a repeated `dedup_key` for this many seconds. Generalizes the disk + alert's `repeat_interval_secs`, which was previously available to nothing else. + - Templates are now supported on every channel including Pushover, keyed by event id wire name, and every template + receives a uniform `event.*` context alongside every legacy top-level key, so templates written against the + documented examples render identically. + - **config.yml (`messaging.disk_alert`)**: - Added optional `disk_alert` block to enable disk-usage alerts via the existing messaging channels. The background monitor in `backend/app/src/api/sys_usage.rs` samples the current working directory's mount on @@ -886,6 +1326,38 @@ ## 🛠 Maintenance +- **`AdmissionRequest` bundles the request-scoped admission arguments**: five functions each threaded the same ten + positional parameters, three of them consecutive bare `bool`s (`use_session_admission`, then + `activate_unbound_session` a slot later). Call sites read `..., true, Some(session_token), true, guard)` — a shape + where transposing two arguments still compiles and silently changes which admission check runs. One struct now names + every field at the call site, and the comment that lived in the parameter list moved onto the field it documents. + This removes three `#[allow(clippy::too_many_arguments)]` and one `clippy::too_many_lines`. + +- **The empty-admission-strategy-list rule is stated rather than implied**: the resolver matched on + `admission_strategies.is_some()` and then re-unwrapped with `unwrap_or_default()`, so the guard proved something the + body checked again — and the rule that an explicitly empty list suppresses the `grace_period_millis` fallback while an + absent list does not was implicit in the arm ordering. Rewritten as a match on `as_ref()` with the distinction + spelled out. `Some(vec![])` still means "no strategies", not "fall back to grace". + +- **Dead admission parameter and a doc block that described a decision that does not exist**: the strategy loop took a + `kind_for_exhausted` it never read (both callers construct the exhausted result themselves), and the doc block on the + post-grace fallback listed a `Deny` rule although `AdmissionDecision` only has `NoMatch`, `Grace` and `Evict`. + +- **The documented event table is complete again**: eight events registered by the user-lifecycle and auth work — + `user.created`, `user.updated`, `user.deleted`, the four `auth.*` decisions and `stream.probe.failed` — were never + given a row, so `every_registered_event_appears_in_the_docs_table` had been failing since before those events landed. + The rows are generated from the descriptors, so severity and description match the registry exactly. A run of + twenty-seven spaces left inside the `stream.probe.failed` description by a collapsed wrapped literal is normalised in + the registry and the table together. + +- **The notification bridge stays a routing table**: fourteen new events left `to_notification` doing its own wording + inline, at 213 lines. Each event's wording moved into a `*_notification` builder beside the three that already + existed, so the match is one arm per variant with no logic in it, and the "... N more not listed" wording has one home + shared by both events that carry sampled lists rather than a copy in each. + +- **`EventBusStats::Default` is hand-written**: the taxonomy crossed 32 kinds, and the derived `Default` for arrays + stops there. + - Moved provider-specific M3U, Xtream and Stalker protocol code from the generic utility namespace into dedicated IPTV modules. @@ -909,6 +1381,74 @@ `TextArea` primitives into a single shared `FieldWrapper` component, reducing duplication while keeping the rendered markup and behavior unchanged. +- **Units and identity in the type system**: several classes of value that were bare integers or strings now carry their + meaning in their type, with no change to the serialized form and no config migration: + - `Millis` / `Secs` for HLS timing config, applied through the DTO → runtime hop rather than unwrapped at the + boundary, so a millisecond value can no longer reach a seconds parameter — the two sat two lines apart in the same + struct as bare `u64`s, and `cache_duration` and `session_idle_timeout` did not carry their unit in their names at + all. + - `Bytes` for resolved byte sizes, so a parsed size is distinguishable from any other `u64` and a runtime struct can + no longer hold an unparsed size by accident. + - `VirtualId` and `ProviderId` as real newtypes. The same store is keyed by a virtual id on the target path and a + provider id on the input path, and nothing stopped a lookup in one key space using an id from the other. There is + deliberately no implicit conversion in either direction. On-disk compatibility is pinned by a B+Tree codec test + asserting a transparent newtype over `u32` encodes byte-for-byte identically and cross-reads in both directions, so + existing databases are unaffected and there is no migration. + - The Xtream store's key space is now a type parameter rather than a runtime tag matched per item inside the insert + loop, so the two key spaces can no longer be swapped by passing the wrong enum variant. + +- **One shape for config preparation and error reporting**: + - A `Prepare` trait replaces ~98 inherent `prepare`/`validate` methods that had no agreed signature — some took + nothing, some pattern templates, some a storage dir, a port or a boolean, returning four different result types. + Because the shape was invisible, the recursive walk was hand-written at every level and a config struct that forgot + to call its children failed silently at runtime rather than at compile time. Dispatch stays entirely static. + - `TuliproxError` splits into a `Copy` kind and a message. All 50 variants carried exactly one string, so it was a + category tag beside a message encoded as an enum — costing a 50-arm accessor and a second 50-name list that had to + be kept in sync by hand, and making the category impossible to compare, store or return on its own. The 755 + construction sites are untouched. + - A `Clock` seam replaces 14 character-for-character copies of `current_time_millis` across two crates. It is meant to + be held as a generic parameter defaulted to a zero-sized type, never as a trait object; a test asserts owning one + leaves a struct's layout unchanged. + +- **Single-sourced relations that were written down more than once**: + - The item-type-to-cluster relation existed in three places with nothing keeping them in agreement, and its conversion + was total while returning a `Result` — a phantom error that had spread defensive fallbacks to 17 call sites across + four crates. All 17 drop their fallback. + - Genre access was a four-arm match written out five times; it is now two methods. + - The mapper and counter field allow-lists are typed rather than string lists, which also collapsed a duplicated EPG + channel-id entry that existed only to cover both accepted spellings. + - Three macros generating by-name field accessors are gone. One of them — an ~80-line prefix-matched lookup for Xtream + cover and backdrop resources — turned out to have no callers at all and was deleted rather than ported. + +- **Provider fetches have one shape**: the three provider families were modelled three different ways and each returned + a differently-shaped tuple, so the dispatcher was a ninety-line match whose eight arms hand-assembled a six-element + tuple, padding fields their provider does not produce with literal zeros and then destructuring by position — two of + the six elements were dead on arrival. One trait with one named result type replaces it, dispatch stays a statically + dispatched match, and the two unsupported input types now carry their reason. + +- **Stalker client seams and test coverage**: the Stalker API client owned its HTTP client and read the system clock + directly, which put every interesting decision it makes behind a live portal — the module docs conceded outright that + no HTTP requests are issued from unit tests. Both are now type parameters defaulted to the production implementation, + neither introducing a vtable or an allocation. Nine tests now cover paths that previously had no way to be reached at + all, including a portal refusal hidden inside a `200 OK`, an over-cap body being refused rather than buffered, + endpoint-candidate failover in priority order, and a session ageing past its TTL on a clock that can be advanced. + Expiry rules — session staleness, cookie `Max-Age`, and the Xtream account-expiry warning — now take the instant as a + parameter rather than reading the clock, so the cookie boundary is asserted at the exact second it flips and the + three-day expiry window has tests for all three branches instead of none. + +- **Stalker page arithmetic has one home**: the rule for "is this the last catalog page" was written out four times and + two copies had already drifted in how they measure progress against the advertised total. The two per-row-type page + parsers collapse into one generic walk as well; they differed only in the row type and both hand-rolled the same four + envelope shapes. + +- **`exec_processing` takes a run object**: it had twelve positional parameters, seven of them `Option`, so a call site + was a wall of `None`s where the reader had to count commas and the compiler could not catch two same-typed arguments + being swapped. The CLI path is now a single constructor call. + +- **Workspace dependency edges reduced from 78 to 75**: the DVR crate no longer depends on the streaming-session + runtime (the event bus was the only thing it wanted, and a trait bound is not a dependency), and neither the IPTV nor + the processing crate depends on the messaging crate any more — emitting an event is not knowing how it is delivered. + ## 3.3.0 (2026-04-02) ## ⚠️ Breaking Changes 3.3.0 diff --git a/Cargo.lock b/Cargo.lock index 995337f27..2bddcfd65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,6 +618,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.5" @@ -804,6 +810,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -990,6 +1005,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -1119,7 +1135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -1223,15 +1239,15 @@ dependencies = [ "fastrand", "futures", "futures-signals", + "gloo-net", "gloo-storage", "gloo-timers", - "gloo-utils 0.2.0", + "gloo-utils", "i18nrs", "indexmap", "js-sys", "log", "regex", - "reqwasm", "serde", "serde_json", "shared", @@ -1248,16 +1264,6 @@ dependencies = [ "yew-router", ] -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "fs_extra" version = "1.3.0" @@ -1457,11 +1463,11 @@ dependencies = [ "gloo-events", "gloo-file", "gloo-history", - "gloo-net 0.5.0", + "gloo-net", "gloo-render", "gloo-storage", "gloo-timers", - "gloo-utils 0.2.0", + "gloo-utils", "gloo-worker", ] @@ -1471,7 +1477,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a17868f56b4a24f677b17c8cb69958385102fa879418052d60b50bc1727e261" dependencies = [ - "gloo-utils 0.2.0", + "gloo-utils", "js-sys", "serde", "wasm-bindgen", @@ -1519,7 +1525,7 @@ checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" dependencies = [ "getrandom 0.2.17", "gloo-events", - "gloo-utils 0.2.0", + "gloo-utils", "serde", "serde-wasm-bindgen", "serde_urlencoded", @@ -1528,26 +1534,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "gloo-net" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2899cb1a13be9020b010967adc6b2a8a343b6f1428b90238c9d53ca24decc6db" -dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "gloo-utils 0.1.7", - "js-sys", - "pin-project", - "serde", - "serde_json", - "thiserror 1.0.69", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "gloo-net" version = "0.5.0" @@ -1557,7 +1543,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-sink", - "gloo-utils 0.2.0", + "gloo-utils", "http 0.2.12", "js-sys", "pin-project", @@ -1585,7 +1571,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbc8031e8c92758af912f9bc08fbbadd3c6f3cfcbf6b64cdf3d6a81f0139277a" dependencies = [ - "gloo-utils 0.2.0", + "gloo-utils", "js-sys", "serde", "serde_json", @@ -1606,19 +1592,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gloo-utils" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" -dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "gloo-utils" version = "0.2.0" @@ -1640,7 +1613,7 @@ checksum = "085f262d7604911c8150162529cefab3782e91adb20202e8658f7275d2aefe5d" dependencies = [ "bincode", "futures", - "gloo-utils 0.2.0", + "gloo-utils", "gloo-worker-macros", "js-sys", "pinned", @@ -1698,9 +1671,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -1788,6 +1761,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "0.2.12" @@ -3245,15 +3227,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwasm" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b89870d729c501fa7a68c43bf4d938bbb3a8c156d333d90faa0e8b3e3212fb" -dependencies = [ - "gloo-net 0.1.0", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -3420,7 +3393,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -3478,7 +3451,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -3992,7 +3965,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -4446,6 +4419,7 @@ dependencies = [ "base64 0.23.1", "blake3", "chrono", + "dashmap", "jsonwebtoken", "log", "rand 0.9.5", @@ -4463,7 +4437,6 @@ name = "tuliprox-btree" version = "3.3.92" dependencies = [ "crc32fast", - "fs2", "log", "lz4_flex", "memmap2", @@ -4567,7 +4540,6 @@ dependencies = [ "tuliprox-core", "tuliprox-messaging", "tuliprox-repository", - "tuliprox-session", "url", "windows-sys 0.61.2", ] @@ -4619,6 +4591,7 @@ dependencies = [ "bytes", "chrono", "futures", + "http 1.4.0", "log", "parking_lot", "reqwest", @@ -4630,7 +4603,6 @@ dependencies = [ "tokio", "tokio-util", "tuliprox-core", - "tuliprox-messaging", "tuliprox-parser", "tuliprox-repository", "url", @@ -4687,14 +4659,18 @@ version = "3.3.92" dependencies = [ "arc-swap", "chrono", + "futures", "handlebars", + "hmac", "log", "mime", "reqwest", "serde", "serde_json", + "sha2", "shared", "tokio", + "tokio-util", "tuliprox-core", "url", ] @@ -4791,7 +4767,6 @@ dependencies = [ "tuliprox-iptv", "tuliprox-library", "tuliprox-media-server", - "tuliprox-messaging", "tuliprox-parser", "tuliprox-repository", "tuliprox-session", @@ -4808,7 +4783,6 @@ dependencies = [ "crc32fast", "env_logger", "filetime", - "fs2", "futures", "indexmap", "libc", @@ -5201,7 +5175,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c13e28da2..679879bf8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ cron = "0.17.0" futures = "0.3.34" serde-saphyr = "0.0.26" sha2 = "0.11.0" +hmac = "0.13.0" thiserror = "2.0.20" tokio = { version = "1.53.1" } strum = "0.28.0" diff --git a/backend/app/src/api/api_utils.rs b/backend/app/src/api/api_utils.rs index d94c622e7..235681a42 100644 --- a/backend/app/src/api/api_utils.rs +++ b/backend/app/src/api/api_utils.rs @@ -46,14 +46,13 @@ use axum::{ use bytes::{Bytes, BytesMut}; use chrono::{DateTime, Utc}; use futures::{stream, Stream, StreamExt, TryStreamExt}; -use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use log::{debug, error, info, log_enabled, trace, warn}; use serde::Serialize; use shared::{ concat_string, defaults::{DASH_EXT, HLS_EXT}, model::{ - Claims, ConfigTargetOptions, InputFetchMethod, InputType, PlaylistEntry, PlaylistItemType, ProxyType, + ConfigTargetOptions, InputFetchMethod, InputType, PlaylistEntry, PlaylistItemType, ProxyType, StalkerStreamKind, StreamChannel, StreamInfo, TargetType, UserConnectionPermission, VirtualId, XtreamCluster, }, utils::{ @@ -323,7 +322,8 @@ pub(crate) use tuliprox_core::utils::request_headers::{get_headers_from_request, pub(crate) use tuliprox_session::{ admission::{ classify_playback_request, connection_priority_for_kind, resolve_admission_with_strategies, - resolve_playback_request_admission, EvictionReentryGuard, PlaybackRequestClass, PlaybackRequestFacts, + resolve_playback_request_admission, AdmissionRequest, EvictionReentryGuard, PlaybackRequestClass, + PlaybackRequestFacts, }, stream_options::{get_stream_options, StreamOptions}, }; @@ -506,7 +506,6 @@ async fn activate_session_before_stream_open( let current_session = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; classify_playback_request(PlaybackRequestFacts { - item_type, existing_session: current_session.as_ref(), prepare_only: false, terminate: false, @@ -517,7 +516,6 @@ async fn activate_session_before_stream_open( } else { let existing_session = app_state.active_users.get_and_update_user_session(&user.username, session_token).await; classify_playback_request(PlaybackRequestFacts { - item_type, existing_session: existing_session.as_ref(), prepare_only: false, terminate: false, @@ -636,7 +634,7 @@ async fn activate_session_before_stream_open( .ensure_user_session_placeholder(crate::api::model::CreateUserSessionParams { user, session_token, - virtual_id, + virtual_id: virtual_id.get(), provider: input.name.as_ref(), stream_url, addr: &fingerprint.addr, @@ -649,18 +647,20 @@ async fn activate_session_before_stream_open( let result = resolve_admission_with_strategies( &app_state.admission_ctx(), - &user.username, - user.max_connections, - user.soft_connections, - &fingerprint.client_ip, - &fingerprint.addr, - true, - Some(session_token), - true, - if socket_bound { - EvictionReentryGuard::SocketPlayback { virtual_id } - } else { - EvictionReentryGuard::Session(session_token) + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some(session_token), + activate_unbound_session: true, + eviction_reentry_guard: if socket_bound { + EvictionReentryGuard::SocketPlayback { virtual_id } + } else { + EvictionReentryGuard::Session(session_token) + }, }, ) .await; @@ -1937,7 +1937,7 @@ pub async fn force_provider_stream_response( preferred_provider, allow_forced_provider_fallback, allow_provider_grace, - stream_channel.virtual_id, + VirtualId::new(stream_channel.virtual_id), connection_priority_for_kind(ctx.user, connection_kind), connection_kind, true, @@ -2080,7 +2080,7 @@ pub(crate) async fn stream_response( user, session_token, request_class, - virtual_id, + virtual_id: VirtualId::new(virtual_id), item_type, stream_url, connection_permission, @@ -2192,7 +2192,7 @@ pub(crate) async fn stream_response( pinned_provider, pinned_provider.is_none(), true, - stream_channel.virtual_id, + VirtualId::new(stream_channel.virtual_id), connection_priority_for_kind(user, connection_kind), connection_kind, false, @@ -2235,7 +2235,7 @@ pub(crate) async fn stream_response( input, fingerprint, session_token, - virtual_id, + virtual_id: VirtualId::new(virtual_id), connection_permission, connection_kind, fallback_stream_url: stream_url, @@ -2538,7 +2538,7 @@ async fn detected_catchup_hls_response(params: DetectedCatchupHlsResponseParams< content, hls_url: response_url, target_id: target.id, - virtual_id, + virtual_id: virtual_id.get(), input_id: input.id, user_token: Some(session_token), }, @@ -2550,7 +2550,7 @@ async fn detected_catchup_hls_response(params: DetectedCatchupHlsResponseParams< .create_user_session(crate::api::model::CreateUserSessionParams { user, session_token, - virtual_id, + virtual_id: virtual_id.get(), provider: &provider, stream_url: request_url, addr: &fingerprint.addr, @@ -3032,7 +3032,7 @@ pub(crate) async fn local_stream_response( user, session_token, request_class, - virtual_id: pli.virtual_id, + virtual_id: VirtualId::new(pli.virtual_id), item_type: pli.item_type, stream_url: &pli.url, connection_permission, @@ -3480,15 +3480,12 @@ pub fn empty_json_list_response() -> axum::response::Response { } pub fn get_username_from_auth_header(token: &str, app_state: &Arc) -> Option { - if let Some(web_auth_config) = &app_state.app_config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()) { - let secret_key: &[u8] = web_auth_config.secret.as_ref(); - if let Ok(token_data) = - decode::(token, &DecodingKey::from_secret(secret_key), &Validation::new(Algorithm::HS256)) - { - return Some(token_data.claims.username); - } - } - None + let config = app_state.app_config.config.load(); + let web_auth_config = config.web_ui.as_ref()?.auth.as_ref()?; + // This hand-rolled its own `decode` with a bare `Validation::new`, which + // checks `exp` and nothing else - no issuer. + crate::auth::verify_token(token, web_auth_config.secret.as_bytes(), &web_auth_config.issuer) + .map(|token_data| token_data.claims.username) } pub fn redirect(url: &str) -> impl IntoResponse { @@ -5292,6 +5289,13 @@ mod tests { geoip, update_guard: crate::api::model::UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } @@ -5399,7 +5403,6 @@ mod tests { #[test] fn classify_playback_request_marks_adaptive_playlist_request_as_prepare() { let request_class = classify_playback_request(PlaybackRequestFacts { - item_type: PlaylistItemType::LiveHls, existing_session: None, prepare_only: true, terminate: false, @@ -5417,7 +5420,6 @@ mod tests { ); let request_class = classify_playback_request(PlaybackRequestFacts { - item_type: PlaylistItemType::LiveHls, existing_session: Some(&session), prepare_only: false, terminate: false, @@ -5432,7 +5434,6 @@ mod tests { create_test_session("tok-active", PlaylistItemType::LiveHls, crate::api::model::PlaybackLifecycle::Active); let request_class = classify_playback_request(PlaybackRequestFacts { - item_type: PlaylistItemType::LiveHls, existing_session: Some(&session), prepare_only: false, terminate: false, @@ -5463,7 +5464,6 @@ mod tests { ); let request_class = classify_playback_request(PlaybackRequestFacts { - item_type: PlaylistItemType::LiveHls, existing_session: Some(&session), prepare_only: false, terminate: false, @@ -5495,7 +5495,6 @@ mod tests { session.lifecycle = crate::api::model::PlaybackLifecycle::Prepared; let request_class = classify_playback_request(PlaybackRequestFacts { - item_type: PlaylistItemType::LiveHls, existing_session: Some(&session), prepare_only: false, terminate: false, @@ -5518,7 +5517,6 @@ mod tests { ); let request_class = classify_playback_request(PlaybackRequestFacts { - item_type: PlaylistItemType::LiveHls, existing_session: Some(&session), prepare_only: false, terminate: false, @@ -5541,7 +5539,6 @@ mod tests { session.lifecycle = crate::api::model::PlaybackLifecycle::Prepared; let request_class = classify_playback_request(PlaybackRequestFacts { - item_type: PlaylistItemType::LiveHls, existing_session: Some(&session), prepare_only: false, terminate: false, @@ -5622,7 +5619,7 @@ mod tests { user: &user, session_token: "tok-follow-up", request_class: None, - virtual_id: channel.virtual_id, + virtual_id: VirtualId::new(channel.virtual_id), item_type: PlaylistItemType::LiveHls, stream_url: channel.url.as_ref(), connection_permission: UserConnectionPermission::Allowed, @@ -5691,7 +5688,7 @@ mod tests { user: &user, session_token: "tok-precomputed-follow-up", request_class: Some(PlaybackRequestClass::FollowUp), - virtual_id: channel.virtual_id, + virtual_id: VirtualId::new(channel.virtual_id), item_type: PlaylistItemType::LiveHls, stream_url: channel.url.as_ref(), connection_permission: UserConnectionPermission::Allowed, @@ -5770,7 +5767,7 @@ mod tests { user: &user, session_token: "tok-stale-followup", request_class: Some(PlaybackRequestClass::FollowUp), - virtual_id: channel.virtual_id, + virtual_id: VirtualId::new(channel.virtual_id), item_type: PlaylistItemType::LiveHls, stream_url: channel.url.as_ref(), connection_permission: UserConnectionPermission::Allowed, @@ -5840,7 +5837,7 @@ mod tests { user: &user, session_token: "tok-pre-resolved-grace", request_class: None, - virtual_id: channel.virtual_id, + virtual_id: VirtualId::new(channel.virtual_id), item_type: PlaylistItemType::LiveHls, stream_url: channel.url.as_ref(), connection_permission: UserConnectionPermission::GracePeriod, @@ -5896,7 +5893,7 @@ mod tests { session_token: "tok-prepare", // Explicitly pass Prepare class — placeholder and admission should be skipped. request_class: Some(PlaybackRequestClass::Prepare), - virtual_id: 55222, + virtual_id: VirtualId::new(55222), item_type: PlaylistItemType::LiveHls, stream_url: "http://provider.example/live/test.ts", connection_permission: UserConnectionPermission::Allowed, @@ -5943,7 +5940,6 @@ mod tests { &app_state.admission_ctx(), &user, &fingerprint, - PlaylistItemType::LiveHls, None, "tok-prepare-only", false, @@ -6009,7 +6005,6 @@ mod tests { &app_state.admission_ctx(), &user, &fingerprint, - PlaylistItemType::LiveHls, before.as_ref(), session_token, false, @@ -6032,7 +6027,6 @@ mod tests { #[test] fn classify_playback_request_returns_terminate_when_flag_set() { let request_class = classify_playback_request(PlaybackRequestFacts { - item_type: PlaylistItemType::Live, existing_session: None, prepare_only: false, terminate: true, @@ -6116,7 +6110,7 @@ mod tests { user: &user, session_token: "tok-grace-hold", request_class: None, - virtual_id: second_channel.virtual_id, + virtual_id: VirtualId::new(second_channel.virtual_id), item_type: PlaylistItemType::LiveHls, stream_url: second_channel.url.as_ref(), connection_permission: UserConnectionPermission::Allowed, @@ -6198,7 +6192,7 @@ mod tests { user: &user, session_token: "tok-atomic-commit", request_class: None, - virtual_id: channel.virtual_id, + virtual_id: VirtualId::new(channel.virtual_id), item_type: channel.item_type, stream_url: channel.url.as_ref(), connection_permission: UserConnectionPermission::Allowed, @@ -6245,6 +6239,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, } @@ -6286,8 +6281,8 @@ mod tests { }); assert_eq!( - get_effective_admission_strategies(&app_state.admission_ctx()), - vec![shared::model::AdmissionStrategy::GraceHoldStream] + get_effective_admission_strategies(&app_state.admission_ctx()).as_ref(), + &[shared::model::AdmissionStrategy::GraceHoldStream][..] ); } @@ -6390,15 +6385,17 @@ mod tests { // Now the new request finds the slot exhausted and the grace strategy kicks in. let result = resolve_admission_with_strategies( &app_state.admission_ctx(), - &user.username, - user.max_connections, - user.soft_connections, - &fingerprint2.client_ip, - &fingerprint2.addr, - true, - Some("tok-new-request"), - true, - EvictionReentryGuard::Session("tok-new-request"), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new-request"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new-request"), + }, ) .await; @@ -6416,7 +6413,7 @@ mod tests { // Grace was used at index 0, so only EvictUserOldest (index 1) is evaluated. // Eviction frees the slot -> Allowed. let strategies = vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]; - let grace_context = GraceResolutionContext { strategy_index: 0, strategies, kind: None }; + let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { retry: true, @@ -6483,15 +6480,17 @@ mod tests { let result = evaluate_remaining_strategies_after_grace( &app_state.admission_ctx(), - "remaining-evict", - 1, - 0, - &fingerprint2.client_ip, - &fingerprint2.addr, - true, - Some("tok-new"), - true, - EvictionReentryGuard::Session("tok-new"), + AdmissionRequest { + username: "remaining-evict", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), + }, &grace_context, Some(crate::api::model::ConnectionKind::Normal), ) @@ -6516,7 +6515,7 @@ mod tests { AdmissionStrategy::EvictUserSameIpOldest, AdmissionStrategy::EvictUserOldest, ]; - let grace_context = GraceResolutionContext { strategy_index: 0, strategies, kind: None }; + let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { retry: true, @@ -6587,15 +6586,17 @@ mod tests { let result = evaluate_remaining_strategies_after_grace( &app_state.admission_ctx(), - "remaining-skip-no-match", - 1, - 0, - &fingerprint2.client_ip, - &fingerprint2.addr, - true, - Some("tok-new"), - true, - EvictionReentryGuard::Session("tok-new"), + AdmissionRequest { + username: "remaining-skip-no-match", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), + }, &grace_context, Some(crate::api::model::ConnectionKind::Normal), ) @@ -6613,7 +6614,7 @@ mod tests { // Strategies: [GraceHoldStream] // Grace was at index 0, remaining slice is empty -> exhausted. let strategies = vec![AdmissionStrategy::GraceHoldStream]; - let grace_context = GraceResolutionContext { strategy_index: 0, strategies, kind: None }; + let grace_context = GraceResolutionContext { strategy_index: 0, strategies: strategies.into(), kind: None }; let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { retry: true, @@ -6636,15 +6637,17 @@ mod tests { let result = evaluate_remaining_strategies_after_grace( &app_state.admission_ctx(), - "no-remaining-strategies", - 1, - 0, - &fingerprint.client_ip, - &fingerprint.addr, - true, - Some("tok-new"), - true, - EvictionReentryGuard::Session("tok-new"), + AdmissionRequest { + username: "no-remaining-strategies", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-new"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), + }, &grace_context, None, ) @@ -6665,7 +6668,7 @@ mod tests { let strategies = vec![AdmissionStrategy::GraceHoldStream]; let grace_context = GraceResolutionContext { strategy_index: 0, - strategies, + strategies: strategies.into(), kind: Some(crate::api::model::ConnectionKind::Soft), }; @@ -6690,15 +6693,17 @@ mod tests { let result = evaluate_remaining_strategies_after_grace( &app_state.admission_ctx(), - "soft-kind-user", - 1, - 0, - &fingerprint.client_ip, - &fingerprint.addr, - true, - Some("tok-soft"), - true, - EvictionReentryGuard::Session("tok-soft"), + AdmissionRequest { + username: "soft-kind-user", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-soft"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-soft"), + }, &grace_context, Some(crate::api::model::ConnectionKind::Soft), ) @@ -6728,7 +6733,7 @@ mod tests { AdmissionStrategy::EvictUserOldest, ]; let strategies_for_config = strategies.clone(); - let grace_context = GraceResolutionContext { strategy_index: 1, strategies, kind: None }; + let grace_context = GraceResolutionContext { strategy_index: 1, strategies: strategies.into(), kind: None }; let app_state = create_test_app_state_with_stream_config(crate::model::StreamConfig { retry: true, @@ -6795,15 +6800,17 @@ mod tests { let result = evaluate_remaining_strategies_after_grace( &app_state.admission_ctx(), - "remaining-no-retry", - 1, - 0, - &fingerprint2.client_ip, - &fingerprint2.addr, - true, - Some("tok-new"), - true, - EvictionReentryGuard::Session("tok-new"), + AdmissionRequest { + username: "remaining-no-retry", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new"), + }, &grace_context, Some(crate::api::model::ConnectionKind::Normal), ) @@ -6824,7 +6831,7 @@ mod tests { let strategies = vec![AdmissionStrategy::GraceHoldStream]; let grace_context = GraceResolutionContext { strategy_index: 0, - strategies, + strategies: strategies.into(), kind: Some(crate::api::model::ConnectionKind::Normal), }; let original_kind = Some(crate::api::model::ConnectionKind::Soft); @@ -6850,15 +6857,17 @@ mod tests { let result = evaluate_remaining_strategies_after_grace( &app_state.admission_ctx(), - "kind-mismatch-empty", - 1, - 0, - &fingerprint.client_ip, - &fingerprint.addr, - true, - Some("tok-empty"), - true, - EvictionReentryGuard::Session("tok-empty"), + AdmissionRequest { + username: "kind-mismatch-empty", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-empty"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-empty"), + }, &grace_context, original_kind, ) @@ -6883,7 +6892,7 @@ mod tests { let strategies = vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::GraceInstantStream]; let grace_context = GraceResolutionContext { strategy_index: 0, - strategies, + strategies: strategies.into(), kind: Some(crate::api::model::ConnectionKind::Normal), }; let original_kind = Some(crate::api::model::ConnectionKind::Soft); @@ -6953,15 +6962,17 @@ mod tests { let result = evaluate_remaining_strategies_after_grace( &app_state.admission_ctx(), - "kind-mismatch-grace", - 1, - 0, - &fingerprint2.client_ip, - &fingerprint2.addr, - true, - Some("tok-new-grace"), - true, - EvictionReentryGuard::Session("tok-new-grace"), + AdmissionRequest { + username: "kind-mismatch-grace", + max_connections: 1, + soft_connections: 0, + client_ip: &fingerprint2.client_ip, + request_addr: &fingerprint2.addr, + use_session_admission: true, + session_token: Some("tok-new-grace"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-new-grace"), + }, &grace_context, original_kind, ) @@ -7082,15 +7093,17 @@ mod tests { let result = resolve_admission_with_strategies( &app_state.admission_ctx(), - "fallthrough", - 1, - 1, - "127.0.0.1", - &"127.0.0.1:55153".parse().unwrap_or_else(|_| unreachable!()), - true, - Some("tok-third"), - false, - EvictionReentryGuard::Session("tok-third"), + AdmissionRequest { + username: "fallthrough", + max_connections: 1, + soft_connections: 1, + client_ip: "127.0.0.1", + request_addr: &"127.0.0.1:55153".parse().unwrap_or_else(|_| unreachable!()), + use_session_admission: true, + session_token: Some("tok-third"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-third"), + }, ) .await; let admission = result.admission; @@ -7145,30 +7158,34 @@ mod tests { let session_based = resolve_admission_with_strategies( &app_state.admission_ctx(), - &user.username, - user.max_connections, - user.soft_connections, - &fingerprint.client_ip, - &fingerprint.addr, - true, - Some("vod-session"), - false, - EvictionReentryGuard::Session("vod-session"), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some("vod-session"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::Session("vod-session"), + }, ) .await; assert_eq!(session_based.admission.permission, UserConnectionPermission::Allowed); let connection_based = resolve_admission_with_strategies( &app_state.admission_ctx(), - &user.username, - user.max_connections, - user.soft_connections, - &fingerprint.client_ip, - &fingerprint.addr, - false, - Some("vod-session"), - false, - EvictionReentryGuard::Session("vod-session"), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: false, + session_token: Some("vod-session"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::Session("vod-session"), + }, ) .await; assert_eq!(connection_based.admission.permission, UserConnectionPermission::Exhausted); @@ -7249,15 +7266,17 @@ mod tests { let result = resolve_admission_with_strategies( &app_state.admission_ctx(), - "loop-user", - 1, - 0, - &reconnect_fingerprint.client_ip, - &reconnect_fingerprint.addr, - true, - Some("socket-reconnect"), - false, - EvictionReentryGuard::SocketPlayback { virtual_id: 9001 }, + AdmissionRequest { + username: "loop-user", + max_connections: 1, + soft_connections: 0, + client_ip: &reconnect_fingerprint.client_ip, + request_addr: &reconnect_fingerprint.addr, + use_session_admission: true, + session_token: Some("socket-reconnect"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9001) }, + }, ) .await; let admission = result.admission; @@ -7378,15 +7397,17 @@ mod tests { let result = resolve_admission_with_strategies( &app_state.admission_ctx(), - "loop-user-2", - 1, - 0, - &new_fingerprint.client_ip, - &new_fingerprint.addr, - true, - Some("session-new"), - false, - EvictionReentryGuard::SocketPlayback { virtual_id: 9103 }, + AdmissionRequest { + username: "loop-user-2", + max_connections: 1, + soft_connections: 0, + client_ip: &new_fingerprint.client_ip, + request_addr: &new_fingerprint.addr, + use_session_admission: true, + session_token: Some("session-new"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9103) }, + }, ) .await; let admission = result.admission; @@ -7502,15 +7523,17 @@ mod tests { let result = resolve_admission_with_strategies( &app_state.admission_ctx(), - "loop-user-4", - 1, - 0, - &new_fingerprint.client_ip, - &new_fingerprint.addr, - true, - Some("session-other"), - false, - EvictionReentryGuard::Session("session-other"), + AdmissionRequest { + username: "loop-user-4", + max_connections: 1, + soft_connections: 0, + client_ip: &new_fingerprint.client_ip, + request_addr: &new_fingerprint.addr, + use_session_admission: true, + session_token: Some("session-other"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::Session("session-other"), + }, ) .await; let admission = result.admission; @@ -7596,15 +7619,17 @@ mod tests { let result = resolve_admission_with_strategies( &app_state.admission_ctx(), - "loop-user-3", - 1, - 1, - &reconnect_fingerprint.client_ip, - &reconnect_fingerprint.addr, - true, - Some("socket-reconnect"), - false, - EvictionReentryGuard::SocketPlayback { virtual_id: 9201 }, + AdmissionRequest { + username: "loop-user-3", + max_connections: 1, + soft_connections: 1, + client_ip: &reconnect_fingerprint.client_ip, + request_addr: &reconnect_fingerprint.addr, + use_session_admission: true, + session_token: Some("socket-reconnect"), + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(9201) }, + }, ) .await; let admission = result.admission; @@ -7644,6 +7669,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }; @@ -7702,6 +7728,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }; @@ -7893,6 +7920,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }); @@ -8043,6 +8071,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }; @@ -8090,6 +8119,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }; @@ -8163,6 +8193,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }; @@ -8239,6 +8270,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }; @@ -8338,28 +8370,32 @@ mod tests { let first_admission = resolve_admission_with_strategies( &app_state.admission_ctx(), - &user.username, - user.max_connections, - user.soft_connections, - &first_fingerprint.client_ip, - &first_fingerprint.addr, - true, - Some("tok-hls-first"), - true, - EvictionReentryGuard::Session("tok-hls-first"), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &first_fingerprint.client_ip, + request_addr: &first_fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-hls-first"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-hls-first"), + }, ) .await; let second_admission = resolve_admission_with_strategies( &app_state.admission_ctx(), - &user.username, - user.max_connections, - user.soft_connections, - &second_fingerprint.client_ip, - &second_fingerprint.addr, - true, - Some("tok-hls-second"), - true, - EvictionReentryGuard::Session("tok-hls-second"), + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &second_fingerprint.client_ip, + request_addr: &second_fingerprint.addr, + use_session_admission: true, + session_token: Some("tok-hls-second"), + activate_unbound_session: true, + eviction_reentry_guard: EvictionReentryGuard::Session("tok-hls-second"), + }, ) .await; @@ -8508,15 +8544,17 @@ mod tests { let mut close_rx = app_state.connection_manager.get_close_connection_channel(); let result = resolve_admission_with_strategies( &app_state.admission_ctx(), - &user.username, - user.max_connections, - user.soft_connections, - &ts_fingerprint.client_ip, - &ts_fingerprint.addr, - false, - None, - false, - EvictionReentryGuard::SocketPlayback { virtual_id: 5001 }, + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &ts_fingerprint.client_ip, + request_addr: &ts_fingerprint.addr, + use_session_admission: false, + session_token: None, + activate_unbound_session: false, + eviction_reentry_guard: EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(5001) }, + }, ) .await; let admission = result.admission; @@ -8832,11 +8870,10 @@ mod tests { &app_state.admission_ctx(), &user, &ts_fingerprint, - PlaylistItemType::Live, None, &ts_token, false, - EvictionReentryGuard::SocketPlayback { virtual_id }, + EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(virtual_id) }, false, false, ) @@ -8986,7 +9023,7 @@ mod tests { None, true, true, - channel.virtual_id, + VirtualId::new(channel.virtual_id), 0, crate::api::model::ConnectionKind::Normal, false, @@ -9067,7 +9104,7 @@ mod tests { let app_state = create_test_app_state_for_config(Arc::new(app_cfg_raw)); let pli = XtreamPlaylistItem { - virtual_id: 100, + virtual_id: VirtualId::new(100), provider_id: 1, name: "test".intern(), logo: "".intern(), diff --git a/backend/app/src/api/auth_middleware.rs b/backend/app/src/api/auth_middleware.rs index bdfe9ab05..722db1fa0 100644 --- a/backend/app/src/api/auth_middleware.rs +++ b/backend/app/src/api/auth_middleware.rs @@ -6,29 +6,83 @@ //! verification they call still lives in `auth::authenticator`. use crate::{ - api::{api_utils::get_username_from_auth_header, model::AppState}, - auth::{validate_token_claims, verify_token, verify_token_admin, verify_token_api_user, AuthBearer, AuthError}, + api::model::AppState, + auth::{validate_password_version, validate_token_claims, verify_token, AuthBearer, AuthError}, }; +use axum::{extract::FromRequestParts, http::request::Parts}; use log::warn; -use shared::model::permission::{permission_to_name, Permission}; +use shared::model::{ + permission::{permission_to_name, Permission, PermissionSet}, + AuthAuditEvent, Claims, EventMessage, EventSink, +}; use std::sync::Arc; -fn validate_request( - app_state: &Arc, - token: &str, - verify_fn: fn(&str, &[u8]) -> bool, -) -> Result<(), AuthError> { +/// The verified claims for the current request, parked in the request +/// extensions by whichever layer authenticated it. +/// +/// A handler behind a permission layer used to have no way to reach the claims +/// the layer had just decoded, so it decoded the token again - a fourth time on +/// some paths. [`AuthorizedClaims`] reads this instead. +#[derive(Debug, Clone)] +pub struct VerifiedClaims(pub Claims); + +/// Verify a bearer token and everything that must hold for its principal. +/// +/// One decode, then: signature, expiry and issuer (inside `verify_token`), +/// schema version and subject id, and finally the principal's password +/// version. That last check is why a password change now invalidates live +/// tokens instead of leaving them valid until expiry. +async fn authenticate(app_state: &Arc, token: &str) -> Result { + let claims = verify_claims(app_state, token)?; + // Last, because it is the only check that touches shared state: a token + // that fails any of the cheap checks above never reaches it. + if app_state.token_revocations.is_revoked(&claims).await { + return Err(AuthError::Revoked); + } + Ok(claims) +} + +/// Everything about a token that can be decided from the token and the config +/// alone. +fn verify_claims(app_state: &Arc, token: &str) -> Result { let config = app_state.app_config.config.load(); let Some(web_auth_config) = config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) else { return Err(AuthError::InvalidToken); }; - let secret_key = web_auth_config.secret.as_ref(); - let token_data = verify_token(token, secret_key).ok_or(AuthError::InvalidToken)?; - validate_token_claims(&token_data.claims)?; - if !verify_fn(token, secret_key) { - return Err(AuthError::Forbidden); + let token_data = verify_token(token, web_auth_config.secret.as_bytes(), &web_auth_config.issuer) + .ok_or(AuthError::InvalidToken)?; + let claims = token_data.claims; + validate_token_claims(&claims)?; + + // Only web users have a password on file here. A proxy API user + // authenticates against `api_proxy.yml` and carries no password version, so + // there is nothing to compare and nothing to enforce. + if let Some(current) = web_auth_config.pwd_version_for(&claims.username) { + validate_password_version(&claims, current)?; } - Ok(()) + + Ok(claims) +} + +/// The permissions that actually apply to this request. +/// +/// The token's permission set is a snapshot from the moment it was minted. +/// Reading it alone meant revoking a group permission had no effect until the +/// token expired. The effective set is the intersection of the token's claim +/// with what the live config grants right now: a revocation takes effect +/// immediately, while a *new* grant still requires a refresh, because a token +/// must never end up with more authority than it was issued with. +/// +/// A principal the web-auth config has never heard of - a proxy API user - +/// has no live set to intersect with, and keeps its claim. +fn effective_permissions(app_state: &Arc, claims: &Claims) -> PermissionSet { + let config = app_state.app_config.config.load(); + config + .web_ui + .as_ref() + .and_then(|c| c.auth.as_ref()) + .and_then(|web_auth| web_auth.resolve_permissions_if_known(&claims.username)) + .map_or(claims.permissions, |live| claims.permissions & live) } /// Build a stable, recognizable rejection response. Refresh-required @@ -43,6 +97,10 @@ fn rejection_for(err: AuthError) -> axum::response::Response { _ => StatusCode::UNAUTHORIZED, }; let mut builder = axum::http::Response::builder().status(status); + if status == StatusCode::UNAUTHORIZED { + // A 401 without a challenge is not a well-formed 401. + builder = builder.header(axum::http::header::WWW_AUTHENTICATE, "Bearer"); + } if err.is_token_refresh_required() { builder = builder.header("X-Token-Refresh", "required"); } @@ -51,13 +109,32 @@ fn rejection_for(err: AuthError) -> axum::response::Response { .unwrap_or_else(|_| axum::http::Response::new(axum::body::Body::empty())) } +/// Authenticate, check the role, and park the claims for the handler. +/// +/// `role_fn` is a plain `fn` pointer - static dispatch, no closure. It reads +/// the claims this function already decoded; it used to be a +/// `fn(&str, &[u8]) -> bool` that decoded the token all over again. +async fn authorize_role( + app_state: &Arc, + token: &str, + request: &mut axum::extract::Request, + role_fn: fn(&Claims) -> bool, +) -> Result<(), AuthError> { + let claims = authenticate(app_state, token).await?; + if !role_fn(&claims) { + return Err(AuthError::Forbidden); + } + request.extensions_mut().insert(VerifiedClaims(claims)); + Ok(()) +} + pub async fn validator_admin( axum::extract::State(app_state): axum::extract::State>, AuthBearer(token): AuthBearer, - request: axum::extract::Request, + mut request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { - match validate_request(&app_state, &token, verify_token_admin) { + match authorize_role(&app_state, &token, &mut request, Claims::is_admin).await { Ok(()) => next.run(request).await, Err(err) => rejection_for(err), } @@ -66,64 +143,157 @@ pub async fn validator_admin( pub async fn validator_api_user( axum::extract::State(app_state): axum::extract::State>, AuthBearer(token): AuthBearer, - request: axum::extract::Request, + mut request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { - if let Some(username) = get_username_from_auth_header(&token, &app_state) { - if let Some(user) = app_state.app_config.get_user_credentials(&username) { - if !user.ui_enabled { - return axum::http::Response::builder() - .status(axum::http::StatusCode::FORBIDDEN) - .body(axum::body::Body::from("principal does not have the required role".to_string())) - .unwrap_or_else(|_| axum::http::Response::new(axum::body::Body::empty())); - } + let claims = match authenticate(&app_state, &token).await { + Ok(claims) => claims, + Err(err) => return rejection_for(err), + }; + // The UI gate reads the username off the claims we already decoded. It used + // to re-decode the token through `get_username_from_auth_header` purely to + // learn the name, and it ran *before* the token was validated at all. + if let Some(user) = app_state.app_config.get_user_credentials(&claims.username) { + if !user.ui_enabled { + return rejection_for(AuthError::Forbidden); } } - match validate_request(&app_state, &token, verify_token_api_user) { - Ok(()) => next.run(request).await, - Err(err) => rejection_for(err), + if !claims.is_api_user() { + return rejection_for(AuthError::Forbidden); + } + request.extensions_mut().insert(VerifiedClaims(claims)); + next.run(request).await +} + +/// Enforce one permission, named as a const generic. +/// +/// `P` is a [`Permission`] discriminant. The permission is therefore fixed at +/// monomorphisation rather than captured in a closure and read at runtime, so +/// each route's check folds to a constant mask. +pub async fn require_permission( + axum::extract::State(app_state): axum::extract::State>, + AuthBearer(token): AuthBearer, + mut request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let claims = match authenticate(&app_state, &token).await { + Ok(claims) => claims, + Err(err) => return rejection_for(err), + }; + let client_ip = audit_client_ip(request.headers()); + if let Err(err) = check_permission::

(&app_state, &claims, client_ip.as_deref()) { + return rejection_for(err); + } + request.extensions_mut().insert(VerifiedClaims(claims)); + next.run(request).await +} + +fn check_permission( + app_state: &Arc, + claims: &Claims, + client_ip: Option<&str>, +) -> Result<(), AuthError> { + // `P` is a discriminant, not a bit. Recovering the variant is a constant + // fold at each monomorphisation; `None` cannot happen for a `P` the + // `permission_layer!` macro produced. + let Some(permission) = Permission::from_repr(P) else { + return Err(AuthError::Forbidden); + }; + if !effective_permissions(app_state, claims).contains(permission) { + let denied_permission = permission_to_name(permission).unwrap_or("unknown"); + warn!("User '{}' denied permission '{denied_permission}'", claims.username); + // A denial went to `warn!` and nowhere else, so nothing that + // subscribes to the bus could see it. + app_state.event_manager.emit(EventMessage::AuthAudit(AuthAuditEvent::permission_denied( + Arc::from(claims.username.as_str()), + Arc::from(client_ip.unwrap_or("unknown")), + Arc::from(denied_permission), + ))); + return Err(AuthError::Forbidden); + } + Ok(()) +} + +/// The client address for an audit record, best-effort. +/// +/// The forwarding headers are read directly rather than through the +/// `Fingerprint` extractor: an audit record is not worth failing a request +/// over when the peer address is unavailable. +fn audit_client_ip(headers: &axum::http::HeaderMap) -> Option { + headers + .get("x-real-ip") + .or_else(|| headers.get("x-forwarded-for")) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(|value| value.trim().to_string()) +} + +/// A handler-signature permission requirement. +/// +/// `async fn handler(AuthorizedClaims::<{ Permission::ConfigRead as u32 }>(claims), ...)` +/// states the requirement in the type and hands the handler the verified +/// claims, so it never has to decode the token itself. Behind a +/// `permission_layer!` this is free - the layer already parked the claims in +/// the request extensions - and it authenticates on its own where there is no +/// layer. +pub struct AuthorizedClaims(pub Claims); + +/// Why [`AuthorizedClaims`] refused the request. +/// +/// A typed rejection rather than a pre-rendered `Response`: both variants are +/// `Copy` and two words wide, so the extractor's `Result` stays small and the +/// caller can still match on the reason. +#[derive(Debug, Clone, Copy)] +pub enum AuthorizeRejection { + /// The `Authorization` header itself was missing or malformed. + Header(crate::auth::AuthRejection), + /// The header was fine; the token or the principal was not. + Token(AuthError), +} + +impl axum::response::IntoResponse for AuthorizeRejection { + fn into_response(self) -> axum::response::Response { + match self { + Self::Header(rejection) => rejection.into_response(), + Self::Token(err) => rejection_for(err), + } } } -pub async fn require_permission_inner( - permission: Permission, - axum::extract::State(app_state): axum::extract::State>, - AuthBearer(token): AuthBearer, - request: axum::extract::Request, - next: axum::middleware::Next, -) -> axum::response::Response { - let config = app_state.app_config.config.load(); - let Some(web_auth_config) = config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) else { - return rejection_for(AuthError::InvalidToken); - }; +impl FromRequestParts> for AuthorizedClaims

{ + type Rejection = AuthorizeRejection; - let Some(token_data) = verify_token(&token, web_auth_config.secret.as_bytes()) else { - return rejection_for(AuthError::InvalidToken); - }; - if let Err(err) = validate_token_claims(&token_data.claims) { - return rejection_for(err); + async fn from_request_parts(parts: &mut Parts, app_state: &Arc) -> Result { + let claims = if let Some(VerifiedClaims(claims)) = parts.extensions.get::() { + // A layer already authenticated this request, revocation included. + claims.clone() + } else { + let AuthBearer(token) = AuthBearer::from_headers(&parts.headers).map_err(AuthorizeRejection::Header)?; + let claims = authenticate(app_state, &token).await.map_err(AuthorizeRejection::Token)?; + parts.extensions.insert(VerifiedClaims(claims.clone())); + claims + }; + let client_ip = audit_client_ip(&parts.headers); + check_permission::

(app_state, &claims, client_ip.as_deref()).map_err(AuthorizeRejection::Token)?; + Ok(Self(claims)) } - - if !token_data.claims.permissions.contains(permission) { - let denied_permission = permission_to_name(permission).unwrap_or("unknown"); - warn!("User '{}' denied permission '{denied_permission}'", token_data.claims.username); - return rejection_for(AuthError::Forbidden); - } - - next.run(request).await } /// Builds an Axum layer that enforces one permission. /// /// Defined here rather than in `auth` because it expands to a call into this /// module; keeping it there meant `auth` named `api`. +/// +/// The permission crosses into [`require_permission`] as a const generic, so +/// the layer is a bare `fn` item rather than a closure holding a runtime value. #[macro_export] macro_rules! permission_layer { ($app_state:expr, $permission:expr ) => {{ let app_state = ::std::sync::Arc::clone($app_state); - ::axum::middleware::from_fn_with_state(app_state, move |state, auth, request, next| { - $crate::api::auth_middleware::require_permission_inner($permission, state, auth, request, next) - }) + ::axum::middleware::from_fn_with_state( + app_state, + $crate::api::auth_middleware::require_permission::<{ $permission as u32 }>, + ) }}; } pub use permission_layer; diff --git a/backend/app/src/api/config_file.rs b/backend/app/src/api/config_file.rs index 599fe73c1..cdceb9539 100644 --- a/backend/app/src/api/config_file.rs +++ b/backend/app/src/api/config_file.rs @@ -4,7 +4,7 @@ use crate::{ prepare_sources_batch, read_sources_file, read_sources_file_from_path_with_templates, read_templates, resolve_template_and_mapping_paths, }, - model::{Config, Mappings, ProcessTargets, SourcesConfig}, + model::{CompiledMappings, Config, ProcessTargets, SourcesConfig}, utils, utils::{read_mappings_file_unprepared, read_mappings_file_with_templates}, }; @@ -32,7 +32,7 @@ pub enum ConfigFile { struct PreparedMappingsReload { mapping_file_path: String, mapping_files: Vec, - mappings: Mappings, + mappings: CompiledMappings, } /// Fully prepared sources + mappings data, ready to apply without any I/O or fallible work. @@ -143,11 +143,14 @@ impl ConfigFile { }; match read_mappings_file_with_templates(mapping_file_path, true, prepared_templates) { - Ok(Some((mapping_files, mappings_cfg))) => Ok(Some(PreparedMappingsReload { - mapping_file_path: mapping_file_path.to_string(), - mapping_files, - mappings: Mappings::from(&mappings_cfg), - })), + Ok(Some((mapping_files, mappings_cfg))) => { + let mappings = CompiledMappings::try_from(&mappings_cfg)?; + Ok(Some(PreparedMappingsReload { + mapping_file_path: mapping_file_path.to_string(), + mapping_files, + mappings, + })) + } Ok(None) => { info!("No mapping file loaded {mapping_file_path}"); Ok(None) @@ -382,6 +385,11 @@ impl ConfigFile { pub(crate) async fn reload(&self, file_path: &Path, app_state: &Arc) -> Result<(), TuliproxError> { debug!("File change detected {}", file_path.display()); + // Channels and templates are built once and cached, so an edited + // token, URL or template would otherwise not take effect until a + // restart. + tuliprox_messaging::channels::invalidate(); + tuliprox_messaging::render::invalidate_cache(); match self { ConfigFile::ApiProxy => { ConfigFile::load_api_proxy(app_state).await?; diff --git a/backend/app/src/api/endpoints/api_playlist_utils.rs b/backend/app/src/api/endpoints/api_playlist_utils.rs index 67132e945..dff5f8dc8 100644 --- a/backend/app/src/api/endpoints/api_playlist_utils.rs +++ b/backend/app/src/api/endpoints/api_playlist_utils.rs @@ -199,8 +199,14 @@ pub(in crate::api::endpoints) async fn get_playlist_for_custom_provider( (playlist, errors, false) } InputPersistence::Xtream => { - let (pl, err, _) = - xtream::download_xtream_playlist(&app_state.app_config, client, input, Some(&[cluster])).await; + let (pl, err, _) = xtream::download_xtream_playlist( + &app_state.app_config, + client, + &app_state.event_manager, + input, + Some(&[cluster]), + ) + .await; (pl, err, false) } InputPersistence::Library => { diff --git a/backend/app/src/api/endpoints/custom_video_stream_api.rs b/backend/app/src/api/endpoints/custom_video_stream_api.rs index 668102db5..088c5c8b0 100644 --- a/backend/app/src/api/endpoints/custom_video_stream_api.rs +++ b/backend/app/src/api/endpoints/custom_video_stream_api.rs @@ -233,7 +233,11 @@ async fn cvs_api_response(context: CvsApiResponseContext<'_>) -> Response { let Some(token) = token.as_deref() else { return app_state.app_config.get_auth_error_status().into_response(); }; - if !verify_access_token(token, &app_state.app_config.access_token_secret) { + if !verify_access_token( + token, + &app_state.app_config.access_token_secret, + crate::auth::scope::INTERNAL_PLAYER, + ) { return app_state.app_config.get_auth_error_status().into_response(); } return create_custom_video_stream_response( @@ -325,7 +329,7 @@ async fn validate_hls_standalone_custom_access( resolve_hls_cvs_access_user(app_state, fingerprint, access.username.as_ref()).map(|_| ()) } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; fn parse_cvs_standalone_hls_segment_file(segment_file: &str) -> Option { let index = segment_file.strip_suffix(".ts")?; @@ -617,6 +621,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }); @@ -733,6 +738,13 @@ mod tests { geoip, update_guard: UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } diff --git a/backend/app/src/api/endpoints/download_api.rs b/backend/app/src/api/endpoints/download_api.rs index 5136b7509..a77853863 100644 --- a/backend/app/src/api/endpoints/download_api.rs +++ b/backend/app/src/api/endpoints/download_api.rs @@ -11,7 +11,6 @@ use crate::{ RecordingExecutionResult, }, }, - messaging::send_message, model::{AppConfig, MessageContent, VideoDownloadConfig}, utils::{async_file_writer, request, request::create_client, IO_BUFFER_SIZE}, }; @@ -873,6 +872,7 @@ fn rollback_last_recording_marker(fd: &mut FileDownload, kind: &shared::model::r fn spawn_recording_notification_after_persist( app_config: &Arc, client: &reqwest::Client, + event_manager: &Arc, plan: RecordingNotificationPlan, persisted: bool, ) { @@ -882,19 +882,28 @@ fn spawn_recording_notification_after_persist( let Some(message) = plan.message else { return; }; - // A full or closed outbox hands the message back; fall through to the + // Also publish on the bus, so plugins and other subscribers can see + // recording lifecycle transitions. The notification bridge deliberately + // ignores this kind: operator delivery stays on the durable path below, + // because the bus may drop for a lagging subscriber and the marker has + // already been persisted. + if let MessageContent::RecordingLifecycle(lifecycle) = &message { + event_manager.send_event(EventMessage::RecordingLifecycle(lifecycle.clone())); + } + // A full or closed outbox hands the event back; fall through to the // direct send rather than dropping it outright. - let message = match crate::api::model::recording::recording_supervisor::notification_outbox() { - Some(outbox) => match outbox.enqueue(message) { + let event = tuliprox_core::model::NotificationEvent::from_content(&message); + let event = match crate::api::model::recording::recording_supervisor::notification_outbox() { + Some(outbox) => match outbox.enqueue(event) { None => return, Some(rejected) => rejected, }, - None => message, + None => event, }; let app_config = Arc::clone(app_config); let client = client.clone(); tokio::spawn(async move { - send_message(&app_config, &client, message).await; + tuliprox_messaging::send_event(&app_config, &client, event).await; }); } @@ -1258,6 +1267,7 @@ pub(in crate::api) async fn ensure_download_worker_running( spawn_recording_notification_after_persist( &app_config, &client, + &event_manager, notification, true, ); @@ -1439,6 +1449,7 @@ pub(in crate::api) async fn ensure_download_worker_running( spawn_recording_notification_after_persist( &app_config, &client, + &event_manager, notification, true, ); @@ -1534,6 +1545,7 @@ pub(in crate::api) async fn ensure_download_worker_running( spawn_recording_notification_after_persist( &app_config, &client, + &event_manager, notification, true, ); @@ -1657,6 +1669,7 @@ pub(in crate::api) async fn ensure_download_worker_running( spawn_recording_notification_after_persist( &app_config, &client, + &event_manager, notification, true, ); @@ -2298,7 +2311,11 @@ mod tests { .and_then(|segments| segments.collect::>().get(4).copied()) .expect("route token"); - assert!(crate::auth::verify_access_token(token, &app_config.access_token_secret)); + assert!(crate::auth::verify_access_token( + token, + &app_config.access_token_secret, + crate::auth::scope::INTERNAL_PLAYER + )); assert_eq!(recording.url.as_str(), persisted_before.url); assert_eq!(DownloadQueue::to_persisted(&recording).url, persisted_before.url); assert_ne!(execution.url, recording.url); @@ -2746,6 +2763,13 @@ mod tests { geoip, update_guard: UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } diff --git a/backend/app/src/api/endpoints/hdhomerun_api.rs b/backend/app/src/api/endpoints/hdhomerun_api.rs index cc7a45396..5a6352211 100644 --- a/backend/app/src/api/endpoints/hdhomerun_api.rs +++ b/backend/app/src/api/endpoints/hdhomerun_api.rs @@ -138,7 +138,7 @@ where url, &credentials.username, &credentials.password, - item.virtual_id, + item.virtual_id.get(), container_extension.as_deref(), live_stream_use_prefix, live_stream_without_extension, diff --git a/backend/app/src/api/endpoints/hls_api.rs b/backend/app/src/api/endpoints/hls_api.rs index 8c6fe17f8..fd7451865 100644 --- a/backend/app/src/api/endpoints/hls_api.rs +++ b/backend/app/src/api/endpoints/hls_api.rs @@ -62,7 +62,7 @@ use shared::{ defaults::HLS_EXT, model::{ ConnectFailureReason, FailureStage, InputType, PlaylistEntry, PlaylistItemType, StreamChannel, StreamInfo, - StreamProperties, TargetType, UserConnectionPermission, XtreamCluster, + StreamProperties, TargetType, UserConnectionPermission, VirtualId, XtreamCluster, }, utils::{ extract_extension_from_url, generate_random_string, is_hls_url, is_m3u_catchup_session_token, @@ -1911,7 +1911,7 @@ async fn ensure_hls_cache_stream_registered( stream_channel.epg_reference_ts = panel_archive_reference; } else { stream_channel.item_type = PlaylistItemType::LiveHls; - stream_channel.cluster = XtreamCluster::try_from(PlaylistItemType::LiveHls).unwrap_or(stream_channel.cluster); + stream_channel.cluster = PlaylistItemType::LiveHls.cluster(); } let shared_stream_id = hls_cache_shared_stream_id(&proxy_session_id); stream_channel.shared = true; @@ -2010,7 +2010,7 @@ async fn build_hls_cache_stream_channel( channel.epg_reference_ts = archive_reference; } else { channel.item_type = PlaylistItemType::LiveHls; - channel.cluster = XtreamCluster::try_from(PlaylistItemType::LiveHls).unwrap_or(channel.cluster); + channel.cluster = PlaylistItemType::LiveHls.cluster(); channel.epg_reference_ts = None; } channel @@ -2610,7 +2610,7 @@ async fn fetch_and_cache_transient_origin_response( .await } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; async fn release_prepared_hls_manifest_session( app_state: &Arc, @@ -2837,7 +2837,7 @@ impl HlsEntryStreamIdentity { } pub(in crate::api) fn from_playlist_item(item: &impl PlaylistEntry) -> Option { - let mut identity = Self::new(item.get_virtual_id(), item.get_input_stream_id()?)?; + let mut identity = Self::new(item.get_virtual_id().get(), item.get_input_stream_id()?)?; identity.upstream_user_agent = item.get_upstream_user_agent().map(Internable::intern); Some(identity) } @@ -4990,11 +4990,10 @@ async fn try_reserve_hls_virtual_entry_origin_account_for_redirect( &app_state.admission_ctx(), user, fingerprint, - PlaylistItemType::LiveHls, None, &session_token, false, - EvictionReentryGuard::SocketPlayback { virtual_id }, + EvictionReentryGuard::SocketPlayback { virtual_id: VirtualId::new(virtual_id) }, false, false, ) @@ -6770,7 +6769,7 @@ pub(in crate::api) async fn handle_hls_stream_request( original_hls_entry_path: &str, ) -> impl IntoResponse + Send { let virtual_id = stream_context.virtual_id(); - if app_state.active_users.is_user_blocked_for_stream(&user.username, virtual_id).await { + if app_state.active_users.is_user_blocked_for_stream(&user.username, VirtualId::new(virtual_id)).await { return axum::http::StatusCode::BAD_REQUEST.into_response(); } @@ -7191,7 +7190,11 @@ async fn resolve_hls_playback_manifest_request_context( let Some(input) = app_state.app_config.get_input_by_id(access_context.input_id) else { return Err(StatusCode::NOT_FOUND); }; - if app_state.active_users.is_user_blocked_for_stream(&user.username, access_context.virtual_id).await { + if app_state + .active_users + .is_user_blocked_for_stream(&user.username, VirtualId::new(access_context.virtual_id)) + .await + { return Err(StatusCode::FORBIDDEN); } let Some(channel) = get_stream_channel(app_state, &target, access_context.virtual_id).await else { @@ -7506,7 +7509,6 @@ async fn admit_recovered_archive_stream( &app_state.admission_ctx(), user, fingerprint, - PlaylistItemType::Catchup, Some(&session), &session.token, true, @@ -7774,11 +7776,6 @@ async fn hls_api_stream_resolved( &app_state.admission_ctx(), &user, &fingerprint, - if is_m3u_catchup_session_token(&session.token) || archive_reference.is_some() { - PlaylistItemType::Catchup - } else { - PlaylistItemType::LiveHls - }, Some(session), &session.token, true, @@ -8024,7 +8021,7 @@ mod tests { HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripConfigDto, HlsStripMode, InputType, M3uPlaylistItem, M3uTargetOutputDto, PlaylistItem, PlaylistItemHeader, PlaylistItemType, ProviderUrlSelectionPolicy, ReverseProxyConfigDto, StreamConfigDto, StreamProperties, TargetOutputDto, - UserConnectionPermission, XtreamCluster, XtreamTargetOutputDto, + UserConnectionPermission, VirtualId, XtreamCluster, XtreamTargetOutputDto, }, utils::Internable, }; @@ -8861,7 +8858,7 @@ mod tests { M3uPlaylistItem::from(&PlaylistItem { header: PlaylistItemHeader { id: input_stream_id.intern(), - virtual_id, + virtual_id: VirtualId::new(virtual_id), input_name: Arc::clone(&input.name), url: url.intern(), item_type: PlaylistItemType::LiveHls, @@ -8886,7 +8883,7 @@ mod tests { async fn cache_test_m3u_hls_item(app_state: &Arc, target: &ConfigTarget, item: M3uPlaylistItem) { let mut playlist = crate::repository::BPlusTree::new(); - playlist.insert(item.virtual_id, item); + playlist.insert(item.virtual_id.get(), item); app_state .playlists .cache_playlist(&target.name, crate::api::model::PlaylistStorage::M3uPlaylist(Box::new(playlist))) @@ -11244,8 +11241,10 @@ mod tests { async fn legacy_hls_manifest_deadline_includes_full_body_read() { let origin = spawn_test_encoded_manifest_origin(None, b"#EXTM3U\n".to_vec(), Duration::from_millis(100)).await; let input = legacy_manifest_test_input(&origin); - let hls_config = - HlsCacheConfig::from(&HlsCacheConfigDto { origin_manifest_timeout_ms: 10, ..Default::default() }); + let hls_config = HlsCacheConfig::from(&HlsCacheConfigDto { + origin_manifest_timeout_ms: shared::model::Millis::new(10), + ..Default::default() + }); let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); let error = super::download_legacy_hls_manifest(&app_state, &input, &legacy_manifest_test_client_headers()) @@ -11640,7 +11639,11 @@ mod tests { #[tokio::test] async fn hls_access_lease_validity_uses_session_idle_timeout_not_cache_duration() { - let hls_dto = HlsCacheConfigDto { cache_duration: 900, session_idle_timeout: 42, ..Default::default() }; + let hls_dto = HlsCacheConfigDto { + cache_duration: shared::model::Secs::new(900), + session_idle_timeout: shared::model::Secs::new(42), + ..Default::default() + }; let hls_config = HlsCacheConfig::from(&hls_dto); let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); @@ -11865,7 +11868,7 @@ mod tests { #[tokio::test] async fn hls_lifecycle_session_idle_timer_removes_idle_session() { - let mut hls_dto = HlsCacheConfigDto { session_idle_timeout: 1, ..Default::default() }; + let mut hls_dto = HlsCacheConfigDto { session_idle_timeout: shared::model::Secs::new(1), ..Default::default() }; hls_dto.segment_repair.max_level = HlsSegmentRepairMode::Low; let hls_config = HlsCacheConfig::from(&hls_dto); let app_state = test_app_state_with_hls_proxy(Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config))); @@ -11928,7 +11931,7 @@ mod tests { let temp_dir = tempfile::tempdir().expect("tempdir"); let mut hls_dto = HlsCacheConfigDto { cache_path: Some(temp_dir.path().to_string_lossy().into_owned()), - session_idle_timeout: 1, + session_idle_timeout: shared::model::Secs::new(1), ..Default::default() }; hls_dto.segment_repair.max_level = HlsSegmentRepairMode::Low; @@ -12172,6 +12175,13 @@ mod tests { geoip, update_guard: UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } @@ -14666,7 +14676,8 @@ mod tests { enabled: true, ..ConfigInput::default() }; - let hls_dto = HlsCacheConfigDto { origin_manifest_timeout_ms: 1_000, ..Default::default() }; + let hls_dto = + HlsCacheConfigDto { origin_manifest_timeout_ms: shared::model::Millis::new(1_000), ..Default::default() }; let hls_config = HlsCacheConfig::from(&hls_dto); let app_state = test_app_state_with_hls_proxy_and_inputs( Arc::new(HlsProxyManager::with_hls_cache_config(&hls_config)), diff --git a/backend/app/src/api/endpoints/hls_terminal_response.rs b/backend/app/src/api/endpoints/hls_terminal_response.rs index 103a1375b..02e3e53c8 100644 --- a/backend/app/src/api/endpoints/hls_terminal_response.rs +++ b/backend/app/src/api/endpoints/hls_terminal_response.rs @@ -306,4 +306,4 @@ pub(super) async fn resolve_hls_terminal_manifest_state( } } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; diff --git a/backend/app/src/api/endpoints/log_ws_api.rs b/backend/app/src/api/endpoints/log_ws_api.rs index 7e9c883a7..5a66600f2 100644 --- a/backend/app/src/api/endpoints/log_ws_api.rs +++ b/backend/app/src/api/endpoints/log_ws_api.rs @@ -1,6 +1,6 @@ use crate::{ api::model::AppState, - auth::verify_token, + auth::TokenVerifier, utils::{get_log_history, subscribe_logs}, }; use axum::{ @@ -13,7 +13,7 @@ use axum::{ use log::{trace, warn}; use serde::Deserialize; use shared::{ - model::{LogLevel, LogWsMessage, Permission, WsCloseCode, ROLE_ADMIN}, + model::{LogLevel, LogWsMessage, Permission, WsCloseCode}, utils::concat_path_leading_slash, }; use std::sync::Arc; @@ -24,29 +24,24 @@ pub struct LogWsQuery { pub min_level: Option, } -fn get_secret_key(app_state: &AppState, auth_required: bool) -> Option> { +/// The verifier this socket authenticates against. +/// +/// This used to hand a bare `Vec` secret around, which is precisely why +/// the WebSocket paths could not check the token's issuer. +fn get_token_verifier(app_state: &AppState, auth_required: bool) -> Option { if !auth_required { return None; } - app_state.app_config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()).map(|c| { - let secret_key: &[u8] = c.secret.as_ref(); - secret_key.to_vec() + app_state.app_config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()).map(TokenVerifier::from_config) +} + +fn check_token_auth(token: &str, verifier: Option<&TokenVerifier>) -> bool { + verifier.and_then(|verifier| verifier.verify(token)).is_some_and(|token_data| { + token_data.claims.permissions.contains(Permission::SystemRead) || token_data.claims.is_admin() }) } -fn check_token_auth(token: &str, secret_key: Option<&[u8]>) -> bool { - let Some(secret_key) = secret_key else { - return false; - }; - if let Some(token_data) = verify_token(token, secret_key) { - token_data.claims.permissions.contains(Permission::SystemRead) - || token_data.claims.roles.iter().any(|r| r == ROLE_ADMIN) - } else { - false - } -} - -async fn wait_for_socket_auth(socket: &mut WebSocket, secret_key: Option<&[u8]>) -> bool { +async fn wait_for_socket_auth(socket: &mut WebSocket, verifier: Option<&TokenVerifier>) -> bool { let auth_timeout = tokio::time::sleep(tokio::time::Duration::from_secs(10)); tokio::pin!(auth_timeout); @@ -66,7 +61,7 @@ async fn wait_for_socket_auth(socket: &mut WebSocket, secret_key: Option<&[u8]>) match msg { Message::Text(text) => { if let Ok(LogWsMessage::Auth(token)) = serde_json::from_str::(&text) { - if check_token_auth(&token, secret_key) { + if check_token_auth(&token, verifier) { let auth_ok = serde_json::to_string(&LogWsMessage::Authorized).unwrap_or_default(); let _ = socket.send(Message::Text(auth_ok.into())).await; return true; @@ -92,19 +87,19 @@ async fn wait_for_socket_auth(socket: &mut WebSocket, secret_key: Option<&[u8]>) } async fn handle_socket(mut socket: WebSocket, app_state: Arc, auth_required: bool, query: LogWsQuery) { - let secret_key = get_secret_key(&app_state, auth_required); + let verifier = get_token_verifier(&app_state, auth_required); let mut is_authorized = !auth_required; let mut min_level: Option = query.min_level.as_deref().and_then(|s| s.parse().ok()); if !is_authorized { if let Some(token) = query.token.as_deref() { - if check_token_auth(token, secret_key.as_deref()) { + if check_token_auth(token, verifier.as_ref()) { is_authorized = true; } } } - if !is_authorized && !wait_for_socket_auth(&mut socket, secret_key.as_deref()).await { + if !is_authorized && !wait_for_socket_auth(&mut socket, verifier.as_ref()).await { return; } @@ -220,22 +215,30 @@ mod tests { t_users: None, t_groups: None, }; - let secret = auth_config.secret.as_bytes(); + let verifier = TokenVerifier::from_config(&auth_config); let admin_token = create_jwt_admin(&auth_config, "admin", 0).unwrap(); - assert!(check_token_auth(&admin_token, Some(secret))); + assert!(check_token_auth(&admin_token, Some(&verifier))); let mut perms = PermissionSet::new(); perms.set(Permission::SystemRead); - let user_token = create_jwt_web_user(&auth_config, "user", perms, 0).unwrap(); - assert!(check_token_auth(&user_token, Some(secret))); + let user_token = + create_jwt_web_user(&auth_config, "user", perms, 0, shared::model::UserId::from("web:user")).unwrap(); + assert!(check_token_auth(&user_token, Some(&verifier))); // Missing system read let empty_perms = PermissionSet::new(); - let token_no_perm = create_jwt_web_user(&auth_config, "user2", empty_perms, 0).unwrap(); - assert!(!check_token_auth(&token_no_perm, Some(secret))); + let token_no_perm = + create_jwt_web_user(&auth_config, "user2", empty_perms, 0, shared::model::UserId::from("web:user2")) + .unwrap(); + assert!(!check_token_auth(&token_no_perm, Some(&verifier))); // Invalid secret - assert!(!check_token_auth(&admin_token, Some(b"wrongsecretwrongsecretwrongsecret"))); + let wrong_secret = TokenVerifier::new(&b"wrongsecretwrongsecretwrongsecret"[..], "tuliprox"); + assert!(!check_token_auth(&admin_token, Some(&wrong_secret))); + + // Right secret, wrong issuer: the token must not verify. + let wrong_issuer = TokenVerifier::new(auth_config.secret.as_bytes(), "somebody-else"); + assert!(!check_token_auth(&admin_token, Some(&wrong_issuer))); } } diff --git a/backend/app/src/api/endpoints/m3u_api.rs b/backend/app/src/api/endpoints/m3u_api.rs index 151608e89..621276d61 100644 --- a/backend/app/src/api/endpoints/m3u_api.rs +++ b/backend/app/src/api/endpoints/m3u_api.rs @@ -37,7 +37,7 @@ use shared::{ error::TuliproxError, model::{ CatchupProperties, ConnectFailureReason, FieldGetAccessor, PlaylistEntry, PlaylistItemType, StreamProperties, - TargetType, UserConnectionPermission, XtreamCluster, + TargetType, UserConnectionPermission, }, utils::{concat_path, extract_extension_from_url, sanitize_sensitive_info}, }; @@ -209,14 +209,13 @@ pub(in crate::api) async fn m3u_api_stream_loaded( } if pli.item_type.is_local() { - let playback_session_token = create_session_fingerprint(fingerprint, &user.username, virtual_id, false); + let playback_session_token = create_session_fingerprint(fingerprint, &user.username, virtual_id.get(), false); let user_session = app_state.active_users.get_and_update_user_session(&user.username, &playback_session_token).await; let (admission, _grace_mode, request_class) = crate::api::api_utils::resolve_playback_request_admission( &app_state.admission_ctx(), &user, fingerprint, - pli.item_type, user_session.as_ref(), playback_session_token.as_str(), false, @@ -243,7 +242,7 @@ pub(in crate::api) async fn m3u_api_stream_loaded( .into_response(); } - let cluster = XtreamCluster::try_from(pli.item_type).unwrap_or(XtreamCluster::Live); + let cluster = pli.item_type.cluster(); pli.url = match resolve_initial_stalker_playback_url( app_state, &input, @@ -265,9 +264,20 @@ pub(in crate::api) async fn m3u_api_stream_loaded( let effective_stream_ext = effective_playback_extension(pli.item_type, &pli.url, stream_ext); let extension = effective_stream_ext.unwrap_or_default(); let session_key = if pli.item_type == PlaylistItemType::Catchup { - create_m3u_catchup_session_key(fingerprint, &user.username, virtual_id, archive_discriminator.unwrap_or("live")) + create_m3u_catchup_session_key( + fingerprint, + &user.username, + virtual_id.get(), + archive_discriminator.unwrap_or("live"), + ) } else { - create_playback_session_fingerprint(fingerprint, &user.username, virtual_id, pli.item_type, Some(extension)) + create_playback_session_fingerprint( + fingerprint, + &user.username, + virtual_id.get(), + pli.item_type, + Some(extension), + ) }; let eviction_reentry_guard = if pli.item_type == PlaylistItemType::Catchup || !crate::api::api_utils::is_socket_bound_playback_session(pli.item_type, Some(extension)) @@ -326,7 +336,7 @@ pub(in crate::api) async fn m3u_api_stream_loaded( ConnectFailureReason::ProviderConnectionsExhausted, ); } - if session.virtual_id == virtual_id && is_seekable_media_request(cluster, req_headers, Some(extension)) { + if session.virtual_id == virtual_id.get() && is_seekable_media_request(cluster, req_headers, Some(extension)) { // partial request means we are in reverse proxy mode, seek happened return force_provider_stream_response( fingerprint, @@ -359,7 +369,6 @@ pub(in crate::api) async fn m3u_api_stream_loaded( &app_state.admission_ctx(), &user, fingerprint, - pli.item_type, user_session.as_ref(), &session_key, false, @@ -376,7 +385,7 @@ pub(in crate::api) async fn m3u_api_stream_loaded( let allow_exhausted_shared_reconnect = should_allow_exhausted_shared_reconnect( is_stream_share_enabled(pli.item_type, &target), user_session.as_ref(), - virtual_id, + virtual_id.get(), session_url.as_ref(), ); if connection_permission == UserConnectionPermission::Exhausted && !allow_exhausted_shared_reconnect { @@ -446,7 +455,7 @@ pub(in crate::api) async fn m3u_api_stream_loaded( error!("HLS input stream identity missing for virtual_id={}; refresh target playlist", pli.virtual_id); return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); }; - let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, pli.virtual_id); + let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, pli.virtual_id.get()); return handle_hls_stream_request( fingerprint, app_state, @@ -1078,6 +1087,7 @@ mod tests { use shared::{ model::{ CatchupProperties, ClusterFlags, LiveStreamProperties, M3uPlaylistItem, PlaylistItemType, StreamProperties, + VirtualId, }, utils::Internable, }; @@ -1187,7 +1197,7 @@ mod tests { fn native_flussonic_item(mode: &str, url: &str) -> M3uPlaylistItem { M3uPlaylistItem { - virtual_id: 59, + virtual_id: VirtualId::new(59), provider_id: "59".intern(), name: "Channel".intern(), chno: 0, diff --git a/backend/app/src/api/endpoints/provider_resolve_api.rs b/backend/app/src/api/endpoints/provider_resolve_api.rs index 5dce43198..6a959c7a9 100644 --- a/backend/app/src/api/endpoints/provider_resolve_api.rs +++ b/backend/app/src/api/endpoints/provider_resolve_api.rs @@ -130,7 +130,7 @@ async fn load_provider_resolve_item( ) .await .map(|item| ProviderResolveLoadedItem { - virtual_id: item.virtual_id, + virtual_id: item.virtual_id.get(), item_type: item.item_type, cluster: item.xtream_cluster, content_allowed: user.t_filter.is_none() || user.allows_content(&shared::model::PlaylistItem::from(&item)), @@ -150,7 +150,7 @@ async fn load_provider_resolve_item( ))); } Ok(ProviderResolveLoadedItem { - virtual_id: item.virtual_id, + virtual_id: item.virtual_id.get(), item_type: item.item_type, cluster: decoded_cluster, content_allowed: user.t_filter.is_none() diff --git a/backend/app/src/api/endpoints/rbac_api.rs b/backend/app/src/api/endpoints/rbac_api.rs index f617743da..75a34a265 100644 --- a/backend/app/src/api/endpoints/rbac_api.rs +++ b/backend/app/src/api/endpoints/rbac_api.rs @@ -258,7 +258,7 @@ fn current_web_auth_snapshot(app_state: &AppState) -> Result<(WebAuthConfig, Str fn current_username(app_state: &AppState, token: &str) -> Option { let config = app_state.app_config.config.load(); let web_auth = config.web_ui.as_ref()?.auth.as_ref()?; - verify_token(token, web_auth.secret.as_bytes()).map(|token_data| token_data.claims.username) + verify_token(token, web_auth.secret.as_bytes(), &web_auth.issuer).map(|token_data| token_data.claims.username) } fn store_reprepared_web_auth(app_state: &AppState) -> Result<(), String> { @@ -305,53 +305,29 @@ async fn save_and_reprepare_auth_file( Ok(()) } -fn token_from_extensions_or_headers(request: &mut axum::extract::Request) -> Result { - if let Some(token) = request.extensions().get::().cloned() { - return Ok(token); - } - - let token = AuthBearer::from_headers(request.headers()).map_err(|(status, _)| status)?; - request.extensions_mut().insert(token.clone()); - Ok(token) -} - -async fn check_permission( - permission: Permission, - State(app_state): State>, - mut request: axum::extract::Request, - next: axum::middleware::Next, -) -> Result { - let token = token_from_extensions_or_headers(&mut request)?; - let config = app_state.app_config.config.load(); - let Some(web_auth_config) = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()) else { - return Err(StatusCode::UNAUTHORIZED); - }; - - let Some(token_data) = verify_token(&token.0, web_auth_config.secret.as_bytes()) else { - return Err(StatusCode::UNAUTHORIZED); - }; - - if token_data.claims.permissions.contains(permission) { - return Ok(next.run(request).await); - } - - Err(StatusCode::FORBIDDEN) -} - +// These two used to be a hand-rolled parallel permission check: it verified +// the signature and read `claims.permissions` straight off the token, and so +// skipped the schema-version gate, the subject-id gate, the password-version +// gate and the live-permission intersection that `require_permission` applies. +// They are now the same check as every other route, with the permission +// crossing as a const generic. async fn validator_user_read( state: State>, + auth: crate::auth::AuthBearer, request: axum::extract::Request, next: axum::middleware::Next, -) -> Result { - check_permission(Permission::UserRead, state, request, next).await +) -> axum::response::Response { + crate::api::auth_middleware::require_permission::<{ Permission::UserRead as u32 }>(state, auth, request, next).await } async fn validator_user_write( state: State>, + auth: crate::auth::AuthBearer, request: axum::extract::Request, next: axum::middleware::Next, -) -> Result { - check_permission(Permission::UserWrite, state, request, next).await +) -> axum::response::Response { + crate::api::auth_middleware::require_permission::<{ Permission::UserWrite as u32 }>(state, auth, request, next) + .await } async fn list_users(State(app_state): State>) -> impl IntoResponse { diff --git a/backend/app/src/api/endpoints/recording_api.rs b/backend/app/src/api/endpoints/recording_api.rs index abb563d8f..2c00e5229 100644 --- a/backend/app/src/api/endpoints/recording_api.rs +++ b/backend/app/src/api/endpoints/recording_api.rs @@ -4,13 +4,12 @@ use crate::{ api::{ endpoints::recording_media_api::AuthClaims, model::{ - event_manager::EventMessage, recording_quota, recording_rule_service::{DeleteFuture, RuleServiceError}, recording_service::{ CreateRecordingInput, EditRecordingPatch, RecordingService, RecordingSourceInput, ServiceError, }, - recording_ws, AppState, FileDownload, + recording_ws, AppState, EventMessage, FileDownload, }, }, repository::recording_rule_repository::RecordingRuleRepository, @@ -26,7 +25,7 @@ use serde::{Deserialize, Serialize}; use shared::model::{ recording::{RecordingProvenance, RecordingVisibility}, recording_rule::{RecordingRule, RuleBody, RuleSource, RuleVisibility}, - FileDownloadDto, Permission, UserId, XtreamCluster, ROLE_ADMIN, + FileDownloadDto, Permission, UserId, XtreamCluster, }; use std::sync::Arc; @@ -78,7 +77,7 @@ pub async fn list_recording_tasks( // *seeing* another owner's private tasks, but accepting the // parameter and silently returning an empty list made the API read // as if cross-owner queries were supported. Reject it explicitly. - if params.owner.is_some() && !is_admin(&claims) { + if params.owner.is_some() && !claims.is_admin() { return error_response(StatusCode::FORBIDDEN, "recording_forbidden"); } let (revision, mut tasks) = recording_ws::recording_snapshot(&app_state.downloads, &claims).await; @@ -431,7 +430,7 @@ pub async fn get_recording_health( State(app_state): State>, AuthClaims(claims): AuthClaims, ) -> impl IntoResponse { - if !is_admin(&claims) { + if !claims.is_admin() { return error_response(StatusCode::FORBIDDEN, "recording_forbidden"); } let health = crate::api::model::recording::recording_supervisor::supervisor_health(); @@ -448,7 +447,11 @@ pub async fn get_recording_health( retention_last_tick: health.retention_last_tick(), retention_sweep_interval_secs: recording.and_then(|cfg| cfg.retention.as_ref().map(|r| r.sweep_interval_secs)), notification_last_drain: health.notification_last_drain(), - notification_outbox_depth: health.notification_outbox_depth(), + // Read from the promoted outbox: the supervisor-local counter stopped + // being updated when the outbox moved into `tuliprox-messaging`. + notification_outbox_depth: tuliprox_messaging::outbox::health() + .outbox_depth + .load(std::sync::atomic::Ordering::Relaxed), notification_dead_lettered: health.notification_dead_lettered(), queue_revision: app_state.downloads.revision.load(std::sync::atomic::Ordering::SeqCst), }) @@ -491,8 +494,6 @@ fn recording_rule_repo(app_state: &AppState) -> RecordingRuleRepository { fn can_write_rules(claims: &shared::model::Claims) -> bool { claims.permissions.contains(Permission::RecordingWrite) } -fn is_admin(claims: &shared::model::Claims) -> bool { claims.roles.iter().any(|role| role == ROLE_ADMIN) } - fn quota_limits_from_config(config: Option<&crate::model::RecordingQuotaConfig>) -> recording_quota::QuotaLimits { let mut per_user_bytes = std::collections::HashMap::new(); if let Some(config) = config { @@ -581,7 +582,7 @@ pub async fn list_recording_rules( let Ok(rules) = recording_rule_repo(&app_state).list().await else { return error_response(StatusCode::INTERNAL_SERVER_ERROR, "recording_persistence_failed"); }; - let admin = is_admin(&claims); + let admin = claims.is_admin(); let revision = app_state.downloads.revision.load(std::sync::atomic::Ordering::SeqCst); Json( rules @@ -627,7 +628,7 @@ pub async fn create_recording_rule( if let Err(err) = crate::api::model::recording_rule_service::validate_rule(&rule).and_then(|()| { crate::api::model::recording_rule_service::authorize_rule_action( can_write_rules(&claims), - is_admin(&claims), + claims.is_admin(), &rule.owner_id, &rule, ) @@ -755,7 +756,7 @@ fn authorize_and_apply_recording_rule_edit( let subject_id = claims.subject_id.as_ref().ok_or(EditRuleError::MissingSubject)?; crate::api::model::recording_rule_service::authorize_rule_action( can_write_rules(claims), - is_admin(claims), + claims.is_admin(), subject_id, rule, ) @@ -764,7 +765,7 @@ fn authorize_and_apply_recording_rule_edit( crate::api::model::recording_rule_service::validate_rule(rule).map_err(EditRuleError::Rule)?; crate::api::model::recording_rule_service::authorize_rule_action( can_write_rules(claims), - is_admin(claims), + claims.is_admin(), subject_id, rule, ) @@ -842,7 +843,7 @@ pub async fn delete_recording_rule( }; if let Err(err) = crate::api::model::recording_rule_service::authorize_rule_action( can_write_rules(&claims), - is_admin(&claims), + claims.is_admin(), subject_id, &rule, ) { @@ -1016,7 +1017,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: admin.then(|| ROLE_ADMIN.to_string()).into_iter().collect(), + roles: if admin { shared::model::RoleSet::ADMIN } else { shared::model::RoleSet::new() }, permissions: Permission::RecordingWrite.into(), pwd_version: 0, subject_id, diff --git a/backend/app/src/api/endpoints/recording_media_api.rs b/backend/app/src/api/endpoints/recording_media_api.rs index 7d0f61fde..343e5f136 100644 --- a/backend/app/src/api/endpoints/recording_media_api.rs +++ b/backend/app/src/api/endpoints/recording_media_api.rs @@ -29,7 +29,7 @@ use axum::{ routing::get, RequestPartsExt, }; -use shared::model::{Claims, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, ROLE_ADMIN, TOKEN_NO_AUTH}; +use shared::model::{Claims, RoleSet, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, TOKEN_NO_AUTH}; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -50,7 +50,7 @@ fn builtin_admin_claims() -> Claims { iss: "tuliprox".to_string(), iat: 0, exp: i64::MAX, - roles: vec![ROLE_ADMIN.to_string()], + roles: RoleSet::ADMIN, permissions: PERM_ALL, pwd_version: 0, subject_id: Some(UserId::builtin_admin()), @@ -75,11 +75,11 @@ impl FromRequestParts> for AuthClaims { ) -> Result { let app_state = state.clone(); let AuthBearer(token) = - parts.extract::().await.map_err(|(_, msg)| (StatusCode::UNAUTHORIZED, msg).into_response())?; + parts.extract::().await.map_err(axum::response::IntoResponse::into_response)?; let config = app_state.app_config.config.load(); match config.web_ui.as_ref().and_then(|w| w.auth.as_ref()).filter(|auth| auth.enabled) { Some(web_auth) => { - let token_data = verify_token(&token, web_auth.secret.as_bytes()) + let token_data = verify_token(&token, web_auth.secret.as_bytes(), &web_auth.issuer) .ok_or_else(|| auth_claims_rejection(AuthError::InvalidToken))?; validate_token_claims(&token_data.claims).map_err(auth_claims_rejection)?; Ok(Self(token_data.claims)) @@ -407,7 +407,7 @@ mod tests { use axum::http::Request; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use shared::model::{ - Permission, UserId, WebUiConfigDto, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, ROLE_ADMIN, TOKEN_NO_AUTH, + Permission, RoleSet, UserId, WebUiConfigDto, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, TOKEN_NO_AUTH, }; fn config_with_web_auth(enabled: bool, secret: &str) -> Config { @@ -443,7 +443,7 @@ mod tests { extract_auth_claims(&state, Some(&format!("Bearer {TOKEN_NO_AUTH}"))).await.expect("builtin claims"); assert_eq!(claims.subject_id, Some(UserId::builtin_admin())); - assert_eq!(claims.roles, vec![ROLE_ADMIN]); + assert_eq!(claims.roles, RoleSet::ADMIN); assert_eq!(claims.permissions, PERM_ALL); assert_eq!(claims.permission_schema_version, CURRENT_PERMISSION_SCHEMA_VERSION); assert!(claims.permissions.contains(Permission::RecordingWrite)); @@ -477,6 +477,12 @@ mod tests { assert!(!response.headers().contains_key("X-Token-Refresh")); } + fn test_claims_for_issuer(issuer: &str) -> Claims { + let mut claims = builtin_admin_claims(); + claims.iss = issuer.to_string(); + claims + } + #[tokio::test] async fn auth_claims_enabled_jwt_truth_table() { let config = config_with_web_auth(true, "secret"); @@ -488,10 +494,12 @@ mod tests { extract_auth_claims(&state, Some(&format!("Bearer {valid}"))).await.expect("valid claims"); assert_eq!(claims.subject_id, Some(UserId::builtin_admin())); - let mut stale_claims = builtin_admin_claims(); + let mut stale_claims = test_claims_for_issuer(&web_auth.issuer); stale_claims.permission_schema_version = 0; - let mut missing_subject = builtin_admin_claims(); + + let mut missing_subject = test_claims_for_issuer(&web_auth.issuer); missing_subject.subject_id = None; + for claims in [stale_claims, missing_subject] { let token = encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(b"secret")).expect("jwt"); diff --git a/backend/app/src/api/endpoints/v1_api.rs b/backend/app/src/api/endpoints/v1_api.rs index 6901bcdb2..cd04cc037 100644 --- a/backend/app/src/api/endpoints/v1_api.rs +++ b/backend/app/src/api/endpoints/v1_api.rs @@ -29,6 +29,7 @@ use shared::{ utils::concat_path_leading_slash, }; use std::{collections::BTreeMap, sync::Arc}; +use tuliprox_session::EmitOutcome; pub const API_V1_PATH: &str = "api/v1"; @@ -80,6 +81,51 @@ async fn status(axum::extract::State(app_state): axum::extract::State>) -> axum::response::Response { + let manager = &app_state.event_manager; + let stats = manager.stats(); + + let emitted: Vec<_> = stats + .emitted() + .into_iter() + .filter(|(_, count)| *count > 0) + .map(|(kind, count)| serde_json::json!({ "event": kind.as_wire_name(), "count": count })) + .collect(); + + let recent: Vec<_> = manager + .recent_events() + .into_iter() + .map(|entry| { + serde_json::json!({ + "event": entry.kind.as_wire_name(), + "uptime_millis": entry.uptime_millis, + "outcome": match entry.outcome { + EmitOutcome::Delivered { receivers } => serde_json::json!({ "delivered": receivers }), + EmitOutcome::NoSubscribers => serde_json::json!("no_subscribers"), + EmitOutcome::Coalesced => serde_json::json!("coalesced"), + }, + }) + }) + .collect(); + + let body = serde_json::json!({ + "total_emitted": stats.total_emitted(), + "no_subscribers": stats.no_subscribers(), + "coalesced": stats.coalesced(), + "lagged": stats.lagged(), + "emitted": emitted, + "recent": recent, + }); + + axum::Json(body).into_response() +} + async fn streams( ExtractAcceptHeader(accept): ExtractAcceptHeader, axum::extract::State(app_state): axum::extract::State>, @@ -128,6 +174,7 @@ pub fn v1_api_register( .route("/status", axum::routing::get(status)) .route("/streams", axum::routing::get(streams)) .route("/ipinfo", axum::routing::get(ipinfo)) + .route("/events/stats", axum::routing::get(event_bus)) .route("/stream-history", axum::routing::get(super::stream_history_api::stream_history_page_query)) .route("/stream-history/summary", axum::routing::get(super::stream_history_api::stream_history_summary_query)) .route("/qos-snapshots", axum::routing::get(super::stream_history_api::qos_snapshot_query)) @@ -322,7 +369,13 @@ mod tests { let Some(web_auth) = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()) else { return Err("missing test web auth".into()); }; - let token = create_jwt_web_user(web_auth, "alice", Permission::RecordingRead.into(), 0)?; + let token = create_jwt_web_user( + web_auth, + "alice", + Permission::RecordingRead.into(), + 0, + shared::model::UserId::from("web:alice"), + )?; let app_state = create_test_app_state(config); let router = v1_api_register(true, &app_state, "").with_state(app_state); diff --git a/backend/app/src/api/endpoints/v1_api_config.rs b/backend/app/src/api/endpoints/v1_api_config.rs index b174c070d..86ba544f6 100644 --- a/backend/app/src/api/endpoints/v1_api_config.rs +++ b/backend/app/src/api/endpoints/v1_api_config.rs @@ -93,10 +93,21 @@ fn has_any_permission(permissions: PermissionSet, required: &[Permission]) -> bo required.iter().any(|permission| permissions.contains(*permission)) } +/// The permissions this token may act with *right now*. +/// +/// The claim alone is a snapshot from mint time, so a revoked group permission +/// kept filtering config in until the token expired. Intersecting with the live +/// grant makes a revocation immediate without ever widening a token beyond what +/// it was issued with. fn decode_permissions(app_state: &AppState, token: &str) -> Option { let config = app_state.app_config.config.load(); let web_auth = config.web_ui.as_ref()?.auth.as_ref()?; - verify_token(token, web_auth.secret.as_bytes()).map(|token_data| token_data.claims.permissions) + let claims = verify_token(token, web_auth.secret.as_bytes(), &web_auth.issuer)?.claims; + Some( + web_auth + .resolve_permissions_if_known(&claims.username) + .map_or(claims.permissions, |live| claims.permissions & live), + ) } fn filter_api_proxy_by_permissions(api_proxy: &mut ApiProxyConfigDto, permissions: PermissionSet) { @@ -111,6 +122,14 @@ fn filter_api_proxy_by_permissions(api_proxy: &mut ApiProxyConfigDto, permission } fn filter_app_config_by_permissions(app_config: &mut shared::model::AppConfigDto, permissions: Option) { + // Unconditional: `config.yml` goes out in full to anyone with + // `ConfigRead`, which included the Telegram bot token, the Pushover + // credentials and any `Authorization` header on the REST channel. + // `save_config_main` puts a returned mask back, so the round-trip is + // lossless. + if let Some(messaging) = app_config.config.messaging.as_mut() { + messaging.redact_secrets(); + } if let Some(permissions) = permissions { if !permissions.contains(Permission::ConfigRead) { app_config.config = ConfigDto::default(); @@ -186,6 +205,16 @@ async fn save_config_main( return response; } + // A client that echoes a redacted secret back means "keep what is + // stored". Without this the round-trip would write the mask over the + // real token and silently break the channel. + if let Some(incoming) = cfg.messaging.as_mut() { + let stored = app_state.app_config.config.load(); + if let Some(current) = stored.messaging.as_ref() { + incoming.restore_redacted_secrets(&shared::model::MessagingConfigDto::from(current)); + } + } + if let Err(err) = cfg.prepare(false) { return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); } @@ -559,7 +588,9 @@ async fn get_xtream_login_info( } }; let http_client = app_state.http_client.load(); - match xtream_login(&app_state.app_config, &http_client, &input_source, &request.username).await { + match xtream_login(&app_state.app_config, &http_client, &app_state.event_manager, &input_source, &request.username) + .await + { Ok(login_info) => axum::Json(login_info.unwrap_or_default()).into_response(), Err(err) => { error!("Failed to get xtream login info: {err}"); @@ -685,6 +716,7 @@ pub fn v1_api_config_register_with_permissions(app_state: &Arc) -> Rou .layer(permission_layer!(app_state, Permission::SourceWrite)); let config_write = Router::new() + .route("/config/messaging/test", axum::routing::post(test_messaging)) .route("/config/main", axum::routing::post(save_config_main)) .route("/config/apiproxy", axum::routing::put(save_config_api_proxy_config)) .route("/config/plans", axum::routing::put(save_config_plans)) @@ -693,6 +725,91 @@ pub fn v1_api_config_register_with_permissions(app_state: &Arc) -> Rou Router::new().merge(base_read).merge(config_read).merge(source_read).merge(source_write).merge(config_write) } +/// Request body for `POST /config/messaging/test`. +#[derive(serde::Deserialize)] +pub struct MessagingTestRequest { + /// Event id to simulate. Defaults to `system.info`. + #[serde(default)] + pub event: Option, + /// Restrict to one channel by its stable id (`telegram`, `discord`, ...). + /// Absent means every configured channel. + #[serde(default)] + pub channel: Option, + /// Render only, send nothing. Lets a template be iterated without + /// spamming a channel. + #[serde(default)] + pub preview: bool, +} + +/// What one channel did with the test event. +#[derive(serde::Serialize)] +pub struct MessagingTestChannelResult { + pub channel: String, + /// `delivered`, `skipped`, `retry`, `permanent`, or `preview`. + pub outcome: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Exactly what the channel was asked to send. + pub rendered: String, + /// Whether an operator template produced `rendered`. + pub templated: bool, +} + +#[derive(serde::Serialize)] +pub struct MessagingTestResponse { + pub event: String, + pub severity: String, + pub results: Vec, +} + +/// Send (or render) a test notification. +/// +/// Messaging config previously had no feedback loop shorter than "save it +/// and wait for something to break". `preview` renders without sending, so a +/// template can be iterated safely. +async fn test_messaging( + axum::extract::State(app_state): axum::extract::State>, + axum::Json(request): axum::Json, +) -> impl axum::response::IntoResponse { + let requested = request.event.as_deref().unwrap_or("system.info"); + let Some(event_id) = shared::model::notification::EventId::from_wire(requested) else { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": format!("unknown event `{requested}`"), + })), + ) + .into_response(); + }; + + let event = tuliprox_messaging::test_event(event_id); + let client = app_state.http_client.load(); + let results = tuliprox_messaging::render_and_send_test( + &app_state.app_config, + &client, + &event, + request.channel.as_deref(), + request.preview, + ) + .await; + + let response = MessagingTestResponse { + event: event.id.to_string(), + severity: event.severity.to_string(), + results: results + .into_iter() + .map(|outcome| MessagingTestChannelResult { + channel: outcome.channel, + outcome: outcome.outcome, + reason: outcome.reason, + rendered: outcome.rendered, + templated: outcome.templated, + }) + .collect(), + }; + (axum::http::StatusCode::OK, axum::Json(response)).into_response() +} + #[cfg(test)] mod tests { use super::{ diff --git a/backend/app/src/api/endpoints/v1_api_playlist.rs b/backend/app/src/api/endpoints/v1_api_playlist.rs index 38c54b309..d7b5eca9b 100644 --- a/backend/app/src/api/endpoints/v1_api_playlist.rs +++ b/backend/app/src/api/endpoints/v1_api_playlist.rs @@ -55,7 +55,7 @@ use shared::{ model::{ permission::Permission, stalker::StalkerStreamKind, EpgChannel, InputType, OperationRunAccepted, PlaylistEpgRequest, PlaylistItem, PlaylistRequest, PlaylistUrlResolveRequest, ProxyType, TargetType, - UiPlaylistItem, XtreamCluster, + UiPlaylistItem, VirtualId, XtreamCluster, }, utils::{concat_path_leading_slash, deobfuscate_text, sanitize_sensitive_info, Internable}, }; @@ -184,7 +184,7 @@ pub(in crate::api) fn build_webplayer_recording_url( virtual_id: u32, cluster: XtreamCluster, ) -> Option { - let access_token = create_access_token(&app_config.access_token_secret, 30); + let access_token = create_access_token(&app_config.access_token_secret, 30, crate::auth::scope::INTERNAL_PLAYER); let config = app_config.config.load(); let server_name = config .web_ui @@ -202,7 +202,7 @@ pub(in crate::api) fn build_stable_recording_url( virtual_id: u32, cluster: XtreamCluster, ) -> Option { - let access_token = create_access_token(&app_config.access_token_secret, 30); + let access_token = create_access_token(&app_config.access_token_secret, 30, crate::auth::scope::INTERNAL_PLAYER); let config = app_config.config.load(); let server_name = config .web_ui @@ -232,9 +232,9 @@ pub(in crate::api) async fn resolve_target_recording_source( if let Some(mut items) = iter_raw_xtream_target_playlist(app_config, &target, cluster).await { while let Some(entry) = items.next().await { let Ok(item) = entry else { continue }; - if item.virtual_id == virtual_id { + if item.virtual_id == VirtualId::new(virtual_id) { resolved = Some(ResolvedRecordingSource { - virtual_id: item.virtual_id, + virtual_id: item.virtual_id.get(), input_name: item.input_name.to_string(), }); break; @@ -246,9 +246,9 @@ pub(in crate::api) async fn resolve_target_recording_source( if let Some(mut items) = iter_raw_m3u_target_playlist(app_config, &target, Some(cluster)).await { while let Some(entry) = items.next().await { let Ok(item) = entry else { continue }; - if item.virtual_id == virtual_id { + if item.virtual_id == VirtualId::new(virtual_id) { resolved = Some(ResolvedRecordingSource { - virtual_id: item.virtual_id, + virtual_id: item.virtual_id.get(), input_name: item.input_name.to_string(), }); break; @@ -282,7 +282,7 @@ pub(in crate::api) async fn resolve_target_live_recording_source_by_epg_channel( let Ok(item) = entry else { continue }; if item.epg_channel_id.as_deref() == Some(epg_channel_id) { let candidate = ResolvedRecordingSource { - virtual_id: item.virtual_id, + virtual_id: item.virtual_id.get(), input_name: item.input_name.to_string(), }; if resolved.replace(candidate).is_some() { @@ -296,7 +296,7 @@ pub(in crate::api) async fn resolve_target_live_recording_source_by_epg_channel( let Ok(item) = entry else { continue }; if item.epg_channel_id.as_deref() == Some(epg_channel_id) { let candidate = ResolvedRecordingSource { - virtual_id: item.virtual_id, + virtual_id: item.virtual_id.get(), input_name: item.input_name.to_string(), }; if resolved.replace(candidate).is_some() { @@ -681,7 +681,7 @@ async fn playlist_webplayer_stream( axum::extract::State(app_state): axum::extract::State>, req_headers: axum::http::HeaderMap, ) -> impl IntoResponse + Send { - if !verify_access_token(&token, &app_state.app_config.access_token_secret) { + if !verify_access_token(&token, &app_state.app_config.access_token_secret, crate::auth::scope::INTERNAL_PLAYER) { return axum::http::StatusCode::FORBIDDEN.into_response(); } @@ -737,7 +737,7 @@ async fn playlist_recording_stream( axum::extract::State(app_state): axum::extract::State>, req_headers: axum::http::HeaderMap, ) -> impl IntoResponse + Send { - if !verify_access_token(&token, &app_state.app_config.access_token_secret) { + if !verify_access_token(&token, &app_state.app_config.access_token_secret, crate::auth::scope::INTERNAL_PLAYER) { return axum::http::StatusCode::FORBIDDEN.into_response(); } let ctxt = try_result_bad_request!(ApiStreamContext::from_str(cluster.as_str())); @@ -771,7 +771,7 @@ async fn playlist_recording_stream( true, format!("Failed to read m3u item for stream id {virtual_id}") ); - if pli.input_name != resolved.input.name || XtreamCluster::try_from(pli.item_type).ok() != Some(ctxt.cluster()) { + if pli.input_name != resolved.input.name || pli.item_type.cluster() != ctxt.cluster() { return axum::http::StatusCode::BAD_REQUEST.into_response(); } let user = Arc::new(create_api_proxy_user(&app_state)); @@ -1252,13 +1252,15 @@ mod tests { mapping: Arc::default(), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, })], }, )); let app_state = test_app_state(Arc::clone(&app_config)); - let token = crate::auth::create_access_token(&app_config.access_token_secret, 1); + let token = + crate::auth::create_access_token(&app_config.access_token_secret, 1, crate::auth::scope::INTERNAL_PLAYER); let fingerprint = crate::auth::Fingerprint::new( "test".to_string(), "127.0.0.1".to_string(), @@ -1359,6 +1361,7 @@ mod tests { mapping: Arc::default(), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }) @@ -1457,6 +1460,13 @@ mod tests { geoip, update_guard: crate::api::model::UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } @@ -1563,6 +1573,7 @@ mod tests { mapping: Arc::default(), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }); @@ -1627,6 +1638,7 @@ mod tests { mapping: Arc::default(), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }); @@ -1677,6 +1689,7 @@ mod tests { mapping: Arc::default(), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }); diff --git a/backend/app/src/api/endpoints/v1_api_user.rs b/backend/app/src/api/endpoints/v1_api_user.rs index 0d01aa23a..2419b449a 100644 --- a/backend/app/src/api/endpoints/v1_api_user.rs +++ b/backend/app/src/api/endpoints/v1_api_user.rs @@ -10,7 +10,10 @@ use crate::{ use axum::{response::IntoResponse, Router}; use serde_json::json; use shared::{ - model::{permission::Permission, ApiProxyConfigDto, ProxyUserCredentialsDto}, + model::{ + permission::Permission, ApiProxyConfigDto, EventMessage, ProxyUserCredentialsDto, UserLifecycleEvent, + UserLifecycleState, + }, utils::mask_credentials, }; use std::{path::Path, sync::Arc}; @@ -177,6 +180,14 @@ async fn save_config_api_proxy_user( // Update state after successful save app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy))); + // Only after the write succeeded and the new config is live: an event + // published before the store could describe a state no reader can see. + app_state.event_manager.send_event(EventMessage::UserLifecycle(UserLifecycleEvent::new( + new_user.username.as_str().into(), + target_name.as_str().into(), + if is_update { UserLifecycleState::Updated } else { UserLifecycleState::Created }, + ))); + if target_has_alias_pool_min(&app_state, &target_name) { let app_state_clone = Arc::clone(&app_state); let target_name_clone = target_name.clone(); @@ -233,6 +244,12 @@ async fn delete_config_api_proxy_user( } } app_state.app_config.api_proxy.store(Some(Arc::clone(&new_api_proxy))); + + app_state.event_manager.send_event(EventMessage::UserLifecycle(UserLifecycleEvent::new( + username.as_str().into(), + target_name.as_str().into(), + UserLifecycleState::Deleted, + ))); } else { return ( axum::http::StatusCode::BAD_REQUEST, diff --git a/backend/app/src/api/endpoints/web_index.rs b/backend/app/src/api/endpoints/web_index.rs index d8c7af41c..1b5e70497 100644 --- a/backend/app/src/api/endpoints/web_index.rs +++ b/backend/app/src/api/endpoints/web_index.rs @@ -1,20 +1,24 @@ use crate::{ api::{ api_utils::{serve_file, try_unwrap_body}, + auth_middleware::AuthorizedClaims, model::AppState, }, - auth::{create_jwt_admin, create_jwt_api_user, create_jwt_web_user, verify_password, verify_token, AuthBearer}, + auth::{ + create_jwt_admin, create_jwt_api_user, create_jwt_web_user, validate_password_version, verify_password, + verify_token, AuthBearer, + }, model::WebAuthConfig, }; use axum::{body::Body, http::Request, response::IntoResponse}; use base64::Engine; -use log::{debug, error}; +use log::{debug, error, warn}; use lol_html::{element, RewriteStrSettings}; use rand::{rngs::OsRng, RngCore, TryRngCore}; use serde_json::json; use shared::{ - model::{TokenResponse, UserCredential, TOKEN_NO_AUTH}, - utils::{concat_path_leading_slash, CONSTANTS}, + model::{AuthAuditEvent, EventMessage, EventSink, TokenResponse, UserCredential, TOKEN_NO_AUTH}, + utils::{concat_path_leading_slash, sanitize_sensitive_info, CONSTANTS}, }; use std::{ path::{Path, PathBuf}, @@ -29,64 +33,239 @@ fn no_web_auth_token() -> impl axum::response::IntoResponse + Send { fn api_user_can_access_web_ui(ui_enabled: bool) -> bool { ui_enabled } +/// The stable subject id for a web user, allocating one on first sight. +/// +/// `register` is get-or-create, so a user bootstrap already synced keeps the +/// id it has and one added since gets a fresh one. This used to be +/// `format!("web:{username}")`, which made the subject a function of the +/// display name: renaming a user reassigned everything the old subject owned. +async fn web_subject_id(app_state: &Arc, username: &str) -> Option { + match app_state.identity_registry.register(username).await { + Ok(id) => Some(id), + Err(err) => { + error!("Cannot resolve a stable subject id for web user '{username}': {err}"); + None + } + } +} + +/// The stable subject id for a proxy API user, allocating one on first sight. +async fn api_subject_id(app_state: &Arc, username: &str) -> Option { + match app_state.identity_registry.register_api_user(username).await { + Ok(id) => Some(id), + Err(err) => { + error!("Cannot resolve a stable subject id for API user '{username}': {err}"); + None + } + } +} + +/// Publish one authentication decision. +/// +/// Sign-ins and their failures went to `warn!` and nowhere else, so nothing +/// that subscribes to the bus - a notification channel, a plugin, an audit +/// sink - could see the events that matter most for spotting an intrusion. +fn emit_auth_audit(app_state: &Arc, event: AuthAuditEvent) { + app_state.event_manager.emit(EventMessage::AuthAudit(event)); +} + +/// A 429 carrying the wait, so a client backs off instead of hammering. +fn too_many_attempts(retry_after: std::time::Duration) -> axum::response::Response { + let seconds = retry_after.as_secs().max(1); + axum::http::Response::builder() + .status(axum::http::StatusCode::TOO_MANY_REQUESTS) + .header(axum::http::header::RETRY_AFTER, seconds.to_string()) + .body(axum::body::Body::empty()) + .unwrap_or_else(|_| axum::http::StatusCode::TOO_MANY_REQUESTS.into_response()) +} + +/// The outcome of one sign-in branch. +/// +/// `Rejected` means "these are not valid credentials for this branch, try the +/// next one"; `Refused` is a decided answer the caller must return as-is, +/// which is what keeps a `ui_enabled: false` API user from silently falling +/// through to the generic 401. +enum SignInAttempt { + Issued(String), + Refused(axum::response::Response), + Rejected, +} + +/// Sign in against `web_ui.auth`. +async fn web_user_sign_in( + app_state: &Arc, + web_auth: &WebAuthConfig, + username: &str, + password: &str, +) -> SignInAttempt { + let Some(hash) = web_auth.get_user_password(username) else { + return SignInAttempt::Rejected; + }; + if !verify_password(hash, password.as_bytes()) { + return SignInAttempt::Rejected; + } + + let pwd_version = WebAuthConfig::pwd_version_from_hash(hash); + let permissions = web_auth.resolve_permissions(username); + let user_entry = web_auth + .t_users + .as_ref() + .and_then(|users| users.iter().find(|user| user.username.eq_ignore_ascii_case(username))); + let is_admin = user_entry.is_some_and(|user| user.groups.iter().any(|group| group.eq_ignore_ascii_case("admin"))); + let user_groups = user_entry.map(|user| user.groups.clone()).unwrap_or_default(); + debug!( + "Web login success candidate: username='{username}', groups={user_groups:?}, is_admin={is_admin}, permissions={permissions}", + ); + + let token_result = if is_admin { + create_jwt_admin(web_auth, username, pwd_version) + } else { + let Some(subject_id) = web_subject_id(app_state, username).await else { + return SignInAttempt::Refused(axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()); + }; + create_jwt_web_user(web_auth, username, permissions, pwd_version, subject_id) + }; + token_result.map_or(SignInAttempt::Rejected, SignInAttempt::Issued) +} + +/// Sign in against the proxy API-user credentials. +async fn api_user_sign_in( + app_state: &Arc, + web_auth: &WebAuthConfig, + username: &str, + password: &str, +) -> SignInAttempt { + let Some(credentials) = app_state.app_config.get_user_credentials(username) else { + return SignInAttempt::Rejected; + }; + if !crate::auth::constant_time_eq(credentials.password.as_bytes(), password.as_bytes()) { + return SignInAttempt::Rejected; + } + if !api_user_can_access_web_ui(credentials.ui_enabled) { + // A decided answer, not a rejection: the credentials were correct and + // this principal is still not allowed into the Web UI. + return SignInAttempt::Refused(axum::http::StatusCode::FORBIDDEN.into_response()); + } + let Some(subject_id) = api_subject_id(app_state, username).await else { + return SignInAttempt::Refused(axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()); + }; + create_jwt_api_user(web_auth, username, subject_id).map_or(SignInAttempt::Rejected, SignInAttempt::Issued) +} + async fn token( axum::extract::State(app_state): axum::extract::State>, + fingerprint: crate::auth::Fingerprint, axum::extract::Json(mut req): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { let config = &app_state.app_config.config.load(); - match config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) { - None => no_web_auth_token().into_response(), - Some(web_auth) => { - if !web_auth.enabled { - return no_web_auth_token().into_response(); - } - let username = req.username.as_str(); - let password = req.password.as_str(); + let Some(web_auth) = config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) else { + return no_web_auth_token().into_response(); + }; + if !web_auth.enabled { + return no_web_auth_token().into_response(); + } - if !(username.is_empty() || password.is_empty()) { - if let Some(hash) = web_auth.get_user_password(username) { - if verify_password(hash, password.as_bytes()) { - let pwd_version = WebAuthConfig::pwd_version_from_hash(hash); - let permissions = web_auth.resolve_permissions(username); - let user_entry = web_auth - .t_users - .as_ref() - .and_then(|users| users.iter().find(|user| user.username.eq_ignore_ascii_case(username))); - let is_admin = user_entry - .is_some_and(|user| user.groups.iter().any(|group| group.eq_ignore_ascii_case("admin"))); - let user_groups = user_entry.map(|user| user.groups.clone()).unwrap_or_default(); - debug!( - "Web login success candidate: username='{username}', groups={user_groups:?}, is_admin={is_admin}, permissions={permissions}", - ); - let token_result = if is_admin { - create_jwt_admin(web_auth, username, pwd_version) - } else { - create_jwt_web_user(web_auth, username, permissions, pwd_version) - }; - if let Ok(token) = token_result { - req.zeroize(); - return axum::Json(TokenResponse { token, username: req.username.clone() }).into_response(); - } - } - } - if let Some(credentials) = app_state.app_config.get_user_credentials(username) { - if crate::auth::constant_time_eq(credentials.password.as_bytes(), password.as_bytes()) { - if !api_user_can_access_web_ui(credentials.ui_enabled) { - req.zeroize(); - return axum::http::StatusCode::FORBIDDEN.into_response(); - } - if let Ok(token) = create_jwt_api_user(web_auth, username) { - req.zeroize(); - return axum::Json(TokenResponse { token, username: req.username.clone() }).into_response(); - } - } - } - } + let client_ip: Arc = Arc::from(fingerprint.client_ip.as_str()); + let username = req.username.clone(); - req.zeroize(); - axum::http::StatusCode::UNAUTHORIZED.into_response() + // Before the argon2 verify, not after: the point is to stop a password + // list, and an attacker who can still force the hash on every attempt has + // not been slowed down. + if let Some(retry_after) = app_state.login_throttle.retry_after(&username, &fingerprint.client_ip) { + warn!( + "Sign-in throttled for '{}' from {}; {}s remaining", + sanitize_sensitive_info(&username), + fingerprint.client_ip, + retry_after.as_secs() + ); + emit_auth_audit(&app_state, AuthAuditEvent::sign_in_throttled(Arc::from(username.as_str()), client_ip)); + req.zeroize(); + return too_many_attempts(retry_after); + } + + if !(username.is_empty() || req.password.is_empty()) { + // Sequential and short-circuiting: the API branch must not run once + // the web branch has answered. It compares credentials and can + // allocate a persisted subject id, neither of which a successful web + // sign-in should trigger. + let mut attempt = web_user_sign_in(&app_state, web_auth, &username, &req.password).await; + if matches!(attempt, SignInAttempt::Rejected) { + attempt = api_user_sign_in(&app_state, web_auth, &username, &req.password).await; + } + match attempt { + SignInAttempt::Issued(token) => { + app_state.login_throttle.record_success(&username, &fingerprint.client_ip); + emit_auth_audit(&app_state, AuthAuditEvent::sign_in_succeeded(Arc::from(username.as_str()), client_ip)); + req.zeroize(); + return axum::Json(TokenResponse { token, username }).into_response(); + } + SignInAttempt::Refused(response) => { + req.zeroize(); + return response; + } + SignInAttempt::Rejected => {} } } + + app_state.login_throttle.record_failure(&username, &fingerprint.client_ip); + emit_auth_audit(&app_state, AuthAuditEvent::sign_in_failed(Arc::from(username.as_str()), client_ip)); + warn!("Sign-in rejected for '{}' from {}", sanitize_sensitive_info(&username), fingerprint.client_ip); + req.zeroize(); + axum::http::StatusCode::UNAUTHORIZED.into_response() +} + +/// The permission required to end somebody else's sessions. +const REVOKE_PERMISSION: u32 = shared::model::permission::Permission::UserWrite as u32; + +/// Revoke every token already issued to one principal. +/// +/// The requirement is in the signature rather than a router layer, because the +/// `/auth` routes are mounted before any state is available to build one. +async fn revoke_user_tokens( + AuthorizedClaims(actor): AuthorizedClaims, + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Path(username): axum::extract::Path, +) -> impl axum::response::IntoResponse + Send { + // Both namespaces: an operator names a principal, not a namespace, and a + // username can exist in either. + let subjects: Vec<_> = [ + app_state.identity_registry.lookup_by_username(&username).await, + app_state.identity_registry.lookup_api_by_username(&username).await, + ] + .into_iter() + .flatten() + .collect(); + + if subjects.is_empty() { + return axum::http::StatusCode::NOT_FOUND.into_response(); + } + + let now = chrono::Utc::now().timestamp(); + for subject in &subjects { + if let Err(err) = app_state.token_revocations.revoke_subject(subject, now).await { + error!("Cannot persist token revocation for '{username}': {err}"); + return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + } + warn!("'{}' revoked every token issued to '{username}'", actor.username); + axum::http::StatusCode::NO_CONTENT.into_response() +} + +/// Revoke every token issued to every principal. +/// +/// The response to a compromise. Short of rotating the signing secret this was +/// not possible at all, and rotating the secret is not reversible or auditable. +async fn revoke_all_tokens( + AuthorizedClaims(actor): AuthorizedClaims, + axum::extract::State(app_state): axum::extract::State>, +) -> impl axum::response::IntoResponse + Send { + let now = chrono::Utc::now().timestamp(); + if let Err(err) = app_state.token_revocations.revoke_all(now).await { + error!("Cannot persist a global token revocation: {err}"); + return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + warn!("'{}' revoked every token issued to every principal", actor.username); + axum::http::StatusCode::NO_CONTENT.into_response() } async fn token_refresh( @@ -101,25 +280,32 @@ async fn token_refresh( return no_web_auth_token().into_response(); } let secret_key = web_auth.secret.as_ref(); - let maybe_token_data = verify_token(&token, secret_key); + let maybe_token_data = verify_token(&token, secret_key, &web_auth.issuer); if let Some(token_data) = maybe_token_data { let claims = token_data.claims; + // A revoked token must not be exchangeable for a fresh one - + // that would make revocation a formality. + if app_state.token_revocations.is_revoked(&claims).await { + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + } let username = claims.username.as_str(); - if claims.roles.iter().any(|role| role.eq_ignore_ascii_case(shared::model::ROLE_API_USER)) { + if claims.is_api_user() { let Some(user) = app_state.app_config.get_user_credentials(username) else { return axum::http::StatusCode::UNAUTHORIZED.into_response(); }; if !user.ui_enabled { return axum::http::StatusCode::FORBIDDEN.into_response(); } - if let Ok(token) = create_jwt_api_user(web_auth, username) { + let Some(subject_id) = api_subject_id(&app_state, username).await else { + return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + if let Ok(token) = create_jwt_api_user(web_auth, username, subject_id) { return axum::Json(TokenResponse { token, username: claims.username }).into_response(); } return axum::http::StatusCode::UNAUTHORIZED.into_response(); } - let token_pwd_version = claims.pwd_version; let Some(users) = web_auth.t_users.as_ref() else { return axum::http::StatusCode::UNAUTHORIZED.into_response(); }; @@ -129,7 +315,9 @@ async fn token_refresh( }; let current_pwd_version = WebAuthConfig::pwd_version_from_hash(&user.password_hash); - if token_pwd_version != 0 && token_pwd_version != current_pwd_version { + // This used to read `pwd_version != 0 && pwd_version != current`, + // so a token carrying `0` skipped the check entirely. + if validate_password_version(&claims, current_pwd_version).is_err() { return axum::http::StatusCode::UNAUTHORIZED.into_response(); } @@ -142,7 +330,10 @@ async fn token_refresh( let new_token = if is_admin { create_jwt_admin(web_auth, username, current_pwd_version) } else { - create_jwt_web_user(web_auth, username, resolved_permissions, current_pwd_version) + let Some(subject_id) = web_subject_id(&app_state, username).await else { + return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + create_jwt_web_user(web_auth, username, resolved_permissions, current_pwd_version, subject_id) }; if let Ok(token) = new_token { return axum::Json(TokenResponse { token, username: user.username.clone() }).into_response(); @@ -328,7 +519,9 @@ pub fn index_register_without_path(web_dir_path: &Path) -> axum::Router axum: let auth_router = axum::Router::new() .route("/token", axum::routing::post(token)) - .route("/refresh", axum::routing::post(token_refresh)); + .route("/refresh", axum::routing::post(token_refresh)) + .route("/revoke/{username}", axum::routing::post(revoke_user_tokens)) + .route("/revoke", axum::routing::post(revoke_all_tokens)); let web_ui_path_clone = web_ui_path.to_string(); axum::Router::new() diff --git a/backend/app/src/api/endpoints/websocket_api.rs b/backend/app/src/api/endpoints/websocket_api.rs index 3942f1ce1..fb2fe9a76 100644 --- a/backend/app/src/api/endpoints/websocket_api.rs +++ b/backend/app/src/api/endpoints/websocket_api.rs @@ -3,7 +3,7 @@ use crate::{ endpoints::{download_api::download_queue_snapshot, v1_api::create_status_check}, model::{AppState, EventMessage}, }, - auth::{validate_token_claims, verify_token}, + auth::{validate_token_claims, TokenVerifier}, }; use axum::{ extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}, @@ -13,8 +13,8 @@ use log::{error, trace}; use shared::{ defaults::default_kick_secs, model::{ - Claims, Permission, ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage, UserCommand, UserId, UserRole, - WsCloseCode, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, PROTOCOL_VERSION, ROLE_ADMIN, TOKEN_NO_AUTH, + Claims, Permission, ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage, RoleSet, UserCommand, UserId, + UserRole, WsCloseCode, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, PROTOCOL_VERSION, TOKEN_NO_AUTH, }, utils::concat_path_leading_slash, }; @@ -91,7 +91,7 @@ fn set_websocket_auth(mem: &mut ProtocolHandlerMemory, auth_token: String, claim return false; } mem.permissions = claims.permissions; - mem.role = if claims.roles.iter().any(|role| role == ROLE_ADMIN) { UserRole::Admin } else { UserRole::User }; + mem.role = if claims.is_admin() { UserRole::Admin } else { UserRole::User }; mem.subject_id = claims.subject_id.as_ref().map(|u| u.0.clone()); mem.token = Some(auth_token); true @@ -111,7 +111,7 @@ fn websocket_claims(mem: &ProtocolHandlerMemory) -> Option { iss: "tuliprox".to_string(), iat: 0, exp: i64::MAX, - roles: if mem.role == UserRole::Admin { vec![ROLE_ADMIN.to_string()] } else { Vec::new() }, + roles: if mem.role == UserRole::Admin { RoleSet::ADMIN } else { RoleSet::new() }, permissions: mem.permissions, pwd_version: 0, subject_id: Some(subject_id), @@ -129,22 +129,22 @@ fn websocket_requires_download_read(auth_required: bool, mem: &ProtocolHandlerMe !auth_required || mem.permissions.contains(Permission::DownloadRead) } +/// Which permission an event needs is a fact about the event, not about the +/// websocket: it does not change with the transport carrying it. The table +/// lives on `EventKind`, so a new variant cannot reach a session that should +/// not see it just because this file was not updated. fn websocket_can_receive_runtime_events(mem: &ProtocolHandlerMemory, event: &EventMessage) -> bool { - match event { - EventMessage::DownloadsUpdate(_) | EventMessage::DownloadsDeltaUpdate(_) => { - mem.permissions.contains(Permission::DownloadRead) - } - EventMessage::RecordingChanged | EventMessage::RecordingRulesChanged => { - mem.permissions.contains(Permission::RecordingRead) - } - EventMessage::PlaylistUpdateProgress(_) | EventMessage::PlaylistUpdate(_) => { - mem.permissions.contains(Permission::PlaylistWrite) - } - EventMessage::LibraryScanProgress(_) => mem.permissions.contains(Permission::LibraryWrite), - _ => mem.permissions.contains(Permission::SystemRead), - } + mem.permissions.contains(event.required_permission()) } +/// The payload itself, cloned only if this session is not the last holder. +/// +/// The bus keeps the large payloads behind `Arc` so that subscribers who do +/// not serialize them - the notification bridge, say - pay a refcount bump +/// instead of a deep copy. Only here, at the wire boundary, is the value +/// itself needed. +fn unwrap_or_clone(value: Arc) -> T { Arc::try_unwrap(value).unwrap_or_else(|shared| (*shared).clone()) } + #[derive(Debug, Clone, Copy, Eq, PartialEq)] enum MainEventReceiveErrorAction { Continue, @@ -165,15 +165,16 @@ fn main_event_receive_error_action( } } -fn get_secret_key(app_state: &AppState, auth: bool) -> Option> { +/// The verifier this socket authenticates against. +/// +/// This used to hand a bare `Vec` secret around, which is precisely why +/// the WebSocket paths could not check the token's issuer. +fn get_token_verifier(app_state: &AppState, auth: bool) -> Option { if !auth { return None; } - app_state.app_config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()).map(|c| { - let secret_key: &[u8] = c.secret.as_ref(); - secret_key.to_vec() - }) + app_state.app_config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()).map(TokenVerifier::from_config) } async fn handle_handshake(msg: Message, socket: &mut WebSocket, version: u8) -> Result<(), WebSocketApiError> { @@ -203,7 +204,7 @@ async fn handle_protocol_message( mem: &mut ProtocolHandlerMemory, app_state: &Arc, auth_required: bool, - secret_key: Option<&Vec>, + verifier: Option<&TokenVerifier>, ) -> Option { if let Message::Binary(bytes) = msg { match ProtocolMessage::from_bytes(bytes) { @@ -213,11 +214,11 @@ async fn handle_protocol_message( return Some(ProtocolMessage::Authorized); } - let Some(secret_key) = secret_key else { + let Some(verifier) = verifier else { return Some(ProtocolMessage::Unauthorized); }; - let Some(token_data) = verify_token(&auth_token, secret_key.as_slice()) else { + let Some(token_data) = verifier.verify(&auth_token) else { return Some(ProtocolMessage::Unauthorized); }; @@ -229,11 +230,11 @@ async fn handle_protocol_message( } Ok(ProtocolMessage::StatusRequest(auth_token)) => { if auth_required { - let Some(secret_key) = secret_key else { + let Some(verifier) = verifier else { return Some(ProtocolMessage::Unauthorized); }; - let Some(token_data) = verify_token(&auth_token, secret_key.as_slice()) else { + let Some(token_data) = verifier.verify(&auth_token) else { return Some(ProtocolMessage::Unauthorized); }; @@ -283,7 +284,7 @@ async fn handle_protocol_message( None } Ok(ProtocolMessage::ActiveProviderCountRequest(auth_token)) => { - handle_active_provider_count_request(auth_token, mem, app_state, auth_required, secret_key).await + handle_active_provider_count_request(auth_token, mem, app_state, auth_required, verifier).await } Ok(_) => { trace!("Unexpected protocol message after handshake"); @@ -321,13 +322,13 @@ async fn handle_active_provider_count_request( mem: &mut ProtocolHandlerMemory, app_state: &Arc, auth_required: bool, - secret_key: Option<&Vec>, + verifier: Option<&TokenVerifier>, ) -> Option { if auth_required { - let Some(secret_key) = secret_key else { + let Some(verifier) = verifier else { return Some(ProtocolMessage::Unauthorized); }; - let Some(token_data) = verify_token(&auth_token, secret_key.as_slice()) else { + let Some(token_data) = verifier.verify(&auth_token) else { return Some(ProtocolMessage::Unauthorized); }; if token_data.claims.permissions.contains(Permission::SystemRead) @@ -351,7 +352,7 @@ async fn handle_incoming_message( handler: &mut ProtocolHandler, app_state: &Arc, auth_required: bool, - secret_key: Option<&Vec>, + verifier: Option<&TokenVerifier>, ) -> Result<(), WebSocketApiError> { let msg = result?; @@ -366,7 +367,7 @@ async fn handle_incoming_message( Ok(()) } ProtocolHandler::Default(mem) => { - let msg = handle_protocol_message(msg, mem, app_state, auth_required, secret_key).await; + let msg = handle_protocol_message(msg, mem, app_state, auth_required, verifier).await; match msg { None => Ok(()), Some(protocol_msg) => { @@ -424,107 +425,113 @@ async fn send_recording_snapshot_event( Ok(()) } +/// The wire frame an event becomes, with the label used if sending it fails. +/// +/// A pure function so the whole taxonomy can be checked at once: the test +/// below asserts every `EventKind` either maps here or is on the short list +/// of kinds handled another way. The mapping used to be a hundred lines of +/// `match` nested three deep inside the socket loop, where a kind that +/// reached no arm was indistinguishable from one deliberately ignored. +fn to_protocol_message(event: EventMessage) -> Option<(ProtocolMessage, &'static str)> { + Some(match event { + EventMessage::ServerError(error) => (ProtocolMessage::ServerError(error), "Server Error event"), + EventMessage::ActiveUser(event) => { + (ProtocolMessage::ActiveUserResponse(event), "Active user connection change event") + } + EventMessage::ActiveProvider(provider, connections) => { + (ProtocolMessage::ActiveProviderResponse(provider, connections), "Provider connection change event") + } + EventMessage::ConfigChange(config) => { + (ProtocolMessage::ConfigChangeResponse(config), "Configuration files change event") + } + // The wire carries the outcome only. The run summary rides along on + // the bus for notifications and plugins; the Web UI re-fetches its + // own view rather than reading statistics off this frame. + EventMessage::PlaylistUpdate(summary) => { + (ProtocolMessage::PlaylistUpdateResponse(summary.state), "Playlist update event") + } + EventMessage::PlaylistUpdateProgress(progress) => { + (ProtocolMessage::PlaylistUpdateProgressResponse(progress), "Playlist update progress event") + } + EventMessage::SystemInfoUpdate(system_info) => { + (ProtocolMessage::SystemInfoResponse(unwrap_or_clone(system_info)), "System info event") + } + EventMessage::LibraryScanProgress(progress) => { + (ProtocolMessage::LibraryScanProgressResponse(progress), "Library scan progress event") + } + EventMessage::DownloadsUpdate(downloads) => { + (ProtocolMessage::DownloadsResponse(unwrap_or_clone(downloads)), "Downloads event") + } + EventMessage::DownloadsDeltaUpdate(delta) => { + (ProtocolMessage::DownloadsDeltaResponse(delta), "Downloads delta event") + } + // The rule repository is per-process and `list_recording_rules` + // enforces the session filter server-side, so this is a bare nudge: + // the frontend re-fetches. + EventMessage::RecordingRulesChanged => (ProtocolMessage::RecordingRulesChanged, "Recording rules changed"), + + // Handled by `handle_event_message`, not translatable here: + // `RecordingChanged` needs a per-session snapshot re-fetch, and the + // metadata events are internal. + // + // The lifecycle events are notification-side only: they reach + // operators through the messaging pipeline and plugins through the + // bus, and adding them to the wire would need a `ProtocolMessage` + // variant and frontend handling that nothing asks for yet. + EventMessage::RecordingChanged + | EventMessage::ServerLifecycle(_) + | EventMessage::InputMetadataUpdatesCompleted(_) + | EventMessage::InputMetadataUpdatesStarted(_) + | EventMessage::InputMetadataUpdatesFailed(_) + | EventMessage::DiskAlert(_) + | EventMessage::ConfigReloadFailed(_) + | EventMessage::PlaylistWatchChanged(_) + | EventMessage::PlaylistGroupsChanged(_) + | EventMessage::PlaylistWatchDisabled(_) + | EventMessage::PlaylistWatchUnmatched(_) + | EventMessage::RecordingLifecycle(_) + | EventMessage::ProviderAccount(_) + | EventMessage::ProviderFetchFailed(_) + | EventMessage::ProviderPoolExhausted(_) + | EventMessage::ProviderPriorityFallback(_) + // The panel already knows it just saved a user - it made the + // request - and nothing in the Web UI subscribes to probe + // failures yet. Both are on the bus for operators and plugins. + | EventMessage::UserLifecycle(_) + | EventMessage::ConnectionDenied(_) + | EventMessage::StreamProbeFailed(_) + // A notification that could not be delivered is an operator concern, + // not a Web UI one, and there is no panel that renders it. + | EventMessage::NotificationDeadLettered(_) + | EventMessage::ScheduledTaskFailed(_) + // Auth decisions reach notification channels and plugins, not the + // Web UI socket: there is no panel that renders them, and pushing + // every sign-in to every connected admin is noise. + | EventMessage::AuthAudit(_) => return None, + }) +} + async fn handle_event_message( app_state: &Arc, socket: &mut WebSocket, event: EventMessage, handler: &ProtocolHandler, ) -> Result<(), WebSocketApiError> { - match handler { - ProtocolHandler::Version(_) => {} - ProtocolHandler::Default(mem) => { - if websocket_can_receive_runtime_events(mem, &event) { - match event { - EventMessage::ServerError(error) => { - send_event_response(socket, ProtocolMessage::ServerError(error), "Server Error event").await?; - } - EventMessage::ActiveUser(event) => { - send_event_response( - socket, - ProtocolMessage::ActiveUserResponse(event), - "Active user connection change event", - ) - .await?; - } - EventMessage::ActiveProvider(provider, connections) => { - send_event_response( - socket, - ProtocolMessage::ActiveProviderResponse(provider, connections), - "Provider connection change event", - ) - .await?; - } - EventMessage::ConfigChange(config) => { - send_event_response( - socket, - ProtocolMessage::ConfigChangeResponse(config), - "Configuration files change event", - ) - .await?; - } - EventMessage::PlaylistUpdate(state) => { - send_event_response( - socket, - ProtocolMessage::PlaylistUpdateResponse(state), - "Playlist update event", - ) - .await?; - } - EventMessage::PlaylistUpdateProgress(progress) => { - send_event_response( - socket, - ProtocolMessage::PlaylistUpdateProgressResponse(progress), - "Playlist update progress event", - ) - .await?; - } - EventMessage::SystemInfoUpdate(system_info) => { - send_event_response( - socket, - ProtocolMessage::SystemInfoResponse(system_info), - "System info event", - ) - .await?; - } - EventMessage::LibraryScanProgress(progress) => { - send_event_response( - socket, - ProtocolMessage::LibraryScanProgressResponse(progress), - "Library scan progress event", - ) - .await?; - } - EventMessage::DownloadsUpdate(downloads) => { - send_event_response(socket, ProtocolMessage::DownloadsResponse(downloads), "Downloads event") - .await?; - } - EventMessage::DownloadsDeltaUpdate(delta) => { - send_event_response( - socket, - ProtocolMessage::DownloadsDeltaResponse(delta), - "Downloads delta event", - ) - .await?; - } - EventMessage::RecordingChanged => { - // Re-fetch the per-session filtered snapshot so the - // visibility contract is enforced by `recording_ws`. - send_recording_snapshot_event(app_state, socket, mem).await?; - } - EventMessage::RecordingRulesChanged => { - // The rule repository is per-process; the - // session-filter is enforced server-side by - // `list_recording_rules`. Just notify the - // frontend — it will re-fetch. - send_event_response(socket, ProtocolMessage::RecordingRulesChanged, "Recording rules changed") - .await?; - } - EventMessage::InputMetadataUpdatesCompleted(_) | EventMessage::InputMetadataUpdatesStarted(_) => { - // Internal events or already handled above - } - } - } - } + let ProtocolHandler::Default(mem) = handler else { + return Ok(()); + }; + if !websocket_can_receive_runtime_events(mem, &event) { + return Ok(()); + } + + // Re-fetch the per-session filtered snapshot so the visibility contract + // is enforced by `recording_ws` rather than by this socket. + if matches!(event, EventMessage::RecordingChanged) { + return send_recording_snapshot_event(app_state, socket, mem).await; + } + + if let Some((message, context)) = to_protocol_message(event) { + send_event_response(socket, message, context).await?; } Ok(()) } @@ -540,7 +547,7 @@ async fn send_event_response( // WebSocket communication logic async fn handle_socket(mut socket: WebSocket, app_state: Arc, auth_required: bool) { - let secret_key = get_secret_key(&app_state, auth_required); + let verifier = get_token_verifier(&app_state, auth_required); let mut event_rx = app_state.event_manager.get_event_channel(); let mut meter_event_rx = app_state.event_manager.get_meter_channel(); @@ -550,7 +557,7 @@ async fn handle_socket(mut socket: WebSocket, app_state: Arc, auth_req tokio::select! { maybe_msg = socket.recv() => { if let Some(msg) = maybe_msg { - if let Err(e) = handle_incoming_message(msg, &mut socket, &mut handler, &app_state, auth_required, secret_key.as_ref()).await { + if let Err(e) = handle_incoming_message(msg, &mut socket, &mut handler, &app_state, auth_required, verifier.as_ref()).await { trace!("WebSocket message handling error: {e}"); break; } @@ -569,6 +576,7 @@ async fn handle_socket(mut socket: WebSocket, app_state: Arc, auth_req } Err(error) => { if let tokio::sync::broadcast::error::RecvError::Lagged(skipped) = &error { + app_state.event_manager.stats().record_lag(*skipped); trace!("Main websocket event receiver lagged by {skipped} messages"); } match main_event_receive_error_action(&handler, &error) { @@ -645,18 +653,249 @@ async fn handle_user_action(app_state: &Arc, cmd: UserCommand) -> bool #[cfg(test)] mod tests { use super::{ - main_event_receive_error_action, set_no_auth_websocket_identity, set_websocket_auth, + main_event_receive_error_action, set_no_auth_websocket_identity, set_websocket_auth, to_protocol_message, websocket_can_receive_runtime_events, websocket_claims, MainEventReceiveErrorAction, }; use crate::api::model::EventMessage; use shared::model::{ - Claims, DownloadsDelta, DownloadsResponse, FileDownloadDto, LibraryScanProgressEvent, LibraryScanSummary, - LibraryScanSummaryStatus, Permission, PlaylistUpdateProgressEvent, ProtocolHandler, ProtocolHandlerMemory, - TaskKindDto, TaskPriorityDto, TransferStatusDto, UserId, UserRole, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, - PROTOCOL_VERSION, ROLE_ADMIN, TOKEN_NO_AUTH, + AuthAuditEvent, AuthAuditOutcome, Claims, ConfigReloadFailure, DiskAlert, DiskAlertLevel, DownloadsDelta, + DownloadsResponse, FileDownloadDto, LibraryScanProgressEvent, LibraryScanSummary, LibraryScanSummaryStatus, + MetadataUpdateFailure, MsgKind, Permission, PlaylistGroupsChanged, PlaylistUpdateProgressEvent, + ProtocolHandler, ProtocolHandlerMemory, ProviderAccountEvent, ProviderAccountState, ProviderFailureKind, + ProviderFetchFailure, ProviderPoolExhausted, ProviderPriorityFallback, RecordingLifecycleMessage, RoleSet, + TaskKindDto, TaskPriorityDto, TransferStatusDto, UserId, UserRole, WatchChanges, WatchDisabled, + WatchDisabledReason, WatchUnmatched, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, PROTOCOL_VERSION, + TOKEN_NO_AUTH, }; + use std::sync::Arc; use tokio::sync::broadcast::error::RecvError; + /// Every kind either becomes a wire frame or is on the short list of + /// kinds handled another way. Without this, a variant added later + /// silently reaches no arm - which is exactly what the old nested match + /// could not distinguish from a deliberate omission. + #[test] + fn every_event_kind_is_either_wire_mapped_or_deliberately_not() { + use shared::model::EventKind; + + // Two reasons a kind produces no frame, kept apart because they are + // not the same fact - the same distinction `to_notification` draws + // between its own two `None` arms. + + // Reachable over the wire, just not through this pure function: + // `RecordingChanged` needs a per-session snapshot re-fetch, and the + // metadata events are internal. + const HANDLED_ELSEWHERE: [EventKind; 3] = [ + EventKind::RecordingChanged, + EventKind::InputMetadataUpdatesCompleted, + EventKind::InputMetadataUpdatesStarted, + ]; + + // Never on the wire: these reach operators through the messaging + // pipeline and plugins through the bus. Putting one on the wire + // would need a `ProtocolMessage` variant and frontend handling that + // nothing asks for. + // + // This list did not exist until now, so the assert below compared + // every notification-only kind against `expected = true` and the + // test failed on `DiskAlert` - it has been red since the lifecycle + // events joined the bus. + const NOT_ON_THE_WIRE: [EventKind; 29] = [ + EventKind::ConnectionDenied, + EventKind::ScheduledTaskFailed, + EventKind::ProviderFetchFailed, + EventKind::ProviderPoolExhausted, + EventKind::ProviderPriorityFallback, + EventKind::PlaylistGroupsChanged, + EventKind::PlaylistWatchDisabled, + EventKind::PlaylistWatchUnmatched, + EventKind::NotificationDeadLettered, + EventKind::ServerStarted, + EventKind::ServerShutdown, + EventKind::InputMetadataUpdatesFailed, + EventKind::DiskAlert, + EventKind::ConfigReloadFailed, + EventKind::PlaylistWatchChanged, + EventKind::RecordingStarted, + EventKind::RecordingCompleted, + EventKind::RecordingFailed, + EventKind::ProviderAccountStatus, + EventKind::ProviderAccountExpiring, + EventKind::ProviderAccountExpired, + EventKind::UserCreated, + EventKind::UserUpdated, + EventKind::UserDeleted, + EventKind::StreamProbeFailed, + EventKind::AuthSignInSucceeded, + EventKind::AuthSignInFailed, + EventKind::AuthSignInThrottled, + EventKind::AuthPermissionDenied, + ]; + + for (event, kind) in sample_event_of_every_kind() { + let mapped = to_protocol_message(event).is_some(); + let expected = !HANDLED_ELSEWHERE.contains(&kind) && !NOT_ON_THE_WIRE.contains(&kind); + assert_eq!(mapped, expected, "{kind:?}: wire-mapped={mapped}, expected={expected}"); + } + } + + fn provider_fetch_failure() -> ProviderFetchFailure { + ProviderFetchFailure { + input: "i".into(), + provider: "m3u".into(), + kind: ProviderFailureKind::Transient, + error_count: 1, + message: None, + retryable: true, + needs_operator: false, + partial: false, + } + } + + fn auth_audit(outcome: AuthAuditOutcome) -> EventMessage { + EventMessage::AuthAudit(AuthAuditEvent { + username: "u".into(), + client_ip: "127.0.0.1".into(), + outcome, + permission: None, + }) + } + + /// One `EventMessage` per `EventKind`; the length assert means a new + /// variant cannot slip past the test above. + /// + /// Long by construction, and it has to stay one list for that assert to + /// mean anything. + #[allow(clippy::too_many_lines)] + fn sample_event_of_every_kind() -> Vec<(EventMessage, shared::model::EventKind)> { + use shared::model::{ + ActiveUserConnectionChange, ConfigType, ConnectionDenied, EventKind, NotificationDeadLetter, + PlaylistUpdateState, PlaylistUpdateSummary, ScheduledTaskFailure, ServerLifecycleEvent, StreamProbeFailure, + StreamProbeFailureReason, SystemInfo, UserLifecycleEvent, UserLifecycleState, + }; + + fn user_lifecycle(state: UserLifecycleState) -> EventMessage { + EventMessage::UserLifecycle(UserLifecycleEvent::new("u".into(), "t".into(), state)) + } + + let downloads = DownloadsResponse { queue: Vec::new(), finished: Vec::new(), active: Vec::new() }; + let samples = vec![ + EventMessage::ServerError("x".to_string()), + EventMessage::ServerLifecycle(ServerLifecycleEvent::started("1".into(), "h:1".into())), + EventMessage::ServerLifecycle(ServerLifecycleEvent::shutting_down("1".into(), "SIGTERM".into())), + EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(0, 0)), + EventMessage::ActiveProvider("p".into(), 1), + EventMessage::ConfigChange(ConfigType::Config), + EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only(PlaylistUpdateState::Success)), + EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: String::new(), + message: String::new(), + }), + EventMessage::SystemInfoUpdate(Arc::new(SystemInfo { + cpu_usage: 0.0, + memory_usage: 0, + memory_total: 0, + net_rx_bytes_per_sec: 0.0, + net_tx_bytes_per_sec: 0.0, + net_rx_bytes_total: 0, + net_tx_bytes_total: 0, + disk_total_bytes: 0, + disk_free_bytes: 0, + })), + EventMessage::LibraryScanProgress(LibraryScanProgressEvent { + summary: LibraryScanSummary { + status: LibraryScanSummaryStatus::Success, + message: String::new(), + result: None, + }, + }), + EventMessage::LibraryScanProgress(LibraryScanProgressEvent { + summary: LibraryScanSummary { + status: LibraryScanSummaryStatus::Error, + message: String::new(), + result: None, + }, + }), + EventMessage::DownloadsUpdate(Arc::new(downloads)), + EventMessage::DownloadsDeltaUpdate(DownloadsDelta::ActiveCleared), + EventMessage::RecordingChanged, + EventMessage::RecordingRulesChanged, + EventMessage::InputMetadataUpdatesCompleted("a".into()), + EventMessage::InputMetadataUpdatesStarted("a".into()), + EventMessage::InputMetadataUpdatesFailed(MetadataUpdateFailure::new("a".into(), 1, false, None)), + EventMessage::DiskAlert(DiskAlert { + level: DiskAlertLevel::Warn, + total_bytes: 100, + free_bytes: 5, + used_bytes: 95, + percent: 95.0, + }), + EventMessage::ConfigReloadFailed(ConfigReloadFailure { + paths: "config.yml".to_string(), + error: "boom".to_string(), + }), + EventMessage::PlaylistWatchChanged(WatchChanges::new( + "t".to_string(), + "g".to_string(), + Vec::new(), + Vec::new(), + )), + EventMessage::PlaylistGroupsChanged(PlaylistGroupsChanged::new("t".to_string(), Vec::new(), Vec::new())), + EventMessage::PlaylistWatchDisabled(WatchDisabled::new( + "t".to_string(), + WatchDisabledReason::InvalidPatterns, + )), + EventMessage::PlaylistWatchUnmatched(WatchUnmatched::new("t".to_string(), vec!["x".to_string()], 0)), + recording_lifecycle(MsgKind::RecordingStarted), + recording_lifecycle(MsgKind::RecordingCompleted), + recording_lifecycle(MsgKind::RecordingFailed), + EventMessage::ProviderFetchFailed(provider_fetch_failure()), + EventMessage::ProviderPoolExhausted(ProviderPoolExhausted::new("i".into(), Vec::new())), + EventMessage::ProviderPriorityFallback(ProviderPriorityFallback::new( + "i".into(), + "p".into(), + 1, + 2, + Some(0), + )), + provider_account(ProviderAccountState::StatusChanged), + provider_account(ProviderAccountState::Expiring), + provider_account(ProviderAccountState::Expired), + user_lifecycle(UserLifecycleState::Created), + user_lifecycle(UserLifecycleState::Updated), + user_lifecycle(UserLifecycleState::Deleted), + EventMessage::ScheduledTaskFailed(ScheduledTaskFailure::new( + shared::model::ScheduleTaskType::GeoIpUpdate, + "boom".to_string(), + )), + EventMessage::NotificationDeadLettered(NotificationDeadLetter::new( + shared::model::notification::registry::SYSTEM_ERROR, + 3, + vec!["telegram".to_string()], + 0, + )), + EventMessage::ConnectionDenied(ConnectionDenied::new("u".into(), "1.2.3.4".into(), 1, 0)), + EventMessage::StreamProbeFailed(StreamProbeFailure::new( + "input".into(), + "1".into(), + "http://example.test/s".into(), + StreamProbeFailureReason::Unreachable, + )), + auth_audit(AuthAuditOutcome::SignInSucceeded), + auth_audit(AuthAuditOutcome::SignInFailed), + auth_audit(AuthAuditOutcome::SignInThrottled), + auth_audit(AuthAuditOutcome::PermissionDenied), + ]; + assert_eq!(samples.len(), EventKind::ALL.len(), "add the new variant to this list"); + samples + .into_iter() + .map(|event| { + let kind = event.kind(); + (event, kind) + }) + .collect() + } + #[test] fn no_auth_websocket_identity_is_builtin_admin() { let mut mem = ProtocolHandlerMemory::default(); @@ -668,7 +907,7 @@ mod tests { assert_eq!(mem.role, UserRole::Admin); assert_eq!(mem.permissions, PERM_ALL); assert_eq!(claims.subject_id, Some(UserId::builtin_admin())); - assert_eq!(claims.roles, vec![ROLE_ADMIN]); + assert_eq!(claims.roles, RoleSet::ADMIN); assert_eq!(claims.permission_schema_version, CURRENT_PERMISSION_SCHEMA_VERSION); } @@ -678,7 +917,7 @@ mod tests { iss: "test".to_string(), iat: 1, exp: i64::MAX, - roles: vec![ROLE_ADMIN.to_string()], + roles: RoleSet::ADMIN, permissions: Permission::RecordingRead.into(), pwd_version: 0, subject_id, @@ -841,11 +1080,11 @@ mod tests { assert!(websocket_can_receive_runtime_events( &mem, - &EventMessage::DownloadsUpdate(DownloadsResponse { + &EventMessage::DownloadsUpdate(Arc::new(DownloadsResponse { queue: Vec::new(), finished: Vec::new(), active: Vec::new(), - }) + })) )); } @@ -857,11 +1096,11 @@ mod tests { assert!(!websocket_can_receive_runtime_events( &mem, - &EventMessage::DownloadsUpdate(DownloadsResponse { + &EventMessage::DownloadsUpdate(Arc::new(DownloadsResponse { queue: Vec::new(), finished: Vec::new(), active: Vec::new(), - }) + })) )); } @@ -890,4 +1129,28 @@ mod tests { })) )); } + + fn recording_lifecycle(event: MsgKind) -> EventMessage { + EventMessage::RecordingLifecycle(RecordingLifecycleMessage { + event, + programme_title: None, + channel: None, + effective_start: None, + effective_end: None, + visibility: None, + output_filename: None, + failure_reason: None, + }) + } + + fn provider_account(state: ProviderAccountState) -> EventMessage { + EventMessage::ProviderAccount(ProviderAccountEvent { + state, + username: "u".to_string(), + provider: "p".to_string(), + status: None, + expires_at: None, + message: "m".to_string(), + }) + } } diff --git a/backend/app/src/api/endpoints/xmltv_api.rs b/backend/app/src/api/endpoints/xmltv_api.rs index aa5aace8a..5c8a0339e 100644 --- a/backend/app/src/api/endpoints/xmltv_api.rs +++ b/backend/app/src/api/endpoints/xmltv_api.rs @@ -998,6 +998,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::new(None)), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, } @@ -1021,6 +1022,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::new(None)), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, } diff --git a/backend/app/src/api/endpoints/xtream_api.rs b/backend/app/src/api/endpoints/xtream_api.rs index 40d64fd5c..81036a84f 100644 --- a/backend/app/src/api/endpoints/xtream_api.rs +++ b/backend/app/src/api/endpoints/xtream_api.rs @@ -57,7 +57,8 @@ use shared::{ error::TuliproxError, model::{ create_stream_channel_with_type, ConnectFailureReason, PlaylistEntry, PlaylistItemType, ProxyType, - ShortEpgResultDto, StreamProperties, TargetType, UserConnectionPermission, XtreamCluster, XtreamPlaylistItem, + ShortEpgResultDto, StreamProperties, TargetType, UserConnectionPermission, VirtualId, XtreamCluster, + XtreamPlaylistItem, }, utils::{ deserialize_as_string, extract_extension_from_url, generate_provider_playlist_uuid, sanitize_sensitive_info, @@ -392,14 +393,13 @@ async fn xtream_player_api_stream( } if pli.item_type.is_local() { - let playback_session_token = create_session_fingerprint(fingerprint, &user.username, virtual_id, false); + let playback_session_token = create_session_fingerprint(fingerprint, &user.username, virtual_id.get(), false); let user_session = app_state.active_users.get_and_update_user_session(&user.username, &playback_session_token).await; let (admission, _grace_mode, request_class) = crate::api::api_utils::resolve_playback_request_admission( &app_state.admission_ctx(), &user, fingerprint, - pli.item_type, user_session.as_ref(), playback_session_token.as_str(), false, @@ -468,11 +468,17 @@ async fn xtream_player_api_stream( let playback_ext: &str = if playback_ext.is_empty() { &requested_extension } else { &playback_ext }; let session_key = if let Some(resolved) = m3u_timeshift.as_ref() { - create_m3u_catchup_session_key(fingerprint, &user.username, virtual_id, &resolved.discriminator) + create_m3u_catchup_session_key(fingerprint, &user.username, virtual_id.get(), &resolved.discriminator) } else if item_type == PlaylistItemType::Catchup { - create_catchup_session_key(fingerprint, &user.username, virtual_id) + create_catchup_session_key(fingerprint, &user.username, virtual_id.get()) } else { - create_playback_session_fingerprint(fingerprint, &user.username, virtual_id, item_type, Some(playback_ext)) + create_playback_session_fingerprint( + fingerprint, + &user.username, + virtual_id.get(), + item_type, + Some(playback_ext), + ) }; let eviction_reentry_guard = if item_type == PlaylistItemType::Catchup || !crate::api::api_utils::is_socket_bound_playback_session(item_type, Some(playback_ext)) @@ -536,7 +542,8 @@ async fn xtream_player_api_stream( let stream_channel = create_stream_channel_with_type(target.id, &pli, item_type); - if session.virtual_id == virtual_id && is_seekable_media_request(cluster, req_headers, Some(playback_ext)) { + if session.virtual_id == virtual_id.get() && is_seekable_media_request(cluster, req_headers, Some(playback_ext)) + { // partial request means we are in reverse proxy mode, seek happened return force_provider_stream_response( fingerprint, @@ -566,7 +573,6 @@ async fn xtream_player_api_stream( &app_state.admission_ctx(), &user, fingerprint, - item_type, user_session.as_ref(), &session_key, false, @@ -583,7 +589,7 @@ async fn xtream_player_api_stream( let allow_exhausted_shared_reconnect = should_allow_exhausted_shared_reconnect( is_stream_share_enabled(item_type, &target), user_session.as_ref(), - virtual_id, + virtual_id.get(), session_url.as_ref(), ); if connection_permission == UserConnectionPermission::Exhausted && !allow_exhausted_shared_reconnect { @@ -648,7 +654,7 @@ async fn xtream_player_api_stream( error!("HLS input stream identity missing for virtual_id={}; refresh target playlist", pli.virtual_id); return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); }; - let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, pli.virtual_id); + let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, pli.virtual_id.get()); let archive_reference = m3u_archive_epg_reference_ts(stream_url.as_ref()) .or_else(|| m3u_archive_epg_reference_ts(pli.url.as_ref())) .or_else(|| m3u_catchup_epg_reference_from_session_token(&session_key)); @@ -859,7 +865,13 @@ pub(in crate::api) async fn xtream_player_api_stream_with_resolved_target( expected_input: Option>, stream_req: ApiStreamRequest<'_>, ) -> impl IntoResponse + Send { - if stream_req.access_token && !verify_access_token(stream_req.password, &app_state.app_config.access_token_secret) { + if stream_req.access_token + && !verify_access_token( + stream_req.password, + &app_state.app_config.access_token_secret, + crate::auth::scope::INTERNAL_PLAYER, + ) + { return axum::http::StatusCode::FORBIDDEN.into_response(); } @@ -908,7 +920,7 @@ pub(in crate::api) async fn xtream_player_api_stream_with_resolved_target( let user = create_api_proxy_user(app_state); if pli.item_type.is_local() { - let playback_session_token = create_session_fingerprint(fingerprint, "webui", virtual_id, false); + let playback_session_token = create_session_fingerprint(fingerprint, "webui", virtual_id.get(), false); return local_stream_response( fingerprint, app_state, @@ -955,7 +967,7 @@ pub(in crate::api) async fn xtream_player_api_stream_with_resolved_target( let is_session_request = is_session_based_playback(pli.item_type, playback_ext); let session_key = - create_playback_session_fingerprint(fingerprint, "webui", virtual_id, pli.item_type, playback_ext); + create_playback_session_fingerprint(fingerprint, "webui", virtual_id.get(), pli.item_type, playback_ext); // TODO how should we use fixed provider for hls in multi provider config? @@ -965,7 +977,7 @@ pub(in crate::api) async fn xtream_player_api_stream_with_resolved_target( error!("HLS input stream identity missing for virtual_id={virtual_id}; refresh target playlist"); return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); }; - let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, virtual_id); + let original_hls_entry_path = build_virtual_hls_entry_path(&target, &input, &user, virtual_id.get()); return handle_hls_stream_request( fingerprint, app_state, @@ -1621,7 +1633,7 @@ async fn xtream_get_catchup_response( &uuid, cp_id, PlaylistItemType::Catchup, - pli.provider_id, + VirtualId::new(pli.provider_id), ); mapping_results.push((idx, virtual_id)); @@ -1631,7 +1643,7 @@ async fn xtream_get_catchup_response( cp_id, virtual_id, PlaylistItemType::Catchup, - pli.provider_id, + VirtualId::new(pli.provider_id), uuid, )); } @@ -1987,11 +1999,10 @@ mod tests { use axum::{http::HeaderMap, response::IntoResponse}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine}; use shared::{ - error::TuliproxError, foundation::Filter, model::{ ClusterFlags, InputType, PlaylistItemType, ProcessingOrder, ProxyUserStatus, StreamProperties, UUIDType, - VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, + VideoStreamProperties, VirtualId, XtreamCluster, XtreamPlaylistItem, }, utils::Internable, }; @@ -2086,6 +2097,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::new(None)), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: true, } @@ -2093,7 +2105,7 @@ mod tests { fn short_epg_live_item() -> XtreamPlaylistItem { XtreamPlaylistItem { - virtual_id: 100, + virtual_id: VirtualId::new(100), provider_id: 0, name: "Formula 1".intern(), logo: "".intern(), @@ -2168,7 +2180,7 @@ mod tests { let app_state = create_test_app_state(config); let live_item = short_epg_live_item(); let mut live = BPlusTree::new(); - live.insert(live_item.virtual_id, live_item.clone()); + live.insert(live_item.virtual_id.get(), live_item.clone()); app_state .playlists .cache_playlist( @@ -2187,7 +2199,7 @@ mod tests { live_item.provider_id, live_item.virtual_id, PlaylistItemType::Live, - 0, + VirtualId::new(0), UUIDType::default(), ), ); @@ -2211,7 +2223,7 @@ mod tests { fn m3u_catchup_item(name: &str, input_name: &str, url: &str, catchup_source: Option<&str>) -> XtreamPlaylistItem { XtreamPlaylistItem { - virtual_id: 100, + virtual_id: VirtualId::new(100), provider_id: 0, name: name.intern(), logo: "".intern(), @@ -2263,7 +2275,7 @@ mod tests { app_state.app_config.sources.store(Arc::new(sources)); } let mut live = BPlusTree::new(); - live.insert(item.virtual_id, item.clone()); + live.insert(item.virtual_id.get(), item.clone()); app_state .playlists .cache_playlist( @@ -2278,7 +2290,13 @@ mod tests { let mut id_mapping = BPlusTree::new(); id_mapping.insert( item.virtual_id, - VirtualIdRecord::new(item.provider_id, item.virtual_id, PlaylistItemType::Live, 0, UUIDType::default()), + VirtualIdRecord::new( + item.provider_id, + item.virtual_id, + PlaylistItemType::Live, + VirtualId::new(0), + UUIDType::default(), + ), ); app_state.playlists.cache_id_mapping(&target.name, id_mapping).await; } @@ -2411,7 +2429,7 @@ mod tests { fn create_test_vod_item(url: &str, container_extension: &str, item_type: PlaylistItemType) -> XtreamPlaylistItem { XtreamPlaylistItem { - virtual_id: 176_141, + virtual_id: VirtualId::new(176_141), provider_id: 813_563, name: "Test".intern(), logo: "".intern(), @@ -2661,7 +2679,7 @@ mod tests { fn m3u_timeshift_item() -> XtreamPlaylistItem { XtreamPlaylistItem { - virtual_id: 100, + virtual_id: VirtualId::new(100), provider_id: 0, name: "Live TV".intern(), logo: "".intern(), @@ -2737,7 +2755,7 @@ mod tests { let err = resolve_m3u_xtream_timeshift(&input, &item, "60/2024-01-01:00-00").expect_err("missing catchup must error"); - assert!(matches!(err, TuliproxError::ApiXtream(_))); + assert_eq!(err.kind(), shared::error::ErrorKind::ApiXtream); } #[test] @@ -2814,7 +2832,8 @@ mod tests { let item = m3u_timeshift_item(); cache_xtream_test_item(&app_state, &target, item, Some(input)).await; - let token = create_access_token(&app_state.app_config.access_token_secret, 60); + let token = + create_access_token(&app_state.app_config.access_token_secret, 60, crate::auth::scope::INTERNAL_PLAYER); let response = xtream_player_api_stream_with_token( &Fingerprint::new("fp".to_string(), "127.0.0.1".to_string(), "127.0.0.1:0".parse().unwrap()), diff --git a/backend/app/src/api/main_api.rs b/backend/app/src/api/main_api.rs index 3f652d3b0..7ffc6dd50 100644 --- a/backend/app/src/api/main_api.rs +++ b/backend/app/src/api/main_api.rs @@ -34,7 +34,7 @@ use crate::{ }, }, model::{AppConfig, Config, HdHomeRunFlags, Healthcheck, ProcessTargets, RateLimitConfig}, - processing::processor::exec_processing, + processing::processor::{exec_processing, ProcessingRun}, repository::{get_geoip_path, GeoIp}, utils::{exec_file_lock_prune, get_default_web_root_path}, VERSION, @@ -49,6 +49,7 @@ use dashmap::DashSet; use log::{debug, error, info, warn}; use shared::{ error::TuliproxError, + model::ServerLifecycleEvent, utils::{concat_path_leading_slash, sanitize_sensitive_info}, }; use std::{ @@ -68,6 +69,10 @@ use tower_http::{ services::ServeDir, }; use tuliprox_hls::api::{exec_hls_cache_gc, exec_hls_lifecycle, HlsProxyManager}; +use tuliprox_repository::{ + identity_registry::{BootstrapOutcome, IdentityRegistry}, + token_revocations::TokenRevocations, +}; const METADATA_TRIGGER_WAIT_CYCLE_LIMIT: u32 = 900; static CORRUPT_DOWNLOADS_STATE_SUFFIX: AtomicU64 = AtomicU64::new(1); @@ -208,25 +213,26 @@ fn spawn_metadata_trigger_update( loop { if let Some(lock) = update_guard.try_playlist() { exec_processing( - &client, - Arc::clone(&app_config), - proc_targets, - Some(Arc::clone(&event_manager)), - Some({ + ProcessingRun::new( + client.clone(), + Arc::clone(&app_config), + proc_targets, + Arc::clone(&event_manager), + ) + .with_bootstrap({ let state = Arc::clone(&app_state_clone); - std::sync::Arc::new(move || { + move || { let state = Arc::clone(&state); - Box::pin(async move { sync_panel_api_exp_dates(&state).await }) - as std::pin::Pin + Send>> - }) - }), - Some(Arc::clone(&playlist_state)), - Some(update_guard.clone()), - disabled_headers.clone(), - Some(app_state_clone.active_provider.clone()), - Some(app_state_clone.metadata_manager.clone()), - pre_processed_inputs.clone(), - Some(lock), + async move { sync_panel_api_exp_dates(&state).await } + } + }) + .with_playlist_state(Arc::clone(&playlist_state)) + .with_update_guard(update_guard.clone()) + .with_disabled_headers(disabled_headers.clone()) + .with_provider_manager(app_state_clone.active_provider.clone()) + .with_metadata_manager(app_state_clone.metadata_manager.clone()) + .with_pre_processed_inputs(pre_processed_inputs.clone()) + .with_acquired_permit(lock), ) .await; break; @@ -309,6 +315,58 @@ async fn ready( (status_code, axum::Json(ReadyResponse { status: status_label })) } +/// Load the stable-identity registry, or refuse to start. +/// +/// A registry that is present loads; a registry that is absent with no +/// persisted principals initialises fresh. A *corrupt* one fails closed - the +/// registry deliberately does not invent replacement ids, because doing so +/// would silently reassign every recording those principals own. The operator +/// has to repair or remove the file. +async fn bootstrap_identity_registry( + config: &Config, + app_config: &Arc, +) -> Result, TuliproxError> { + let path = std::path::PathBuf::from(&config.storage_dir).join("identity_registry.json"); + + let web_users: Vec = config + .web_ui + .as_ref() + .and_then(|web_ui| web_ui.auth.as_ref()) + .and_then(|auth| auth.t_users.as_ref()) + .map(|users| users.iter().map(|user| user.username.clone()).collect()) + .unwrap_or_default(); + + let api_users: Vec = app_config.api_proxy.load().as_ref().as_ref().map_or_else(Vec::new, |api_proxy| { + api_proxy + .user + .iter() + .flat_map(|target_user| target_user.credentials.iter()) + .map(|credential| credential.username.clone()) + .collect() + }); + + // The fail-closed pre-scan - handing bootstrap the subject ids already + // referenced by persisted recordings - is not wired yet, so a *missing* + // registry alongside existing recordings still initialises fresh. A + // corrupt one fails closed regardless, which is the case that actually + // arises from a half-written file. + let (registry, outcome) = IdentityRegistry::bootstrap(path, Vec::new(), &web_users, &api_users).await; + match outcome { + BootstrapOutcome::FailClosed { reason, persisted_user_ids } => { + error!( + "Identity registry failed closed ({reason:?}); {} persisted subject ids are at risk. \ + Repair or remove the registry file before restarting.", + persisted_user_ids.len() + ); + Err(TuliproxError::Server("identity registry is unusable".to_string())) + } + outcome => { + info!("Identity registry: {outcome:?}"); + Ok(Arc::new(registry)) + } + } +} + async fn create_shared_data( app_config: &Arc, forced_targets: &Arc, @@ -335,7 +393,7 @@ async fn create_shared_data( }; let cache = create_cache(&config); - let event_manager = Arc::new(EventManager::new()); + let event_manager = Arc::new(EventManager::with_capacity(config.event_channel_capacity as usize)); let active_provider = Arc::new(ActiveProviderManager::new(app_config, &event_manager)); let shared_stream_manager = Arc::new(SharedStreamManager::new(Arc::clone(&active_provider))); let rewrite_secret = @@ -366,6 +424,13 @@ async fn create_shared_data( let cancel_tokens = Arc::new(ArcSwap::from_pointee(tokens)); let (manual_update_sender, manual_update_rx) = mpsc::channel::(1); + let identity_registry = bootstrap_identity_registry(&config, app_config).await?; + let revocations_path = std::path::PathBuf::from(&config.storage_dir).join("token_revocations.json"); + let token_revocations = Arc::new(TokenRevocations::load(revocations_path).await.map_err(|err| { + // Reading a corrupt file as "nothing is revoked" would silently + // reinstate every revoked session. + TuliproxError::Server(format!("Cannot load token revocations: {err}")) + })?); let app_state = AppState { forced_targets: Arc::new(ArcSwap::new(Arc::clone(forced_targets))), @@ -387,6 +452,9 @@ async fn create_shared_data( geoip, update_guard: UpdateGuard::new(), metadata_manager, + identity_registry, + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations, manual_update_sender, }; @@ -405,26 +473,25 @@ async fn run_manual_update_worker( break; }; exec_processing( - &client, - Arc::clone(&app_state.app_config), - request.targets, - Some(Arc::clone(&app_state.event_manager)), - Some({ + ProcessingRun::new( + client.clone(), + Arc::clone(&app_state.app_config), + request.targets, + Arc::clone(&app_state.event_manager), + ) + .with_bootstrap({ let state = Arc::clone(&app_state); - std::sync::Arc::new(move || { + move || { let state = Arc::clone(&state); - Box::pin(async move { sync_panel_api_exp_dates(&state).await }) - as std::pin::Pin + Send>> - }) - }), - Some(Arc::clone(&app_state.playlists)), - Some(app_state.update_guard.clone()), - app_state.get_disabled_headers(), - Some(Arc::clone(&app_state.active_provider)), - Some(Arc::clone(&app_state.metadata_manager) - as std::sync::Arc), - None, - Some(permit), + async move { sync_panel_api_exp_dates(&state).await } + } + }) + .with_playlist_state(Arc::clone(&app_state.playlists)) + .with_update_guard(app_state.update_guard.clone()) + .with_disabled_headers(app_state.get_disabled_headers()) + .with_provider_manager(Arc::clone(&app_state.active_provider)) + .with_metadata_manager(Arc::clone(&app_state.metadata_manager)) + .with_acquired_permit(permit), ) .await; } @@ -447,6 +514,10 @@ async fn cancel_all_service_tokens(app_state: &Arc) { { warn!("Connection manager shutdown timed out after 30s, forcing exit"); } + // After the connections are gone, so the final batch reports what the + // streams actually transferred. `Drop` cancels the sampler but cannot + // await, which left the last window's bytes unreported. + app_state.event_manager.shutdown().await; } #[cfg(unix)] @@ -480,30 +551,24 @@ fn exec_update_on_boot(client: &reqwest::Client, app_state: &Arc, targ let disabled_headers = app_state.get_disabled_headers(); let provider_manager = Arc::clone(&app_state.active_provider); let metadata_manager = Arc::clone(&app_state.metadata_manager); - let event_manager = Some(Arc::clone(&app_state.event_manager)); + let event_manager = Arc::clone(&app_state.event_manager); let app_state_clone = Arc::clone(app_state); tokio::spawn(async move { exec_processing( - &client, - app_config_clone, - targets_clone, - event_manager, - Some({ - let state = Arc::clone(&app_state_clone); - std::sync::Arc::new(move || { - let state = Arc::clone(&state); - Box::pin(async move { sync_panel_api_exp_dates(&state).await }) - as std::pin::Pin + Send>> + ProcessingRun::new(client, app_config_clone, targets_clone, event_manager) + .with_bootstrap({ + let state = Arc::clone(&app_state_clone); + move || { + let state = Arc::clone(&state); + async move { sync_panel_api_exp_dates(&state).await } + } }) - }), - Some(playlist_state), - update_guard, - disabled_headers, - Some(provider_manager), - Some(metadata_manager), - None, - None, + .with_playlist_state(playlist_state) + .with_update_guard(update_guard) + .with_disabled_headers(disabled_headers) + .with_provider_manager(provider_manager) + .with_metadata_manager(metadata_manager), ) .await; }); @@ -761,6 +826,43 @@ pub async fn start_server(app_config: Arc, targets: Arc, targets: Arc { info!("Received shutdown signal ({signal_name}), cancelling all background services"); + // Before `cancel_all_service_tokens`, which stops the outbox + // that would carry this to a channel. + let _ = app_state_signal.event_manager.send_event(EventMessage::ServerLifecycle( + ServerLifecycleEvent::shutting_down(crate::VERSION.into(), signal_name.into()), + )); server_cancel_token_signal.cancel(); cancel_all_service_tokens(&app_state_signal).await; } @@ -1109,6 +1216,13 @@ mod tests { geoip, update_guard: UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } diff --git a/backend/app/src/api/model/app_state.rs b/backend/app/src/api/model/app_state.rs index bb85a31fd..b2ce12285 100644 --- a/backend/app/src/api/model/app_state.rs +++ b/backend/app/src/api/model/app_state.rs @@ -40,6 +40,7 @@ use tokio::{ use tokio_util::sync::CancellationToken; use tuliprox_hls::api::HlsProxyManager; use tuliprox_metadata::manager::MetadataUpdateManager; +use tuliprox_repository::{identity_registry::IdentityRegistry, token_revocations::TokenRevocations}; use tuliprox_session::{provider_dns_manager::exec_provider_dns, qos_aggregation_manager::exec_qos_aggregation}; macro_rules! cancel_service { @@ -440,6 +441,22 @@ pub struct AppState { pub geoip: Arc>, pub update_guard: UpdateGuard, pub metadata_manager: Arc, + /// Stable subject identities for web and API users. + /// + /// The registry existed but was never wired in, so token minting derived + /// the subject from the username - `web:` / `api:` - and a + /// rename silently reassigned everything the old subject owned. + pub identity_registry: Arc, + /// Backoff for repeated failed sign-ins. + /// + /// `/auth/token` used to answer 401 and forget, so a password list could + /// be worked against it as fast as argon2 allows. + pub login_throttle: Arc, + /// Revocation watermarks for already-issued tokens. + /// + /// The tokens this server mints are stateless, so nothing could take one + /// back: a leak stayed valid until it expired. + pub token_revocations: Arc, /// Bounded channel (capacity 1) for manual playlist update requests. /// `try_send` deduplicates rapid clicks: if an update is already pending /// or the channel is full, the request is silently dropped so at most one @@ -512,6 +529,13 @@ pub(crate) fn create_test_app_state(config: Config) -> Arc { geoip, update_guard: UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } @@ -867,7 +891,7 @@ mod tests { let result = state.set_config(config_with_web_auth("secret")).await; - assert!(matches!(result, Err(shared::error::TuliproxError::ConfigWebUi(_)))); + assert!(matches!(&result, Err(err) if err.kind() == shared::error::ErrorKind::ConfigWebUi)); assert!(state.app_config.config.load().web_ui.is_none()); } @@ -877,7 +901,7 @@ mod tests { let result = state.set_config(config_with_web_auth("new-secret")).await; - assert!(matches!(result, Err(shared::error::TuliproxError::ConfigWebUi(_)))); + assert!(matches!(&result, Err(err) if err.kind() == shared::error::ErrorKind::ConfigWebUi)); assert_eq!( state .app_config diff --git a/backend/app/src/api/model/app_state_view.rs b/backend/app/src/api/model/app_state_view.rs index 14ec592df..d7e75943f 100644 --- a/backend/app/src/api/model/app_state_view.rs +++ b/backend/app/src/api/model/app_state_view.rs @@ -45,15 +45,30 @@ impl AppState { /// Declare a view: the accessor name, the context type, and the `AppState` /// fields it clones. Field names are the same on both sides by construction - /// a view renaming a handle would be a view lying about what it reads. +/// +/// The one permitted exception is `ctx_field <- state_field`, for a context +/// that is generic over a handle `AppState` holds concretely - `events <- +/// event_manager`. The context names the handle by the role it plays, +/// because it is written against a bound rather than against +/// `EventManager`; the view still reads exactly the handle it names. +macro_rules! view_field { + ($state:ident, $field:ident) => { + ::core::clone::Clone::clone(&$state.$field) + }; + ($state:ident, $field:ident, $source:ident) => { + ::core::clone::Clone::clone(&$state.$source) + }; +} + macro_rules! app_state_views { ($( $(#[$meta:meta])* - $accessor:ident => $ctx:ty { $($field:ident),+ $(,)? } + $accessor:ident => $ctx:ty { $($field:ident $(<- $source:ident)?),+ $(,)? } )+) => { $( impl AppStateView for $ctx { fn from_app_state(state: &AppState) -> Self { - Self { $($field: ::core::clone::Clone::clone(&state.$field)),+ } + Self { $($field: view_field!(state, $field $(, $source)?)),+ } } } @@ -70,8 +85,8 @@ macro_rules! app_state_views { app_state_views! { /// The handles the DVR needs: the recording queue and what feeds it. - recording_ctx => crate::api::model::recording::recording_ctx::RecordingCtx { - app_config, downloads, event_manager, http_client, + recording_ctx => crate::api::model::recording::recording_ctx::RecordingCtx> { + app_config, downloads, events <- event_manager, http_client, } /// The handles the HLS proxy needs: itself, plus provider allocation and @@ -92,8 +107,8 @@ app_state_views! { } /// The handles the background metadata worker reads. - metadata_update_ctx => tuliprox_metadata::ctx::MetadataUpdateCtx { - app_config, active_provider, connection_manager, event_manager, playlists, update_guard, + metadata_update_ctx => tuliprox_metadata::ctx::BoundMetadataUpdateCtx { + app_config, active_provider, connection_manager, events <- event_manager, playlists, update_guard, http_client, http_client_no_redirect, } } diff --git a/backend/app/src/api/model/hls_provisioning.rs b/backend/app/src/api/model/hls_provisioning.rs index f955a32cb..a09fea9ef 100644 --- a/backend/app/src/api/model/hls_provisioning.rs +++ b/backend/app/src/api/model/hls_provisioning.rs @@ -304,7 +304,7 @@ impl HlsProvisioningState { } } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; fn hls_response(hls_content: String) -> axum::response::Response { match axum::response::Response::builder() diff --git a/backend/app/src/api/model/mod.rs b/backend/app/src/api/model/mod.rs index df4c3a590..f96065b42 100644 --- a/backend/app/src/api/model/mod.rs +++ b/backend/app/src/api/model/mod.rs @@ -24,6 +24,13 @@ pub use crate::model::{stream_error::*, update_task::*}; // In-memory playlist storage moved to `repository`; re-exported so `api` call // sites keep their names. pub use crate::repository::playlist_mem_cache::*; +// Provider allocation and the streaming-session runtime moved to +// `tuliprox-session`; re-exported so `api` call sites keep their names, module +// paths included. +// The event taxonomy itself lives in `shared` - every emitter crate already +// sees it, and a metadata refresh has no business depending on the streaming +// session runtime. Only the bus implementation stays in `tuliprox-session`. +pub use shared::model::EventMessage; // Dependency-free model types moved to `tuliprox-core`, and the provider // response-header helpers to `tuliprox-session` beside the header types they // operate on. Re-exported so `api` call sites keep their names. @@ -82,15 +89,9 @@ pub use tuliprox_metadata::{ctx::MetadataUpdateCtx, manager::*}; // aggregation to `tuliprox-session`, the playlist cache loader to // `tuliprox-repository`. Re-exported so `api` call sites keep their names. pub use tuliprox_repository::playlist_cache_loader::*; -// Provider allocation and the streaming-session runtime moved to -// `tuliprox-session`; re-exported so `api` call sites keep their names, module -// paths included. pub use tuliprox_session::{ - active_provider_manager, active_user_manager, admission_strategy, connection_manager, event_manager, meter, - provider_lineup_manager, stream, -}; -pub use tuliprox_session::{ - active_provider_manager::*, active_user_manager::*, admission_strategy::*, connection_manager::*, event_manager::*, - meter::*, provider_dns_manager::*, provider_lineup_manager::*, qos_aggregation_manager::*, response_headers::*, - stream::*, streams::*, + active_provider_manager, active_provider_manager::*, active_user_manager, active_user_manager::*, + admission_strategy, admission_strategy::*, connection_manager, connection_manager::*, event_manager, + event_manager::*, meter, meter::*, provider_dns_manager::*, provider_lineup_manager, provider_lineup_manager::*, + qos_aggregation_manager::*, response_headers::*, stream, stream::*, streams::*, }; diff --git a/backend/app/src/api/model/streams/active_client_stream.rs b/backend/app/src/api/model/streams/active_client_stream.rs index ba95acb1e..1bd22dfe0 100644 --- a/backend/app/src/api/model/streams/active_client_stream.rs +++ b/backend/app/src/api/model/streams/active_client_stream.rs @@ -896,7 +896,7 @@ pub(crate) async fn create_active_client_stream(request: ActiveClientStreamParam } else { stream }; - return wrap_timed_client_stream_if_needed(app_state, stream, fingerprint.addr, virtual_id); + return wrap_timed_client_stream_if_needed(app_state, stream, fingerprint.addr, VirtualId::new(virtual_id)); } } @@ -928,8 +928,9 @@ pub(crate) async fn create_active_client_stream(request: ActiveClientStreamParam // Compute deferred provider open before moving stream_details into the grace task. let deferred_provider_open = create_deferred_provider_open_future(app_state, &stream_details, fingerprint, &stream_channel, req_headers); - let timed_stream_context = - deferred_provider_open.as_ref().and_then(|_| create_timed_stream_context(app_state, virtual_id)); + let timed_stream_context = deferred_provider_open + .as_ref() + .and_then(|_| create_timed_stream_context(app_state, VirtualId::new(virtual_id))); let stream_taken = stream_details.stream.take(); let has_deferred_open = stream_details.has_deferred_provider_open(); let grace_waker = @@ -940,7 +941,7 @@ pub(crate) async fn create_active_client_stream(request: ActiveClientStreamParam user_grace_period: grant_user_grace_period, user: user.clone(), fingerprint: fingerprint.clone(), - virtual_id, + virtual_id: VirtualId::new(virtual_id), session_token: owned_session_token, provisioning_info, waker: grace_waker, @@ -987,7 +988,7 @@ pub(crate) async fn create_active_client_stream(request: ActiveClientStreamParam } else { stream }; - Some(wrap_timed_client_stream_if_needed(app_state, stream, fingerprint.addr, virtual_id)) + Some(wrap_timed_client_stream_if_needed(app_state, stream, fingerprint.addr, VirtualId::new(virtual_id))) } }; @@ -1171,15 +1172,17 @@ fn stream_grace_period(request: GracePeriodParams) -> (Option>, Op let remaining_result = tuliprox_session::admission::evaluate_remaining_strategies_after_grace( &app_state.admission_ctx(), - &username, - max_connections, - user.soft_connections, - &fingerprint.client_ip, - &fingerprint.addr, - true, - session_token.as_deref(), - true, - eviction_guard, + tuliprox_session::admission::AdmissionRequest { + username: &username, + max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: session_token.as_deref(), + activate_unbound_session: true, + eviction_reentry_guard: eviction_guard, + }, ctx, grace_kind, ) @@ -1390,7 +1393,7 @@ mod tests { use shared::{ model::{ AdmissionStrategy, ConfigPaths, InputFetchMethod, InputType, PlaylistItemType, StreamChannel, - UserConnectionPermission, XtreamCluster, + UserConnectionPermission, VirtualId, XtreamCluster, }, utils::Internable, }; @@ -1513,6 +1516,13 @@ mod tests { geoip, update_guard: UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } @@ -1584,6 +1594,13 @@ mod tests { geoip, update_guard: UpdateGuard::new(), metadata_manager, + identity_registry: Arc::new(tuliprox_repository::identity_registry::IdentityRegistry::empty( + std::path::PathBuf::new(), + )), + login_throttle: Arc::new(crate::auth::LoginThrottle::new()), + token_revocations: Arc::new(tuliprox_repository::token_revocations::TokenRevocations::empty( + std::path::PathBuf::new(), + )), manual_update_sender, }) } @@ -1862,7 +1879,7 @@ mod tests { user_grace_period: false, user: test_user, fingerprint: test_fingerprint, - virtual_id: 1, + virtual_id: VirtualId::new(1), session_token: session_token.map(str::to_string), provisioning_info: None, waker: None, @@ -2392,7 +2409,11 @@ mod tests { deferred_provider_open: Some(DeferredProviderOpenState::Opening(Box::pin(async { DeferredProviderOpenOutcome::Stream(futures::stream::pending::>().boxed()) }))), - timed_stream_context: Some(TimedStreamContext { app_state, duration_secs: 1, virtual_id: 1 }), + timed_stream_context: Some(TimedStreamContext { + app_state, + duration_secs: 1, + virtual_id: VirtualId::new(1), + }), preempt_cancelled: None, grace_task_handle: None, provisioning_stop_signal: None, @@ -2709,7 +2730,7 @@ mod tests { let grace_context = GraceResolutionContext { strategy_index: 0, - strategies: vec![AdmissionStrategy::GraceHoldStream], + strategies: [AdmissionStrategy::GraceHoldStream].into(), kind: Some(crate::api::model::ConnectionKind::Soft), }; @@ -2741,7 +2762,7 @@ mod tests { user_grace_period: true, user: user.clone(), fingerprint: second_fingerprint.clone(), - virtual_id: 2, + virtual_id: VirtualId::new(2), session_token: Some("tok-second".to_string()), provisioning_info: None, waker: None, diff --git a/backend/app/src/api/sys_usage.rs b/backend/app/src/api/sys_usage.rs index 6efb82800..0c6c3a2f4 100644 --- a/backend/app/src/api/sys_usage.rs +++ b/backend/app/src/api/sys_usage.rs @@ -1,11 +1,7 @@ #[cfg(target_os = "linux")] use crate::utils::parse_ascii_u64_bytes; -use crate::{ - api::model::AppState, - messaging::send_message as send_messaging, - model::{DiskAlertConfig, MessageContent}, -}; -use shared::model::{DiskAlert, DiskAlertLevel, MsgKind, SystemInfo}; +use crate::{api::model::AppState, model::DiskAlertConfig}; +use shared::model::{DiskAlert, DiskAlertLevel, EventMessage, SystemInfo}; use std::{ sync::Arc, time::{Duration, Instant}, @@ -174,7 +170,7 @@ pub fn exec_system_usage(app_state: &Arc) -> tokio::task::JoinHandle<( let Some(info) = sampler.sample() else { continue }; if has_receivers { - state.event_manager.send_system_info(info); + state.event_manager.send_event(EventMessage::SystemInfoUpdate(Arc::new(info))); } // Disk-alert check is gated on (1) disk info being available, and @@ -186,12 +182,15 @@ pub fn exec_system_usage(app_state: &Arc) -> tokio::task::JoinHandle<( // whether anyone is currently listening on the WS event stream, // so the monitor's hysteresis/rearm semantics stay correct across // transient disconnects. + // The thresholds live under `messaging.disk_alert`, so that + // config is still required to evaluate anything. The *subscription* + // check that used to sit here is gone: the notification layer + // already drops unsubscribed events, and gating here meant a + // plugin watching for disk pressure saw nothing unless an + // operator happened to subscribe to the same event by mail. let alert_cfg: DiskAlertConfig = { let cfg = state.app_config.config.load(); let Some(messaging) = cfg.messaging.as_ref() else { continue }; - if !messaging.notify_on.contains(&MsgKind::DiskAlert) { - continue; - } let Some(alert_cfg) = messaging.disk_alert.as_ref() else { continue }; alert_cfg.clone() }; @@ -199,8 +198,7 @@ pub fn exec_system_usage(app_state: &Arc) -> tokio::task::JoinHandle<( else { continue; }; - let http_client = state.http_client.load(); - send_messaging(&state.app_config, &http_client, MessageContent::DiskAlert(alert)).await; + state.event_manager.send_event(EventMessage::DiskAlert(alert)); } }) } diff --git a/backend/app/src/api/tasks/config_watch.rs b/backend/app/src/api/tasks/config_watch.rs index 82e2768cb..3f9ab1e13 100644 --- a/backend/app/src/api/tasks/config_watch.rs +++ b/backend/app/src/api/tasks/config_watch.rs @@ -13,7 +13,10 @@ use notify::{ event::{AccessKind, AccessMode}, recommended_watcher, EventKind, RecursiveMode, Watcher, }; -use shared::{error::TuliproxError, model::ConfigPaths}; +use shared::{ + error::TuliproxError, + model::{ConfigPaths, ConfigReloadFailure}, +}; use std::{ collections::{HashMap, HashSet}, path::{Path, PathBuf}, @@ -65,9 +68,16 @@ fn start_config_watch(app_state: &Arc, cancel_token: &CancellationToke let watcher_app_state = Arc::clone(app_state); let handle_error = move |err: TuliproxError, paths: &HashSet| { - let msg = format!("Failed to reload config files [{}]: {err}", format_paths(paths)); + let formatted = format_paths(paths); + let msg = format!("Failed to reload config files [{formatted}]: {err}"); error!("{msg}"); - event_manager.send_event(EventMessage::ServerError(msg)); + // Two events, deliberately. `ServerError` is what the Web UI shows + // as a toast; `ConfigReloadFailed` is the typed one, so an operator + // or a plugin can subscribe to "my config stopped loading" without + // taking every server error. + event_manager.send_event(EventMessage::ServerError(msg.clone())); + event_manager + .send_event(EventMessage::ConfigReloadFailed(ConfigReloadFailure { paths: formatted, error: msg })); }; tokio::spawn(async move { diff --git a/backend/app/src/api/tasks/mod.rs b/backend/app/src/api/tasks/mod.rs index e75a9767d..28ae2b8df 100644 --- a/backend/app/src/api/tasks/mod.rs +++ b/backend/app/src/api/tasks/mod.rs @@ -18,11 +18,13 @@ mod config_watch; mod hdhomerun_ssdp; mod library_scan; +mod notification_bridge; mod scheduler; mod xtream_expiry; pub(in crate::api) use config_watch::*; pub(in crate::api) use hdhomerun_ssdp::*; pub(in crate::api) use library_scan::*; +pub(in crate::api) use notification_bridge::*; pub(in crate::api) use scheduler::*; pub(in crate::api) use xtream_expiry::*; diff --git a/backend/app/src/api/tasks/notification_bridge.rs b/backend/app/src/api/tasks/notification_bridge.rs new file mode 100644 index 000000000..4b66be14d --- /dev/null +++ b/backend/app/src/api/tasks/notification_bridge.rs @@ -0,0 +1,806 @@ +//! Bridges the in-process event bus onto the notification pipeline. +//! +//! `EventMessage` already carries fourteen variants from thirteen emitters - +//! playlist updates, config changes, library scans, user connections, +//! metadata updates, recording changes - and every one of them reached the +//! Web UI over the websocket and nowhere else. Meanwhile the notification +//! side had six emitters of its own. +//! +//! One subscriber closes that gap: every bus event becomes notifiable, and +//! every future `EventMessage` variant comes along for free. +//! +//! Three things keep it from being a firehose: +//! +//! * high-frequency variants (progress ticks, download deltas, periodic +//! system info) map to terminal events only, or to nothing; +//! * everything defaults to unsubscribed, so an upgrade does not silently +//! start messaging people; +//! * the broadcast channel has capacity 10, so a slow subscriber must +//! survive `Lagged` rather than dying and taking the bridge with it. + +use crate::api::model::AppState; +use log::{debug, warn}; +use shared::model::{ + notification::{EventId, Severity}, + AuthAuditEvent, AuthAuditOutcome, ConnectionDenied, EventKind, EventKindMask, EventMessage, + LibraryScanSummaryStatus, MetadataUpdateFailure, PlaylistGroupsChanged, ProviderFetchFailure, + ProviderPoolExhausted, ProviderPriorityFallback, ScheduledTaskFailure, ServerLifecycleEvent, ServerLifecycleState, + StreamProbeFailure, StreamProbeFailureReason, UserLifecycleEvent, UserLifecycleState, WatchDisabled, + WatchDisabledReason, WatchUnmatched, +}; +use std::{fmt::Write, sync::Arc}; +use tokio::sync::broadcast::error::RecvError; +use tokio_util::sync::CancellationToken; +use tuliprox_core::model::{MessageContent, NotificationEvent, ProcessingStats}; + +/// Subscribe to the event bus and forward what the config asks for. +pub fn spawn_notification_bridge(app_state: &Arc, cancel_token: &CancellationToken) { + let mut receiver = app_state.event_manager.subscribe_filtered(NOTIFIABLE_KINDS); + let app_state = Arc::clone(app_state); + let cancel_token = cancel_token.clone(); + tokio::spawn(async move { + loop { + let message = tokio::select! { + () = cancel_token.cancelled() => break, + message = receiver.recv() => message, + }; + match message { + Ok(message) => { + if let Some(event) = to_notification(&message) { + let client = app_state.http_client.load(); + tuliprox_messaging::send_event(&app_state.app_config, &client, event).await; + } + } + // Capacity is 10. Falling behind must not kill the bridge - + // report the gap and keep going. + Err(RecvError::Lagged(skipped)) => { + app_state.event_manager.stats().record_lag(skipped); + warn!("Notification bridge fell behind and skipped {skipped} event(s)"); + } + Err(RecvError::Closed) => break, + } + } + debug!("Notification bridge stopped"); + }); +} + +/// The kinds this bridge can turn into a notification. +/// +/// The four high-frequency kinds are deliberately absent rather than +/// filtered inside `to_notification`: excluding them here means the bus +/// never wakes this task for a progress tick at all, which under a playlist +/// refresh is most of the traffic on the channel. +/// +/// Keep in step with the `None` arms below - the test at the bottom of this +/// file asserts they agree. +const NOTIFIABLE_KINDS: EventKindMask = EventKindMask::new() + .with(EventKind::DiskAlert) + .with(EventKind::ConfigReloadFailed) + .with(EventKind::PlaylistWatchChanged) + .with(EventKind::PlaylistGroupsChanged) + .with(EventKind::PlaylistWatchDisabled) + .with(EventKind::PlaylistWatchUnmatched) + .with(EventKind::ProviderAccountStatus) + .with(EventKind::ProviderAccountExpiring) + .with(EventKind::ProviderAccountExpired) + .with(EventKind::ProviderFetchFailed) + .with(EventKind::ProviderPoolExhausted) + .with(EventKind::ProviderPriorityFallback) + .with(EventKind::ServerError) + .with(EventKind::ServerStarted) + .with(EventKind::ServerShutdown) + .with(EventKind::PlaylistUpdate) + .with(EventKind::ConfigChange) + .with(EventKind::LibraryScanProgress) + .with(EventKind::LibraryScanFailed) + .with(EventKind::InputMetadataUpdatesStarted) + .with(EventKind::InputMetadataUpdatesCompleted) + .with(EventKind::InputMetadataUpdatesFailed) + .with(EventKind::ActiveUser) + .with(EventKind::ActiveProvider) + .with(EventKind::RecordingChanged) + .with(EventKind::RecordingRulesChanged) + .with(EventKind::UserCreated) + .with(EventKind::UserUpdated) + .with(EventKind::UserDeleted) + .with(EventKind::ConnectionDenied) + .with(EventKind::StreamProbeFailed) + .with(EventKind::ScheduledTaskFailed) + .with(EventKind::AuthSignInSucceeded) + .with(EventKind::AuthSignInFailed) + .with(EventKind::AuthSignInThrottled) + .with(EventKind::AuthPermissionDenied); + +/// Map a bus event onto a notification, or `None` to ignore it. +/// +/// `None` is the right answer for the high-frequency variants: progress +/// ticks and download deltas fire many times per operation and carry +/// nothing an operator wants pushed to a phone. Their terminal counterparts +/// are what get through. +// Two arms return `None` for different reasons - one because delivery is +// owned by the durable recording path, one because the event declared itself +// non-notifiable. Merging them would collapse that distinction into a list of +// variants with no way to tell which is which. +#[allow(clippy::match_same_arms)] +#[must_use] +// One arm per `EventMessage` variant and no logic beyond dispatch: the +// wording lives in the builders below. It is long because the taxonomy is, +// and splitting a routing table in half only hides which variants are handled. +#[allow(clippy::too_many_lines)] +pub fn to_notification(message: &EventMessage) -> Option { + // Which notification an event *is* belongs to the event; this function + // only decides how to word it and what to attach. `None` here means the + // event declared itself non-notifiable, so there is no second table to + // keep in step. + let id = message.notification_id()?; + match message { + EventMessage::ServerError(error) => { + Some(NotificationEvent::new(id, first_line(error), error.clone()).with_severity(message.severity())) + } + + // Rendered through `ProcessingStats` so the "Stats"/"Error" + // templates - which read `fields.stats` - see exactly the shape the + // separate stats message used to hand them. The id and severity come + // from the event, because the pipeline's own `PlaylistUpdateState` is + // a better answer than re-deriving the outcome from which fields + // happen to be populated. + EventMessage::PlaylistUpdate(summary) => { + let content = MessageContent::ProcessingStats(ProcessingStats { + stats: (!summary.stats.is_empty()).then(|| summary.stats.clone()), + errors: summary.error.clone(), + }); + let mut event = NotificationEvent::from_content(&content); + event.id = id; + event.severity = message.severity(); + Some(event) + } + + EventMessage::ConfigChange(config_type) => { + let title = format!("Configuration reloaded: {config_type}"); + Some( + NotificationEvent::new(id, title.clone(), title) + // One reload of the same file is one piece of news, however + // many times the watcher fires for it. + .with_dedup_key(format!("config:{config_type}")) + .with_fields(&ConfigTypeField { config_type: config_type.to_string() }), + ) + } + + EventMessage::LibraryScanProgress(event) => { + // Progress ticks are not news; the finished scan is - and a scan + // that failed is not a scan that finished. `id` already carries + // the distinction, so the wording only has to follow it. + let summary = &event.summary; + let title = match summary.status { + LibraryScanSummaryStatus::Success => "Library scan finished".to_string(), + LibraryScanSummaryStatus::Error => format!("Library scan failed: {}", summary.message), + }; + Some(NotificationEvent::new(id, title.clone(), title).with_fields(summary)) + } + + EventMessage::ServerLifecycle(event) => Some(server_lifecycle_notification(id, event)), + + EventMessage::InputMetadataUpdatesStarted(input) => { + let title = format!("Metadata update started for {input}"); + Some(NotificationEvent::new(id, title.clone(), title)) + } + EventMessage::InputMetadataUpdatesCompleted(input) => { + let title = format!("Metadata update finished for {input}"); + Some(NotificationEvent::new(id, title.clone(), title)) + } + EventMessage::InputMetadataUpdatesFailed(failure) => { + Some(metadata_failure_notification(id, failure, message.severity())) + } + + EventMessage::ActiveUser(change) => { + let title = "User connection changed".to_string(); + Some(NotificationEvent::new(id, title.clone(), title).with_fields(change)) + } + EventMessage::ActiveProvider(name, count) => { + let title = format!("Provider {name} now has {count} active connection(s)"); + Some(NotificationEvent::new(id, title.clone(), title)) + } + + EventMessage::RecordingChanged => { + let title = "Recording queue changed".to_string(); + Some(NotificationEvent::new(id, title.clone(), title)) + } + EventMessage::RecordingRulesChanged => { + let title = "Recording rules changed".to_string(); + Some(NotificationEvent::new(id, title.clone(), title)) + } + + // The three that already have a `MessageContent` shape reuse it: + // `from_content` is what built their title, body and template fields + // before, and rebuilding that here by hand would be a second copy + // free to drift from the templates that render it. + EventMessage::DiskAlert(alert) => { + Some(NotificationEvent::from_content(&MessageContent::DiskAlert(alert.clone()))) + } + EventMessage::PlaylistWatchChanged(changes) => { + Some(NotificationEvent::from_content(&MessageContent::Watch(changes.clone()))) + } + EventMessage::PlaylistGroupsChanged(changes) => Some(groups_changed_notification(id, changes)), + EventMessage::PlaylistWatchDisabled(disabled) => { + Some(watch_disabled_notification(id, disabled, message.severity())) + } + EventMessage::PlaylistWatchUnmatched(unmatched) => { + Some(watch_unmatched_notification(id, unmatched, message.severity())) + } + EventMessage::RecordingLifecycle(_) => None, + + // Deliberately not notified, and deliberately absent from + // `NOTIFIABLE_KINDS` so the bridge is not even woken for it. This + // event exists *because* delivery failed; enqueueing a notice about + // it into the same outbox, against the same channels that just + // failed, is a loop that ends in an outbox full of notices about + // notices. Operators get the `notification::audit` log line and the + // `dead_lettered` counter, neither of which runs through the path + // that broke. Plugins and the status endpoint see it on the bus. + EventMessage::NotificationDeadLettered(_) => None, + + EventMessage::ScheduledTaskFailed(failure) => { + Some(scheduled_task_notification(id, failure, message.severity())) + } + + EventMessage::ProviderFetchFailed(failure) => Some(fetch_failure_notification(id, failure, message.severity())), + EventMessage::ProviderPoolExhausted(exhausted) => { + Some(pool_exhausted_notification(id, exhausted, message.severity())) + } + EventMessage::ProviderPriorityFallback(fallback) => { + Some(priority_fallback_notification(id, fallback, message.severity())) + } + + EventMessage::UserLifecycle(event) => Some(user_lifecycle_notification(id, event, message.severity())), + EventMessage::ConnectionDenied(denied) => Some(connection_denied_notification(id, denied, message.severity())), + EventMessage::StreamProbeFailed(failure) => Some(probe_failure_notification(id, failure, message.severity())), + + EventMessage::AuthAudit(event) => Some(auth_audit_notification(id, event, message.severity())), + + EventMessage::ConfigReloadFailed(failure) => { + let title = format!("Configuration reload failed: {}", failure.paths); + Some( + NotificationEvent::new(id, title, failure.error.clone()) + .with_severity(message.severity()) + .with_fields(failure), + ) + } + + EventMessage::ProviderAccount(event) => Some( + NotificationEvent::new(id, event.message.clone(), event.message.clone()) + .with_severity(message.severity()) + // Re-evaluated on every playlist refresh; without this an + // expiring account notifies on each one for three days. + .with_dedup_key(event.dedup_key()) + .with_fields(event), + ), + + // Unreachable: `notification_id` already returned `None` for these, + // which is the single place that decision is made. + EventMessage::PlaylistUpdateProgress(_) + | EventMessage::SystemInfoUpdate(_) + | EventMessage::DownloadsUpdate(_) + | EventMessage::DownloadsDeltaUpdate(_) => None, + } +} + +/// One notification builder per event that needs more than a title. +/// +/// `to_notification` is a routing table; keeping the wording out of it is what +/// stops it turning into one long function as the taxonomy grows. +fn server_lifecycle_notification(id: EventId, event: &ServerLifecycleEvent) -> NotificationEvent { + let title = match event.state { + ServerLifecycleState::Started => event.address.as_ref().map_or_else( + || format!("Server {} started", event.version), + |addr| format!("Server {} started on {addr}", event.version), + ), + ServerLifecycleState::ShuttingDown => event + .reason + .as_ref() + .map_or_else(|| "Server shutting down".to_string(), |why| format!("Server shutting down ({why})")), + }; + NotificationEvent::new(id, title.clone(), title).with_fields(event) +} + +fn metadata_failure_notification( + id: EventId, + failure: &MetadataUpdateFailure, + severity: Severity, +) -> NotificationEvent { + let title = + format!("Metadata update for {} could not finish: {} task(s) exhausted", failure.input, failure.failed_tasks); + let body = failure.last_error.as_ref().map_or_else(|| title.clone(), |err| format!("{title}\n\n{err}")); + NotificationEvent::new(id, title, body).with_severity(severity).with_fields(failure) +} + +fn groups_changed_notification(id: EventId, changes: &PlaylistGroupsChanged) -> NotificationEvent { + let title = + format!("{} group(s) added, {} removed in {}", changes.added_total, changes.removed_total, changes.target); + let mut body = title.clone(); + push_sampled_list(&mut body, "Added", &changes.added, changes.added_total); + push_sampled_list(&mut body, "Removed", &changes.removed, changes.removed_total); + NotificationEvent::new(id, title, body).with_fields(changes) +} + +/// Render one direction of a sampled change set. +/// +/// The lists carry titles only and may be shorter than the total - or empty - +/// so the omission is spelled out rather than smuggled into the list. +fn push_sampled_list(body: &mut String, label: &str, names: &[String], total: usize) { + if total == 0 { + return; + } + let _ = write!(body, "\n\n{label} ({total}):"); + for name in names { + body.push('\n'); + body.push_str(name); + } + let omitted = total.saturating_sub(names.len()); + if omitted > 0 { + let _ = write!(body, "\n... {omitted} more not listed"); + } +} + +fn watch_disabled_notification(id: EventId, disabled: &WatchDisabled, severity: Severity) -> NotificationEvent { + let what = match disabled.reason { + WatchDisabledReason::InvalidPatterns => "no watch pattern compiled".to_string(), + WatchDisabledReason::UnnamedTarget => "the target has no unique name".to_string(), + WatchDisabledReason::StorageFailure => disabled.group.as_ref().map_or_else( + || "watch state is unreadable".to_string(), + |group| format!("watch state for {group} is unreadable"), + ), + }; + let title = format!("Watch is not running for {}: {what}", disabled.target); + let body = disabled.detail.as_ref().map_or_else(|| title.clone(), |detail| format!("{title}\n\n{detail}")); + NotificationEvent::new(id, title, body).with_severity(severity).with_fields(disabled) +} + +fn watch_unmatched_notification(id: EventId, unmatched: &WatchUnmatched, severity: Severity) -> NotificationEvent { + let title = format!( + "{} watch pattern(s) for {} matched none of its {} group(s)", + unmatched.patterns.len(), + unmatched.target, + unmatched.groups_seen + ); + let body = format!("{title}\n\n{}", unmatched.patterns.join("\n")); + NotificationEvent::new(id, title, body).with_severity(severity).with_fields(unmatched) +} + +fn fetch_failure_notification(id: EventId, failure: &ProviderFetchFailure, severity: Severity) -> NotificationEvent { + let title = + format!("{} playlist fetch failed for {} ({})", failure.provider, failure.input, failure.kind.as_wire_name()); + let mut body = failure.message.as_ref().map_or_else(|| title.clone(), |detail| format!("{title}\n\n{detail}")); + if failure.error_count > 1 { + let _ = write!(body, "\n\n{} error(s) in total.", failure.error_count); + } + if failure.partial { + body.push_str("\n\nSome of the playlist was fetched anyway."); + } + NotificationEvent::new(id, title, body).with_severity(severity).with_fields(failure) +} + +fn pool_exhausted_notification( + id: EventId, + exhausted: &ProviderPoolExhausted, + severity: Severity, +) -> NotificationEvent { + let title = format!("All {} provider(s) for {} are at capacity", exhausted.providers.len(), exhausted.input); + let mut body = title.clone(); + for entry in &exhausted.providers { + let _ = write!( + body, + "\n{}: {}/{}{}", + entry.name, + entry.current_connections, + entry.max_connections, + if entry.expired { " (expired)" } else { "" } + ); + } + NotificationEvent::new(id, title, body).with_severity(severity).with_fields(exhausted) +} + +fn priority_fallback_notification( + id: EventId, + fallback: &ProviderPriorityFallback, + severity: Severity, +) -> NotificationEvent { + let direction = if fallback.is_recovery() { "moved back to" } else { "fell back to" }; + let title = format!( + "{} {direction} provider priority group {} of {}", + fallback.input, fallback.group_index, fallback.group_count + ); + let body = format!("{title}\n\nNow served by {}", fallback.provider); + NotificationEvent::new(id, title, body).with_severity(severity).with_fields(fallback) +} + +fn scheduled_task_notification(id: EventId, failure: &ScheduledTaskFailure, severity: Severity) -> NotificationEvent { + let title = format!("Scheduled {:?} failed", failure.task); + let mut body = format!("{title}\n\n{}", failure.error); + if let Some(schedule) = &failure.schedule { + let _ = write!(body, "\n\nSchedule: {schedule}"); + } + NotificationEvent::new(id, title, body).with_severity(severity).with_fields(failure) +} + +fn connection_denied_notification(id: EventId, denied: &ConnectionDenied, severity: Severity) -> NotificationEvent { + let limit = if denied.max_connections > 0 { + format!("{} connection(s)", denied.max_connections) + } else { + format!("{} soft connection(s)", denied.soft_connections) + }; + let title = format!("{} was refused a connection (limit: {limit})", denied.username); + let body = format!("{title}\n\nFrom {}", denied.client_ip); + NotificationEvent::new(id, title, body).with_severity(severity).with_fields(denied) +} + +/// Word an account lifecycle change. +fn user_lifecycle_notification(id: EventId, event: &UserLifecycleEvent, severity: Severity) -> NotificationEvent { + let action = match event.state { + UserLifecycleState::Created => "created", + UserLifecycleState::Updated => "updated", + UserLifecycleState::Deleted => "deleted", + }; + let title = format!("User {} {action} on target {}", event.username, event.target); + NotificationEvent::new(id, title.clone(), title) + .with_severity(severity) + .with_dedup_key(event.dedup_key()) + .with_fields(event) +} + +/// Word an authentication decision. +fn auth_audit_notification(id: EventId, event: &AuthAuditEvent, severity: Severity) -> NotificationEvent { + let title = match event.outcome { + AuthAuditOutcome::SignInSucceeded => format!("{} signed in from {}", event.username, event.client_ip), + AuthAuditOutcome::SignInFailed => format!("Sign-in rejected for {} from {}", event.username, event.client_ip), + AuthAuditOutcome::SignInThrottled => { + format!("Sign-in throttled for {} from {}", event.username, event.client_ip) + } + AuthAuditOutcome::PermissionDenied => format!( + "{} was denied '{}' from {}", + event.username, + event.permission.as_deref().unwrap_or("unknown"), + event.client_ip + ), + }; + NotificationEvent::new(id, title.clone(), title) + .with_severity(severity) + // A password-guessing run is one piece of news, not one per attempt - + // which is exactly the case that generates the most events. + .with_dedup_key(event.dedup_key()) + .with_fields(event) +} + +/// Word a failed stream probe. +fn probe_failure_notification(id: EventId, failure: &StreamProbeFailure, severity: Severity) -> NotificationEvent { + let reason = match failure.reason { + StreamProbeFailureReason::NotFound => "returned 404", + StreamProbeFailureReason::Unreachable => "was unreachable or timed out", + }; + let title = format!("Stream probe failed for input {}", failure.input); + let body = format!("Stream {} {reason}: {}", failure.unique_id, failure.url); + NotificationEvent::new(id, title, body) + .with_severity(severity) + // Per input, not per stream: a provider outage fails every channel + // behind it in the same run. + .with_dedup_key(failure.dedup_key()) + .with_fields(failure) +} + +/// `ConfigType` is not `Serialize`, so the template payload carries its +/// display form. +#[derive(serde::Serialize)] +struct ConfigTypeField { + config_type: String, +} + +fn first_line(s: &str) -> String { s.lines().next().unwrap_or(s).trim().to_string() } + +#[cfg(test)] +mod tests { + use super::{to_notification, EventMessage, NOTIFIABLE_KINDS}; + use shared::model::{ + notification::{registry, Severity}, + ActiveUserConnectionChange, AuthAuditEvent, AuthAuditOutcome, ConfigReloadFailure, ConfigType, + ConnectionDenied, DiskAlert, DiskAlertLevel, DownloadsResponse, EventKind, LibraryScanProgressEvent, + LibraryScanSummary, LibraryScanSummaryStatus, MetadataUpdateFailure, MsgKind, NotificationDeadLetter, + PlaylistGroupsChanged, PlaylistUpdateProgressEvent, PlaylistUpdateState, PlaylistUpdateSummary, + ProviderAccountEvent, ProviderAccountState, ProviderFailureKind, ProviderFetchFailure, ProviderPoolExhausted, + ProviderPriorityFallback, RecordingLifecycleMessage, ScheduledTaskFailure, ServerLifecycleEvent, + StreamProbeFailure, StreamProbeFailureReason, SystemInfo, UserLifecycleEvent, UserLifecycleState, WatchChanges, + WatchDisabled, WatchDisabledReason, WatchUnmatched, + }; + use std::sync::Arc; + + /// The subscription mask and the mapping must agree. If they drift, the + /// bridge either wakes for events it will drop, or - worse - silently + /// stops delivering a kind that `to_notification` still handles. + #[test] + fn notifiable_mask_matches_the_mapped_variants() { + for (message, kind) in sample_of_every_kind() { + let mapped = to_notification(&message).is_some(); + assert_eq!( + NOTIFIABLE_KINDS.contains(kind), + mapped, + "{kind:?}: mask says {}, to_notification says {mapped}", + NOTIFIABLE_KINDS.contains(kind), + ); + } + } + + fn provider_fetch_failure() -> ProviderFetchFailure { + ProviderFetchFailure { + input: "i".into(), + provider: "m3u".into(), + kind: ProviderFailureKind::Transient, + error_count: 1, + message: None, + retryable: true, + needs_operator: false, + partial: false, + } + } + + fn auth_audit(outcome: AuthAuditOutcome) -> EventMessage { + EventMessage::AuthAudit(AuthAuditEvent { + username: "u".into(), + client_ip: "127.0.0.1".into(), + outcome, + permission: None, + }) + } + + /// One `EventMessage` per `EventKind`, so the test above cannot miss a + /// variant added later. + /// + /// One entry per kind and an assert on the length: long by construction, + /// and it has to stay one list for that assert to mean anything. + #[allow(clippy::too_many_lines)] + fn sample_of_every_kind() -> Vec<(EventMessage, EventKind)> { + let empty_downloads = DownloadsResponse { queue: Vec::new(), finished: Vec::new(), active: Vec::new() }; + let samples = vec![ + EventMessage::ServerError("x".to_string()), + EventMessage::ServerLifecycle(ServerLifecycleEvent::started("1".into(), "h:1".into())), + EventMessage::ServerLifecycle(ServerLifecycleEvent::shutting_down("1".into(), "SIGTERM".into())), + EventMessage::ActiveUser(ActiveUserConnectionChange::Connections(0, 0)), + EventMessage::ActiveProvider("p".into(), 1), + EventMessage::ConfigChange(ConfigType::Config), + EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only(PlaylistUpdateState::Success)), + EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: String::new(), + message: String::new(), + }), + EventMessage::SystemInfoUpdate(Arc::new(SystemInfo { + cpu_usage: 0.0, + memory_usage: 0, + memory_total: 0, + net_rx_bytes_per_sec: 0.0, + net_tx_bytes_per_sec: 0.0, + net_rx_bytes_total: 0, + net_tx_bytes_total: 0, + disk_total_bytes: 0, + disk_free_bytes: 0, + })), + EventMessage::LibraryScanProgress(LibraryScanProgressEvent { + summary: LibraryScanSummary { + status: LibraryScanSummaryStatus::Error, + message: String::new(), + result: None, + }, + }), + EventMessage::LibraryScanProgress(LibraryScanProgressEvent { + summary: LibraryScanSummary { + status: LibraryScanSummaryStatus::Success, + message: String::new(), + result: None, + }, + }), + EventMessage::DownloadsUpdate(Arc::new(empty_downloads)), + EventMessage::DownloadsDeltaUpdate(shared::model::TransfersDelta::ActiveCleared), + EventMessage::RecordingChanged, + EventMessage::RecordingRulesChanged, + EventMessage::InputMetadataUpdatesStarted("a".into()), + EventMessage::InputMetadataUpdatesFailed(MetadataUpdateFailure::new("a".into(), 1, false, None)), + EventMessage::InputMetadataUpdatesCompleted("a".into()), + EventMessage::DiskAlert(DiskAlert { + level: DiskAlertLevel::Warn, + total_bytes: 100, + free_bytes: 5, + used_bytes: 95, + percent: 95.0, + }), + EventMessage::ConfigReloadFailed(ConfigReloadFailure { + paths: "config.yml".to_string(), + error: "boom".to_string(), + }), + EventMessage::PlaylistWatchChanged(WatchChanges::new( + "t".to_string(), + "g".to_string(), + Vec::new(), + Vec::new(), + )), + EventMessage::PlaylistGroupsChanged(PlaylistGroupsChanged::new("t".to_string(), Vec::new(), Vec::new())), + EventMessage::PlaylistWatchDisabled(WatchDisabled::new( + "t".to_string(), + WatchDisabledReason::InvalidPatterns, + )), + EventMessage::PlaylistWatchUnmatched(WatchUnmatched::new("t".to_string(), vec!["x".to_string()], 0)), + recording_lifecycle(MsgKind::RecordingStarted), + recording_lifecycle(MsgKind::RecordingCompleted), + recording_lifecycle(MsgKind::RecordingFailed), + EventMessage::ProviderFetchFailed(provider_fetch_failure()), + EventMessage::ProviderPoolExhausted(ProviderPoolExhausted::new("i".into(), Vec::new())), + EventMessage::ProviderPriorityFallback(ProviderPriorityFallback::new( + "i".into(), + "p".into(), + 1, + 2, + Some(0), + )), + provider_account(ProviderAccountState::StatusChanged), + provider_account(ProviderAccountState::Expiring), + provider_account(ProviderAccountState::Expired), + user_lifecycle(UserLifecycleState::Created), + user_lifecycle(UserLifecycleState::Updated), + user_lifecycle(UserLifecycleState::Deleted), + EventMessage::ScheduledTaskFailed(ScheduledTaskFailure::new( + shared::model::ScheduleTaskType::GeoIpUpdate, + "boom".to_string(), + )), + EventMessage::NotificationDeadLettered(NotificationDeadLetter::new( + shared::model::notification::registry::SYSTEM_ERROR, + 3, + vec!["telegram".to_string()], + 0, + )), + EventMessage::ConnectionDenied(ConnectionDenied::new("u".into(), "1.2.3.4".into(), 1, 0)), + EventMessage::StreamProbeFailed(StreamProbeFailure::new( + "input".into(), + "1".into(), + "http://example.test/s".into(), + StreamProbeFailureReason::NotFound, + )), + auth_audit(AuthAuditOutcome::SignInSucceeded), + auth_audit(AuthAuditOutcome::SignInFailed), + auth_audit(AuthAuditOutcome::SignInThrottled), + auth_audit(AuthAuditOutcome::PermissionDenied), + ]; + assert_eq!(samples.len(), EventKind::ALL.len(), "add the new variant to this list"); + samples + .into_iter() + .map(|message| { + let kind = message.kind(); + (message, kind) + }) + .collect() + } + + #[test] + fn high_frequency_variants_are_not_notifiable() { + // Progress ticks and download deltas fire many times per operation; + // their terminal counterparts are what reaches a channel. Guarded by + // the exhaustive match in `to_notification`, so a new variant is a + // compile error rather than a silent firehose. + assert!(to_notification(&EventMessage::DownloadsDeltaUpdate(shared::model::TransfersDelta::ActiveCleared)) + .is_none()); + } + + #[test] + fn a_failed_playlist_update_maps_to_the_failure_event_at_error_severity() { + let event = to_notification(&EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + PlaylistUpdateState::Failure, + ))) + .expect("mapped"); + assert_eq!(event.id, registry::PLAYLIST_UPDATE_FAILED); + assert_eq!(event.severity, Severity::Error); + } + + #[test] + fn a_partial_playlist_update_is_a_warning_not_a_success() { + let event = to_notification(&EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + PlaylistUpdateState::Partial, + ))) + .expect("mapped"); + assert_eq!(event.id, registry::PLAYLIST_UPDATE_COMPLETED); + assert_eq!(event.severity, Severity::Warn, "a partial update must not read as a clean success"); + } + + #[test] + fn a_successful_playlist_update_is_informational() { + let event = to_notification(&EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + PlaylistUpdateState::Success, + ))) + .expect("mapped"); + assert_eq!(event.id, registry::PLAYLIST_UPDATE_COMPLETED); + assert_eq!(event.severity, Severity::Info); + } + + #[test] + fn config_changes_dedup_per_file_so_a_watcher_burst_is_one_notification() { + let event = to_notification(&EventMessage::ConfigChange(ConfigType::Mapping)).expect("mapped"); + assert_eq!(event.id, registry::CONFIG_CHANGED); + assert_eq!(event.dedup_key.as_deref(), Some("config:Mapping")); + } + + #[test] + fn different_config_files_do_not_dedup_against_each_other() { + let mapping = to_notification(&EventMessage::ConfigChange(ConfigType::Mapping)).expect("mapped"); + let sources = to_notification(&EventMessage::ConfigChange(ConfigType::Sources)).expect("mapped"); + assert_ne!(mapping.dedup_key, sources.dedup_key); + } + + #[test] + fn server_errors_map_to_the_error_event() { + let event = to_notification(&EventMessage::ServerError("bang\nsecond line".to_string())).expect("mapped"); + assert_eq!(event.id, registry::SYSTEM_ERROR); + assert_eq!(event.severity, Severity::Error); + assert_eq!(event.title, "bang", "title should be the first line only"); + assert!(event.body.contains("second line"), "body should keep everything"); + } + + #[test] + fn every_mapped_event_has_a_title_and_body() { + let messages = vec![ + EventMessage::ServerError("x".to_string()), + EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only(PlaylistUpdateState::Success)), + EventMessage::ConfigChange(ConfigType::Config), + EventMessage::InputMetadataUpdatesStarted("input-a".into()), + EventMessage::InputMetadataUpdatesCompleted("input-a".into()), + EventMessage::ActiveProvider("provider-a".into(), 3), + EventMessage::RecordingChanged, + EventMessage::RecordingRulesChanged, + ]; + for message in messages { + let event = to_notification(&message).expect("mapped"); + assert!(!event.title.trim().is_empty(), "empty title for {:?}", event.id); + assert!(!event.body.trim().is_empty(), "empty body for {:?}", event.id); + } + } + + #[test] + fn every_mapped_event_id_is_registered() { + // An unregistered id can never be matched by a `notify_on` pattern, + // so the bridge would emit into the void. + let messages = vec![ + EventMessage::ServerError("x".to_string()), + EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only(PlaylistUpdateState::Failure)), + EventMessage::ConfigChange(ConfigType::Config), + EventMessage::InputMetadataUpdatesStarted("a".into()), + EventMessage::InputMetadataUpdatesCompleted("a".into()), + EventMessage::ActiveProvider("a".into(), 1), + EventMessage::RecordingChanged, + EventMessage::RecordingRulesChanged, + ]; + for message in messages { + let event = to_notification(&message).expect("mapped"); + assert!(registry::describe(event.id).is_some(), "unregistered id {}", event.id); + } + } + + fn recording_lifecycle(event: MsgKind) -> EventMessage { + EventMessage::RecordingLifecycle(RecordingLifecycleMessage { + event, + programme_title: None, + channel: None, + effective_start: None, + effective_end: None, + visibility: None, + output_filename: None, + failure_reason: None, + }) + } + + fn user_lifecycle(state: UserLifecycleState) -> EventMessage { + EventMessage::UserLifecycle(UserLifecycleEvent::new("u".into(), "t".into(), state)) + } + + fn provider_account(state: ProviderAccountState) -> EventMessage { + EventMessage::ProviderAccount(ProviderAccountEvent { + state, + username: "u".to_string(), + provider: "p".to_string(), + status: None, + expires_at: None, + message: "m".to_string(), + }) + } +} diff --git a/backend/app/src/api/tasks/scheduler.rs b/backend/app/src/api/tasks/scheduler.rs index 51c766d25..675c07d9d 100644 --- a/backend/app/src/api/tasks/scheduler.rs +++ b/backend/app/src/api/tasks/scheduler.rs @@ -6,14 +6,14 @@ use crate::{ model::{AppConfig, ProcessTargets, ScheduleConfig}, processing::{ geoip::{update_geoip_db, GeoIpUpdateError}, - processor::exec_processing, + processor::{exec_processing, ProcessingRun}, }, utils::exit, }; use chrono::{DateTime, FixedOffset, Local}; use cron::Schedule; use shared::{ - model::ScheduleTaskType, + model::{EventMessage, ScheduleTaskType, ScheduledTaskFailure}, utils::{interner_gc, interner_len}, }; use std::{ @@ -184,26 +184,25 @@ async fn run_playlist_update_inner( // so that input/target ID changes from hot-reloads are picked up. let targets = get_process_targets(&app_state.app_config, &app_state.forced_targets.load(), schedule_target_names); exec_processing( - client, - Arc::clone(&app_state.app_config), - targets, - Some(Arc::clone(&app_state.event_manager)), - Some({ + ProcessingRun::new( + client.clone(), + Arc::clone(&app_state.app_config), + targets, + Arc::clone(&app_state.event_manager), + ) + .with_bootstrap({ let state = Arc::clone(app_state); - std::sync::Arc::new(move || { + move || { let state = Arc::clone(&state); - Box::pin(async move { crate::api::sync_panel_api_exp_dates(&state).await }) - as std::pin::Pin + Send>> - }) - }), - Some(app_state.playlists.clone()), - Some(app_state.update_guard.clone()), - app_state.get_disabled_headers(), - Some(Arc::clone(&app_state.active_provider)), - Some(Arc::clone(&app_state.metadata_manager) - as std::sync::Arc), - None, - Some(permit), + async move { crate::api::sync_panel_api_exp_dates(&state).await } + } + }) + .with_playlist_state(app_state.playlists.clone()) + .with_update_guard(app_state.update_guard.clone()) + .with_disabled_headers(app_state.get_disabled_headers()) + .with_provider_manager(Arc::clone(&app_state.active_provider)) + .with_metadata_manager(Arc::clone(&app_state.metadata_manager)) + .with_acquired_permit(permit), ) .await; } @@ -229,7 +228,7 @@ async fn start_scheduler( run_library_scan(&client, &app_state); } ScheduleTaskType::GeoIpUpdate => { - run_geoip_update(&app_state); + run_geoip_update(&app_state, expression); } } } @@ -267,13 +266,22 @@ fn run_library_scan(client: &reqwest::Client, app_state: &Arc) { } } -fn run_geoip_update(app_state: &Arc) { +fn run_geoip_update(app_state: &Arc, schedule: &str) { let app_state = Arc::clone(app_state); + let schedule = schedule.to_string(); tokio::spawn(async move { if let Err(err) = update_geoip_db(&app_state.app_config, &app_state.http_client.load(), &app_state.geoip).await { + // `Disabled` is not a failure - the task ran and found nothing to + // do, which is what the config asked for. if !matches!(err, GeoIpUpdateError::Disabled) { log::error!("Scheduled GeoIp update failed: {err}"); + // The playlist update and the library scan both report their + // own outcomes. This one had no terminal event of its own, so + // an operator running on a stale database never found out. + let _ = app_state.event_manager.send_event(EventMessage::ScheduledTaskFailed( + ScheduledTaskFailure::new(ScheduleTaskType::GeoIpUpdate, err.to_string()).with_schedule(schedule), + )); } } }); diff --git a/backend/app/src/main.rs b/backend/app/src/main.rs index 95685920d..70f512ed1 100644 --- a/backend/app/src/main.rs +++ b/backend/app/src/main.rs @@ -38,7 +38,7 @@ use crate::{ auth::generate_password, library::{LibraryProcessor, MediaToolProbes}, model::{AppConfig, Config, Healthcheck, HealthcheckConfig, ProcessTargets, SourcesConfig}, - processing::processor::exec_processing, + processing::processor::{exec_processing, ProcessingRun}, repository::{db_viewer, run_startup_migrations}, utils::{config_file_reader, init_logger, request::create_client, resolve_env_var}, }; @@ -64,7 +64,6 @@ use tuliprox_core::{model, utils}; use tuliprox_iptv as iptv; use tuliprox_library::library; use tuliprox_media_server as media_server; -use tuliprox_messaging as messaging; use tuliprox_mpegts as mpegts; use tuliprox_processing as processing; use tuliprox_repository as repository; @@ -166,7 +165,7 @@ impl Args { } } -const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); const BUILD_TIMESTAMP: &str = env!("VERGEN_BUILD_TIMESTAMP"); // #[cfg(not(target_env = "msvc"))] @@ -400,7 +399,7 @@ async fn start_in_cli_mode(cfg: Arc, targets: Arc) { reqwest::Client::new() }); // In CLI mode, we don't start background managers for events or providers - exec_processing(&client, cfg, targets, None, None, None, None, None, None, None, None, None).await; + exec_processing(ProcessingRun::new(client, cfg, targets, shared::model::NoopSink)).await; } async fn start_in_server_mode(cfg: Arc, targets: Arc) { diff --git a/backend/auth/Cargo.toml b/backend/auth/Cargo.toml index 7eb5fc265..c087ecb24 100644 --- a/backend/auth/Cargo.toml +++ b/backend/auth/Cargo.toml @@ -17,6 +17,7 @@ axum = { version = "0.8.9", features = ["macros", "default", "ws"] } base64.workspace = true blake3.workspace = true chrono.workspace = true +dashmap.workspace = true jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs"] } log.workspace = true rand = "0.9.5" diff --git a/backend/auth/src/access_token.rs b/backend/auth/src/access_token.rs index 8a82f6942..d27876f80 100644 --- a/backend/auth/src/access_token.rs +++ b/backend/auth/src/access_token.rs @@ -11,26 +11,51 @@ pub use tuliprox_core::utils::constant_time_eq; // sig: String, // } -pub fn create_access_token(secret: &[u8; 32], ttl_secs: u16) -> String { - create_access_token_at(secret, ttl_secs, Utc::now().timestamp()) +/// The capability an access token grants. +/// +/// The signed payload used to be `(timestamp, ttl)` and nothing else, so every +/// valid token was valid everywhere a token was accepted - one token minted for +/// any purpose opened all of them. The scope is mixed into the keyed hash, so a +/// token minted for one capability does not verify against another. +/// +/// A scope is a compile-time constant, never caller-supplied data: two sides of +/// the same handshake have to agree on the exact bytes or the signature fails, +/// so they must both name the same constant. +pub mod scope { + /// The internal web player chain: the webplayer and recording stream + /// entry points, the xtream handler they delegate to, and the + /// custom-video-stream fallback the stream layer redirects into. One + /// token travels that whole chain, so it is one scope. + pub const INTERNAL_PLAYER: &str = "internal-player"; } -fn create_access_token_at(secret: &[u8; 32], ttl_secs: u16, timestamp: i64) -> String { +/// Build the bytes that get signed. Mint and verify must agree exactly. +fn signing_payload(timestamp_bytes: &[u8], ttl_bytes: &[u8], scope: &str) -> Vec { + let scope_bytes = scope.as_bytes(); + let mut payload = Vec::with_capacity(timestamp_bytes.len() + ttl_bytes.len() + scope_bytes.len()); + payload.extend_from_slice(timestamp_bytes); + payload.extend_from_slice(ttl_bytes); + payload.extend_from_slice(scope_bytes); + payload +} + +pub fn create_access_token(secret: &[u8; 32], ttl_secs: u16, scope: &str) -> String { + create_access_token_at(secret, ttl_secs, scope, Utc::now().timestamp()) +} + +fn create_access_token_at(secret: &[u8; 32], ttl_secs: u16, scope: &str, timestamp: i64) -> String { let timestamp_bytes = timestamp.to_le_bytes(); let ttl_secs_bytes = ttl_secs.to_le_bytes(); - let mut payload = Vec::with_capacity(timestamp_bytes.len() + ttl_secs_bytes.len()); - payload.extend_from_slice(×tamp_bytes); - payload.extend_from_slice(&ttl_secs_bytes); - let hash = blake3::keyed_hash(secret, &payload); + let hash = blake3::keyed_hash(secret, &signing_payload(×tamp_bytes, &ttl_secs_bytes, scope)); let signature = hex_encode(hash.as_bytes()); format!("{}{}{signature}", hex_encode(×tamp_bytes), hex_encode(&ttl_secs_bytes)) } -pub fn verify_access_token(token_str: &str, secret: &[u8; 32]) -> bool { - verify_access_token_at(token_str, secret, Utc::now().timestamp()) +pub fn verify_access_token(token_str: &str, secret: &[u8; 32], scope: &str) -> bool { + verify_access_token_at(token_str, secret, scope, Utc::now().timestamp()) } -fn verify_access_token_at(token_str: &str, secret: &[u8; 32], current_timestamp: i64) -> bool { +fn verify_access_token_at(token_str: &str, secret: &[u8; 32], scope: &str, current_timestamp: i64) -> bool { const TOKEN_LEN: usize = 84; const TIMESTAMP_END: usize = 16; const TTL_END: usize = 20; @@ -60,10 +85,7 @@ fn verify_access_token_at(token_str: &str, secret: &[u8; 32], current_timestamp: let ttl_secs = u16::from_le_bytes(ttl_bytes.as_slice().try_into().unwrap_or([0; 2])); let signature = hex_decode(&token_str[TTL_END..]).unwrap_or_default(); - let mut payload = Vec::with_capacity(timestamp_bytes.len() + ttl_bytes.len()); - payload.extend_from_slice(×tamp_bytes); - payload.extend_from_slice(&ttl_bytes); - let expected = blake3::keyed_hash(secret, &payload); + let expected = blake3::keyed_hash(secret, &signing_payload(×tamp_bytes, &ttl_bytes, scope)); if !constant_time_eq(expected.as_bytes(), &signature) { return false; } @@ -76,35 +98,49 @@ fn verify_access_token_at(token_str: &str, secret: &[u8; 32], current_timestamp: #[cfg(test)] mod tests { - use crate::access_token::{create_access_token_at, verify_access_token, verify_access_token_at}; + use crate::access_token::{create_access_token_at, scope, verify_access_token, verify_access_token_at}; use std::panic::catch_unwind; + const SCOPE: &str = scope::INTERNAL_PLAYER; + #[test] fn test_valid_token() { let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; - let token = create_access_token_at(secret, 30, 1_700_000_000); - assert!(verify_access_token_at(token.as_str(), secret, 1_700_000_030)); - assert!(!verify_access_token_at(token.as_str(), secret, 1_700_000_031)); - assert_ne!(token, create_access_token_at(secret, 30, 1_700_000_001)); + let token = create_access_token_at(secret, 30, SCOPE, 1_700_000_000); + assert!(verify_access_token_at(token.as_str(), secret, SCOPE, 1_700_000_030)); + assert!(!verify_access_token_at(token.as_str(), secret, SCOPE, 1_700_000_031)); + assert_ne!(token, create_access_token_at(secret, 30, SCOPE, 1_700_000_001)); } #[test] fn test_expiry_check_handles_extreme_timestamps_without_overflow() { let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; - let token = create_access_token_at(secret, 30, i64::MIN); + let token = create_access_token_at(secret, 30, SCOPE, i64::MIN); - assert!(!verify_access_token_at(&token, secret, i64::MAX)); + assert!(!verify_access_token_at(&token, secret, SCOPE, i64::MAX)); } #[test] fn test_ttl_tampering_invalidates_token() { let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; - let token = create_access_token_at(secret, 1, 1_700_000_000); + let token = create_access_token_at(secret, 1, SCOPE, 1_700_000_000); let mut tampered = token.clone(); tampered.replace_range(16..20, "ffff"); - assert!(!verify_access_token(tampered.as_str(), secret)); + assert!(!verify_access_token(tampered.as_str(), secret, SCOPE)); + } + + #[test] + fn test_token_does_not_verify_under_a_different_scope() { + // The point of the scope: a token minted for one capability must not + // open another. Before this, every valid token was valid everywhere. + let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; + let token = create_access_token_at(secret, 30, SCOPE, 1_700_000_000); + + assert!(verify_access_token_at(&token, secret, SCOPE, 1_700_000_010)); + assert!(!verify_access_token_at(&token, secret, "some-other-capability", 1_700_000_010)); + assert!(!verify_access_token_at(&token, secret, "", 1_700_000_010)); } #[test] @@ -112,7 +148,7 @@ mod tests { let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; let invalid = "é".repeat(84); - let result = catch_unwind(|| verify_access_token(&invalid, secret)); + let result = catch_unwind(|| verify_access_token(&invalid, secret, SCOPE)); assert!(result.is_ok(), "verification should not panic on non-ascii input"); assert!(!result.unwrap_or(true)); diff --git a/backend/auth/src/auth_basic.rs b/backend/auth/src/auth_basic.rs index dc43bf576..ba3ddff2b 100644 --- a/backend/auth/src/auth_basic.rs +++ b/backend/auth/src/auth_basic.rs @@ -1,9 +1,10 @@ use crate::Rejection; use axum::{ extract::FromRequestParts, - http::{request::Parts, StatusCode}, + http::{header::AUTHORIZATION, request::Parts}, }; use base64::{engine::general_purpose, Engine}; +use tuliprox_core::model::{AuthRejection, AuthScheme}; #[derive(Debug, PartialEq, Eq, Clone)] pub struct AuthBasic(pub (String, String)); @@ -28,18 +29,16 @@ impl AuthBasic { fn decode_request_parts(req: &mut Parts) -> Result { let authorization = req .headers - .get(axum::http::header::AUTHORIZATION) - .ok_or((StatusCode::FORBIDDEN, "Authorization header is missing"))? + .get(AUTHORIZATION) + .ok_or(AuthRejection::MissingHeader(AuthScheme::Basic))? .to_str() - .map_err(|_| (StatusCode::FORBIDDEN, "Authorization header contains invalid characters"))?; + .map_err(|_| AuthRejection::MalformedHeader(AuthScheme::Basic))?; - let split = authorization.split_once(' '); - match split { + match authorization.split_once(' ') { Some((scheme, contents)) if scheme.eq_ignore_ascii_case("Basic") => { - let decoded = decode(contents)?; - Ok(Self::from_header(decoded)) + Ok(Self::from_header(decode(contents)?)) } - _ => Err((StatusCode::FORBIDDEN, "`Authorization` header must be a basic auth")), + _ => Err(AuthRejection::WrongScheme(AuthScheme::Basic)), } } } @@ -47,16 +46,13 @@ impl AuthBasic { /// Decodes the two parts of basic auth using the colon fn decode(input: &str) -> Result<(String, String), Rejection> { // Decode from base64 into a string - let decoded = general_purpose::STANDARD - .decode(input) - .map_err(|_| (StatusCode::FORBIDDEN, "Authorization header contains invalid characters"))?; - let decoded = String::from_utf8(decoded) - .map_err(|_| (StatusCode::FORBIDDEN, "Authorization header contains invalid characters"))?; + let decoded = + general_purpose::STANDARD.decode(input).map_err(|_| AuthRejection::MalformedHeader(AuthScheme::Basic))?; + let decoded = String::from_utf8(decoded).map_err(|_| AuthRejection::MalformedHeader(AuthScheme::Basic))?; // Return depending on if password is present - if let Some((username, password)) = decoded.split_once(':') { - Ok((username.trim().to_string(), password.trim().to_string())) - } else { - Err((StatusCode::FORBIDDEN, "Authorization header contains no password")) - } + decoded + .split_once(':') + .map(|(username, password)| (username.trim().to_string(), password.trim().to_string())) + .ok_or(AuthRejection::MissingBasicPassword) } diff --git a/backend/auth/src/auth_bearer.rs b/backend/auth/src/auth_bearer.rs index c9b472845..72c1d72c2 100644 --- a/backend/auth/src/auth_bearer.rs +++ b/backend/auth/src/auth_bearer.rs @@ -1,8 +1,9 @@ use crate::Rejection; use axum::{ extract::FromRequestParts, - http::{request::Parts, HeaderMap, StatusCode}, + http::{header::AUTHORIZATION, request::Parts, HeaderMap}, }; +use tuliprox_core::model::{AuthRejection, AuthScheme}; #[derive(Debug, PartialEq, Eq, Clone)] pub struct AuthBearer(pub String); @@ -26,15 +27,14 @@ impl AuthBearer { pub fn from_headers(headers: &HeaderMap) -> Result { let authorization = headers - .get(axum::http::header::AUTHORIZATION) - .ok_or((StatusCode::FORBIDDEN, "Authorization header is missing"))? + .get(AUTHORIZATION) + .ok_or(AuthRejection::MissingHeader(AuthScheme::Bearer))? .to_str() - .map_err(|_| (StatusCode::FORBIDDEN, "Authorization header contains invalid characters"))?; + .map_err(|_| AuthRejection::MalformedHeader(AuthScheme::Bearer))?; - let split = authorization.split_once(' '); - match split { + match authorization.split_once(' ') { Some((scheme, contents)) if scheme.eq_ignore_ascii_case("bearer") => Ok(Self::from_header(contents)), - _ => Err((StatusCode::FORBIDDEN, "`Authorization` header must be a bearer token")), + _ => Err(AuthRejection::WrongScheme(AuthScheme::Bearer)), } } diff --git a/backend/auth/src/authenticator.rs b/backend/auth/src/authenticator.rs index 1d424d730..4dea3ad49 100644 --- a/backend/auth/src/authenticator.rs +++ b/backend/auth/src/authenticator.rs @@ -1,66 +1,81 @@ use chrono::{Duration, Local}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation}; +use log::warn; use shared::{ error::to_io_error, model::{ permission::{PermissionSet, PERM_ALL}, - Claims, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, ROLE_ADMIN, ROLE_API_USER, + Claims, Role, RoleSet, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, }, }; use tuliprox_core::model::WebAuthConfig; +/// Hard ceiling on a minted token's lifetime. +/// +/// A token this server issues is a bearer credential with no revocation list +/// behind it, so its lifetime is the entire blast radius of a leak. +pub const MAX_TOKEN_TTL_MINS: u32 = 60 * 24 * 30; // 30 days + +/// Lifetime used when the config asks for an unbounded token. +/// +/// `token_ttl_mins: 0` used to mean "expire in 100 years", which is a +/// permanent credential written as if it were a configuration convenience. +pub const DEFAULT_TOKEN_TTL_MINS: u32 = 60 * 24; // 24 hours + +/// Clamp a configured TTL into something a bearer token may actually carry. +fn effective_ttl_mins(configured: u32) -> u32 { + if configured == 0 { + warn!( + "web_ui.auth.token_ttl_mins is 0; issuing {DEFAULT_TOKEN_TTL_MINS}-minute tokens instead of \ + non-expiring ones. Set an explicit value to silence this." + ); + DEFAULT_TOKEN_TTL_MINS + } else if configured > MAX_TOKEN_TTL_MINS { + warn!("web_ui.auth.token_ttl_mins of {configured} exceeds the {MAX_TOKEN_TTL_MINS} minute ceiling; clamped."); + MAX_TOKEN_TTL_MINS + } else { + configured + } +} + pub fn create_jwt_admin( web_auth_config: &WebAuthConfig, username: &str, pwd_version: u32, ) -> Result { - create_jwt( - web_auth_config, - username, - vec![ROLE_ADMIN.to_string()], - PERM_ALL, - pwd_version, - Some(UserId::builtin_admin()), - ) + create_jwt(web_auth_config, username, RoleSet::ADMIN, PERM_ALL, pwd_version, Some(UserId::builtin_admin())) } -pub fn create_jwt_api_user(web_auth_config: &WebAuthConfig, username: &str) -> Result { - create_jwt( - web_auth_config, - username, - vec![ROLE_API_USER.to_string()], - PermissionSet::new(), - 0, - // The identity registry will eventually provide the API - // user's stable `UserId`. Until then, the username-as-id - // fallback is the only stable choice. - Some(UserId::from(format!("api:{username}"))), - ) +/// `subject_id` comes from the identity registry. +/// +/// It used to be `format!("api:{username}")`, which made the subject a +/// function of the display name: renaming a user reassigned every recording +/// they owned to a principal that did not exist, and two deployments that +/// happened to share a username shared an identity. +pub fn create_jwt_api_user( + web_auth_config: &WebAuthConfig, + username: &str, + subject_id: UserId, +) -> Result { + create_jwt(web_auth_config, username, RoleSet::API_USER, PermissionSet::new(), 0, Some(subject_id)) } +/// `subject_id` comes from the identity registry. See +/// [`create_jwt_api_user`] for why it is no longer derived from the username. pub fn create_jwt_web_user( web_auth_config: &WebAuthConfig, username: &str, permissions: PermissionSet, pwd_version: u32, + subject_id: UserId, ) -> Result { - create_jwt( - web_auth_config, - username, - Vec::new(), - permissions, - pwd_version, - // The identity registry will eventually provide the web - // user's stable `UserId`. Until then, the username-as-id - // fallback is the only stable choice. - Some(UserId::from(format!("web:{username}"))), - ) + create_jwt(web_auth_config, username, RoleSet::new(), permissions, pwd_version, Some(subject_id)) } fn create_jwt( web_auth_config: &WebAuthConfig, username: &str, - roles: Vec, + roles: RoleSet, permissions: PermissionSet, pwd_version: u32, subject_id: Option, @@ -69,12 +84,7 @@ fn create_jwt( header.typ = Some("JWT".to_string()); let now = Local::now(); let iat = now.timestamp(); - let duration = web_auth_config.token_ttl_mins; - let exp = if duration > 0 { - (now + Duration::minutes(i64::from(duration))).timestamp() - } else { - (now + Duration::days(365 * 100)).timestamp() // 100 years - }; + let exp = (now + Duration::minutes(i64::from(effective_ttl_mins(web_auth_config.token_ttl_mins)))).timestamp(); let claims = Claims { username: username.to_string(), iss: web_auth_config.issuer.clone(), @@ -92,34 +102,45 @@ fn create_jwt( } } -pub fn verify_token(token: &str, secret_key: &[u8]) -> Option> { - if let Ok(token_data) = - decode::(token, &DecodingKey::from_secret(secret_key), &Validation::new(Algorithm::HS256)) - { - return Some(token_data); +/// Verify a token's signature, expiry **and issuer**. +/// +/// `iss` was written into every token and then never looked at: +/// `Validation::new` checks `exp` and nothing else. A token minted by a +/// different deployment that happened to share the secret verified cleanly. +pub fn verify_token(token: &str, secret_key: &[u8], issuer: &str) -> Option> { + let mut validation = Validation::new(Algorithm::HS256); + validation.set_issuer(&[issuer]); + decode::(token, &DecodingKey::from_secret(secret_key), &validation).ok() +} + +/// The material needed to verify a token, for call sites that hold it across +/// an await or a task boundary and so cannot borrow the live config. +/// +/// The WebSocket paths used to carry a bare `Vec` secret, which is exactly +/// why they could not check the issuer. +#[derive(Debug, Clone)] +pub struct TokenVerifier { + secret: Vec, + issuer: String, +} + +impl TokenVerifier { + pub fn new(secret: impl Into>, issuer: impl Into) -> Self { + Self { secret: secret.into(), issuer: issuer.into() } } - None + + pub fn from_config(config: &WebAuthConfig) -> Self { Self::new(config.secret.as_bytes(), config.issuer.clone()) } + + pub fn verify(&self, token: &str) -> Option> { verify_token(token, &self.secret, &self.issuer) } } -fn has_role(token_data: Option>, role: &str) -> bool { - if let Some(data) = token_data { - data.claims.roles.contains(&role.to_string()) - } else { - false - } +fn has_role(token_data: Option>, role: Role) -> bool { + token_data.is_some_and(|data| data.claims.roles.contains(role)) } -pub fn is_admin(token_data: Option>) -> bool { has_role(token_data, ROLE_ADMIN) } +pub fn is_admin(token_data: Option>) -> bool { has_role(token_data, Role::Admin) } -pub fn is_api_user(token_data: Option>) -> bool { has_role(token_data, ROLE_API_USER) } - -pub fn verify_token_admin(bearer: &str, secret_key: &[u8]) -> bool { - has_role(verify_token(bearer, secret_key), ROLE_ADMIN) -} - -pub fn verify_token_api_user(bearer: &str, secret_key: &[u8]) -> bool { - has_role(verify_token(bearer, secret_key), ROLE_API_USER) -} +pub fn is_api_user(token_data: Option>) -> bool { has_role(token_data, Role::ApiUser) } /// Stable error type for the validators. A stable /// "token-refresh-required" response lets the frontend sign out @@ -137,6 +158,14 @@ pub enum AuthError { /// Token signature is valid but it lacks a `subject_id`. The /// identity-registry-bound principal cannot be resolved. MissingSubject, + /// Token signature is valid but it was minted against a password that has + /// since changed - or it carries no password version at all. Either way + /// the principal must authenticate again; a refresh cannot help, because + /// the refresh endpoint applies the same check. + PasswordChanged, + /// Token signature is valid but it was issued before a revocation that + /// covers it - the principal was signed out, or every session was. + Revoked, /// Token signature is valid but the principal has the wrong /// role/permission for the requested endpoint. Forbidden, @@ -155,6 +184,8 @@ impl std::fmt::Display for AuthError { Self::InvalidToken => f.write_str("token is invalid or expired"), Self::StaleSchema => f.write_str("token was issued for an older permission schema; refresh required"), Self::MissingSubject => f.write_str("token is missing a subject_id; refresh required"), + Self::PasswordChanged => f.write_str("token was issued for a different password; sign in again"), + Self::Revoked => f.write_str("token has been revoked; sign in again"), Self::Forbidden => f.write_str("principal does not have the required role"), } } @@ -176,12 +207,31 @@ pub fn validate_token_claims(claims: &Claims) -> Result<(), AuthError> { Ok(()) } +/// Validate a token's `pwd_version` against the principal's current one. +/// +/// `pwd_version` was minted into every web token and then checked in exactly +/// one place - the refresh endpoint - which meant changing a password did not +/// invalidate any live token on any guarded route. Combined with the old +/// "0 means non-expiring" TTL, a leaked token was a permanent credential. +/// +/// A `pwd_version` of `0` is rejected rather than waved through. The refresh +/// endpoint used to treat `0` as "skip the check", which made the check +/// bypassable by anything that could mint or replay a zero-versioned token. +/// +/// Call this only for principals that actually have a password on file - the +/// web users in `web_ui.auth`. Proxy API users authenticate against +/// `api_proxy.yml` credentials and carry no password version. +pub fn validate_password_version(claims: &Claims, current_pwd_version: u32) -> Result<(), AuthError> { + if claims.pwd_version == 0 || claims.pwd_version != current_pwd_version { + return Err(AuthError::PasswordChanged); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use shared::model::{ - permission::Permission, Claims, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, ROLE_ADMIN, ROLE_API_USER, - }; + use shared::model::{permission::Permission, Claims, Role, RoleSet, UserId, CURRENT_PERMISSION_SCHEMA_VERSION}; use tuliprox_core::model::WebAuthConfig; fn test_web_auth_config() -> WebAuthConfig { @@ -202,10 +252,10 @@ mod tests { let cfg = test_web_auth_config(); let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt"); let secret = cfg.secret.as_bytes(); - let data = verify_token(&jwt, secret).expect("verify"); + let data = verify_token(&jwt, secret, &cfg.issuer).expect("verify"); assert_eq!(data.claims.username, "any"); assert_eq!(data.claims.subject_id, Some(UserId::builtin_admin())); - assert!(data.claims.roles.contains(&ROLE_ADMIN.to_string())); + assert!(data.claims.is_admin()); assert!(data.claims.permissions.contains(Permission::ConfigRead)); assert!(data.claims.permissions.contains(Permission::RecordingRead)); assert!(data.claims.permissions.contains(Permission::RecordingWrite)); @@ -215,12 +265,18 @@ mod tests { #[test] fn web_user_jwt_carries_web_namespaced_subject_id() { let cfg = test_web_auth_config(); - let jwt = - create_jwt_web_user(&cfg, "alice", Permission::ConfigRead | Permission::RecordingRead, 0).expect("web jwt"); - let data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + let jwt = create_jwt_web_user( + &cfg, + "alice", + Permission::ConfigRead | Permission::RecordingRead, + 0, + UserId::from("web:alice-uuid"), + ) + .expect("web jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); assert_eq!(data.claims.username, "alice"); - assert_eq!(data.claims.subject_id, Some(UserId::from("web:alice"))); - assert!(!data.claims.roles.contains(&ROLE_ADMIN.to_string())); + assert_eq!(data.claims.subject_id, Some(UserId::from("web:alice-uuid"))); + assert!(!data.claims.is_admin()); assert!(data.claims.permissions.contains(Permission::RecordingRead)); assert!(!data.claims.permissions.contains(Permission::RecordingWrite)); } @@ -228,10 +284,10 @@ mod tests { #[test] fn api_user_jwt_carries_api_namespaced_subject_id() { let cfg = test_web_auth_config(); - let jwt = create_jwt_api_user(&cfg, "bob").expect("api jwt"); - let data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); - assert_eq!(data.claims.subject_id, Some(UserId::from("api:bob"))); - assert!(data.claims.roles.contains(&ROLE_API_USER.to_string())); + let jwt = create_jwt_api_user(&cfg, "bob", UserId::from("api:bob-uuid")).expect("api jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); + assert_eq!(data.claims.subject_id, Some(UserId::from("api:bob-uuid"))); + assert!(data.claims.is_api_user()); assert!(data.claims.permissions.is_empty()); } @@ -242,7 +298,7 @@ mod tests { // refresh-required. let cfg = test_web_auth_config(); let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt"); - let mut data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + let mut data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); data.claims.permission_schema_version = 0; // stale let err = validate_token_claims(&data.claims).unwrap_err(); assert!(matches!(err, AuthError::StaleSchema)); @@ -256,7 +312,7 @@ mod tests { // refresh-required. let cfg = test_web_auth_config(); let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt"); - let mut data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + let mut data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); data.claims.subject_id = None; let err = validate_token_claims(&data.claims).unwrap_err(); assert!(matches!(err, AuthError::MissingSubject)); @@ -271,7 +327,7 @@ mod tests { // Flip a character in the signature segment. let last = tampered.pop().unwrap(); tampered.push(if last == 'A' { 'B' } else { 'A' }); - assert!(verify_token(&tampered, cfg.secret.as_bytes()).is_none()); + assert!(verify_token(&tampered, cfg.secret.as_bytes(), &cfg.issuer).is_none()); } #[test] @@ -283,18 +339,70 @@ mod tests { // permission set. let cfg = test_web_auth_config(); let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt"); - let data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); let json = serde_json::to_string(&data.claims).expect("serialize"); assert!(!json.contains("owner_id"), "JWT must not carry an owner_id field; got: {json}"); assert!(json.contains("subject_id"), "JWT must carry subject_id: {json}"); } + #[test] + fn token_from_another_issuer_does_not_verify() { + // `iss` was minted and never checked. It is checked now. + let cfg = test_web_auth_config(); + let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt"); + assert!(verify_token(&jwt, cfg.secret.as_bytes(), "tuliprox-test").is_some()); + assert!(verify_token(&jwt, cfg.secret.as_bytes(), "someone-else").is_none()); + } + + #[test] + fn zero_ttl_config_no_longer_mints_a_century_long_token() { + let mut cfg = test_web_auth_config(); + cfg.token_ttl_mins = 0; + let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); + let lifetime_mins = (data.claims.exp - data.claims.iat) / 60; + assert_eq!(lifetime_mins, i64::from(DEFAULT_TOKEN_TTL_MINS)); + } + + #[test] + fn oversized_ttl_is_clamped_to_the_ceiling() { + let mut cfg = test_web_auth_config(); + cfg.token_ttl_mins = MAX_TOKEN_TTL_MINS * 10; + let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); + let lifetime_mins = (data.claims.exp - data.claims.iat) / 60; + assert_eq!(lifetime_mins, i64::from(MAX_TOKEN_TTL_MINS)); + } + + #[test] + fn password_version_mismatch_is_rejected() { + let cfg = test_web_auth_config(); + let jwt = create_jwt_admin(&cfg, "any", 7).expect("admin jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); + assert!(validate_password_version(&data.claims, 7).is_ok()); + assert_eq!(validate_password_version(&data.claims, 8), Err(AuthError::PasswordChanged)); + } + + #[test] + fn zero_password_version_is_rejected_rather_than_waved_through() { + // The refresh endpoint used to read `pwd_version == 0` as "skip the + // check", which made the check bypassable. + let cfg = test_web_auth_config(); + let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify"); + assert_eq!(validate_password_version(&data.claims, 0), Err(AuthError::PasswordChanged)); + assert_eq!(validate_password_version(&data.claims, 5), Err(AuthError::PasswordChanged)); + } + #[test] fn is_token_refresh_required_classifier() { assert!(AuthError::StaleSchema.is_token_refresh_required()); assert!(AuthError::MissingSubject.is_token_refresh_required()); assert!(!AuthError::InvalidToken.is_token_refresh_required()); assert!(!AuthError::Forbidden.is_token_refresh_required()); + // A changed password cannot be fixed by a refresh - the refresh + // endpoint applies the same check. The client must sign in again. + assert!(!AuthError::PasswordChanged.is_token_refresh_required()); } #[test] @@ -307,7 +415,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 100, exp: 200, - roles: vec!["user".to_string()], + roles: RoleSet::from(Role::Admin), permissions: Permission::ConfigRead.into(), pwd_version: 7, subject_id: Some(UserId::from("web:alice")), diff --git a/backend/auth/src/lib.rs b/backend/auth/src/lib.rs index 95a83e65a..f62b94b99 100644 --- a/backend/auth/src/lib.rs +++ b/backend/auth/src/lib.rs @@ -1,19 +1,23 @@ -use axum::http::StatusCode; - mod access_token; mod api_user_context; mod auth_basic; mod auth_bearer; mod authenticator; +mod login_throttle; // Client identity moved to `tuliprox-core`: the connection layer keys on it // too. Re-exported so auth call sites keep their name. pub use tuliprox_core::model::Fingerprint; mod password; mod recording_auth; -type Rejection = (StatusCode, &'static str); +// The single rejection type for every authentication extractor. It lives in +// `tuliprox-core` because `Fingerprint` - which is an extractor too - lives +// there and used to declare its own identical copy. +pub use tuliprox_core::model::{AuthRejection, AuthScheme}; + +type Rejection = AuthRejection; pub use self::{ - access_token::*, api_user_context::*, auth_basic::*, auth_bearer::*, authenticator::*, password::*, - recording_auth::*, + access_token::*, api_user_context::*, auth_basic::*, auth_bearer::*, authenticator::*, login_throttle::*, + password::*, recording_auth::*, }; diff --git a/backend/auth/src/login_throttle.rs b/backend/auth/src/login_throttle.rs new file mode 100644 index 000000000..5237bb758 --- /dev/null +++ b/backend/auth/src/login_throttle.rs @@ -0,0 +1,210 @@ +//! Backoff for repeated failed sign-ins. +//! +//! `/auth/token` verifies an argon2 hash and answers 401. Nothing counted how +//! often that happened, so an attacker could work a password list against it as +//! fast as the hash function allows, for as long as they liked. The +//! reverse-proxy rate limiter is opt-in, disabled by default, and applies a +//! single blanket budget to every route - it is not a credential-stuffing +//! control. +//! +//! Two dimensions, because either alone is defeatable: +//! +//! * **by client address** - stops one host grinding through a password list. +//! An attacker with a pool of addresses gets around it. +//! * **by username** - stops a distributed attack converging on one account. +//! On its own it is a denial-of-service lever: anyone who knows a username +//! can lock its owner out. So the username block is time-bounded and short, +//! and a correct password clears it immediately. +//! +//! The address dimension is only as trustworthy as the address, which this +//! server currently takes from `X-Forwarded-For` without a trusted-proxy +//! allowlist. Until that is fixed, treat the username dimension as the one +//! doing the work. + +use dashmap::DashMap; +use std::time::{Duration, Instant}; + +/// Failures tolerated before backoff starts. +const FREE_ATTEMPTS: u32 = 3; + +/// The first backoff, doubling per failure beyond [`FREE_ATTEMPTS`]. +const BASE_BACKOFF: Duration = Duration::from_secs(2); + +/// Ceiling on a single backoff. +const MAX_BACKOFF: Duration = Duration::from_mins(15); + +/// Entries idle for this long are dropped by the sweep. +const IDLE_EVICTION: Duration = Duration::from_hours(1); + +/// Sweep once the map passes this many entries. +const SWEEP_THRESHOLD: usize = 4096; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum Dimension { + Address, + Username, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct Key(Dimension, String); + +#[derive(Debug)] +struct Attempts { + consecutive_failures: u32, + blocked_until: Option, + last_seen: Instant, +} + +/// Per-address and per-username sign-in backoff. +#[derive(Debug, Default)] +pub struct LoginThrottle { + entries: DashMap, +} + +impl LoginThrottle { + pub fn new() -> Self { Self::default() } + + /// How long the caller must wait, or `None` if it may attempt now. + /// + /// Both dimensions are consulted and the longer wait wins. + pub fn retry_after(&self, username: &str, client_ip: &str) -> Option { + self.retry_after_at(username, client_ip, Instant::now()) + } + + fn retry_after_at(&self, username: &str, client_ip: &str, now: Instant) -> Option { + [Key(Dimension::Address, client_ip.to_string()), Key(Dimension::Username, canonical(username))] + .into_iter() + .filter_map(|key| self.entries.get(&key).and_then(|entry| entry.blocked_until).filter(|until| *until > now)) + .map(|until| until.duration_since(now)) + .max() + } + + /// Record a rejected attempt and extend the backoff on both dimensions. + pub fn record_failure(&self, username: &str, client_ip: &str) { + self.record_failure_at(username, client_ip, Instant::now()); + } + + fn record_failure_at(&self, username: &str, client_ip: &str, now: Instant) { + for key in [Key(Dimension::Address, client_ip.to_string()), Key(Dimension::Username, canonical(username))] { + let mut entry = self.entries.entry(key).or_insert(Attempts { + consecutive_failures: 0, + blocked_until: None, + last_seen: now, + }); + entry.consecutive_failures = entry.consecutive_failures.saturating_add(1); + entry.last_seen = now; + entry.blocked_until = backoff_for(entry.consecutive_failures).map(|wait| now + wait); + } + self.sweep(now); + } + + /// Clear both dimensions. A correct password releases a username block + /// immediately, which is what keeps the username dimension from being a + /// lockout lever. + pub fn record_success(&self, username: &str, client_ip: &str) { + self.entries.remove(&Key(Dimension::Address, client_ip.to_string())); + self.entries.remove(&Key(Dimension::Username, canonical(username))); + } + + /// Drop idle entries so a long grind cannot grow the map without bound. + fn sweep(&self, now: Instant) { + if self.entries.len() <= SWEEP_THRESHOLD { + return; + } + self.entries.retain(|_, entry| now.duration_since(entry.last_seen) < IDLE_EVICTION); + } +} + +/// The backoff after `failures` consecutive rejections, or `None` while the +/// caller is still inside the free allowance. +fn backoff_for(failures: u32) -> Option { + let over = failures.checked_sub(FREE_ATTEMPTS)?; + if over == 0 { + return None; + } + // `over - 1` doublings: the first backoff past the allowance is BASE. + let doublings = (over - 1).min(u32::BITS - 1); + Some(BASE_BACKOFF.saturating_mul(1u32 << doublings).min(MAX_BACKOFF)) +} + +/// Usernames are matched case-insensitively everywhere else in the auth path, +/// so the throttle must match too - otherwise `Alice` and `alice` get separate +/// budgets against the same account. +fn canonical(username: &str) -> String { username.trim().to_ascii_lowercase() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_free_allowance_is_not_throttled() { + let throttle = LoginThrottle::new(); + let now = Instant::now(); + for _ in 0..FREE_ATTEMPTS { + throttle.record_failure_at("alice", "10.0.0.1", now); + } + assert!(throttle.retry_after_at("alice", "10.0.0.1", now).is_none()); + } + + #[test] + fn backoff_starts_after_the_allowance_and_doubles() { + assert_eq!(backoff_for(FREE_ATTEMPTS), None); + assert_eq!(backoff_for(FREE_ATTEMPTS + 1), Some(BASE_BACKOFF)); + assert_eq!(backoff_for(FREE_ATTEMPTS + 2), Some(BASE_BACKOFF * 2)); + assert_eq!(backoff_for(FREE_ATTEMPTS + 3), Some(BASE_BACKOFF * 4)); + } + + #[test] + fn backoff_is_capped() { + assert_eq!(backoff_for(u32::MAX), Some(MAX_BACKOFF)); + assert_eq!(backoff_for(FREE_ATTEMPTS + 40), Some(MAX_BACKOFF)); + } + + #[test] + fn a_blocked_caller_is_told_how_long_to_wait() { + let throttle = LoginThrottle::new(); + let now = Instant::now(); + for _ in 0..=FREE_ATTEMPTS { + throttle.record_failure_at("alice", "10.0.0.1", now); + } + assert_eq!(throttle.retry_after_at("alice", "10.0.0.1", now), Some(BASE_BACKOFF)); + // ...and is free again once it has elapsed. + assert!(throttle.retry_after_at("alice", "10.0.0.1", now + BASE_BACKOFF).is_none()); + } + + #[test] + fn the_username_dimension_follows_the_account_across_addresses() { + let throttle = LoginThrottle::new(); + let now = Instant::now(); + // Each address stays inside its own allowance; the account does not. + for (index, address) in ["10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.4"].into_iter().enumerate() { + let _ = index; + throttle.record_failure_at("alice", address, now); + } + assert_eq!(throttle.retry_after_at("alice", "10.0.0.99", now), Some(BASE_BACKOFF)); + // A different account from a fresh address is unaffected. + assert!(throttle.retry_after_at("bob", "10.0.0.99", now).is_none()); + } + + #[test] + fn a_correct_password_clears_the_block() { + let throttle = LoginThrottle::new(); + let now = Instant::now(); + for _ in 0..=FREE_ATTEMPTS { + throttle.record_failure_at("alice", "10.0.0.1", now); + } + assert!(throttle.retry_after_at("alice", "10.0.0.1", now).is_some()); + throttle.record_success("alice", "10.0.0.1"); + assert!(throttle.retry_after_at("alice", "10.0.0.1", now).is_none()); + } + + #[test] + fn usernames_are_matched_case_insensitively() { + let throttle = LoginThrottle::new(); + let now = Instant::now(); + for _ in 0..=FREE_ATTEMPTS { + throttle.record_failure_at("Alice", "10.0.0.1", now); + } + assert!(throttle.retry_after_at("alice", "10.0.0.99", now).is_some()); + } +} diff --git a/backend/auth/src/password.rs b/backend/auth/src/password.rs index 2c31f7eb7..3356b555c 100644 --- a/backend/auth/src/password.rs +++ b/backend/auth/src/password.rs @@ -1,5 +1,6 @@ use rand::{distr::Alphanumeric, Rng}; use shared::error::str_to_io_error; +use zeroize::Zeroize; fn generate_salt(length: usize) -> String { let salt: String = rand::rng().sample_iter(&Alphanumeric).take(length).map(char::from).collect(); @@ -31,18 +32,18 @@ pub fn generate_password_from_input(password: &str) -> std::io::Result { hash(password.as_bytes()).map_or_else(|| Err(str_to_io_error("Failed to generate hash")), Ok) } +/// Prompt twice and hash. Both plaintexts are wiped before returning. +/// +/// `UserCredential::zeroize` already applies this discipline to a password +/// that arrives over HTTP; a password typed at the terminal deserves the same +/// treatment, and used to be left sitting in two `String`s until their +/// allocations happened to be reused. pub fn generate_password() -> std::io::Result { - match rpassword::prompt_password("password> ") { - Ok(pwd1) => match rpassword::prompt_password("retype password> ") { - Ok(pwd2) => { - if pwd1.eq(&pwd2) { - generate_password_from_input(&pwd1) - } else { - Err(str_to_io_error("Passwords don't match")) - } - } - Err(err) => Err(err), - }, - Err(err) => Err(err), - } + let mut pwd1 = rpassword::prompt_password("password> ")?; + let mut pwd2 = rpassword::prompt_password("retype password> ")?; + let result = + if pwd1 == pwd2 { generate_password_from_input(&pwd1) } else { Err(str_to_io_error("Passwords don't match")) }; + pwd1.zeroize(); + pwd2.zeroize(); + result } diff --git a/backend/auth/src/recording_auth.rs b/backend/auth/src/recording_auth.rs index 044e08bb1..2df195bcd 100644 --- a/backend/auth/src/recording_auth.rs +++ b/backend/auth/src/recording_auth.rs @@ -20,7 +20,7 @@ use shared::model::{ permission::Permission, recording::{RecordingMetadata, RecordingVisibility}, - Claims, UserId, ROLE_ADMIN, + Claims, UserId, }; /// Actions the recording system distinguishes. Each action maps to one @@ -175,8 +175,6 @@ impl RecordingDecision { pub fn is_allow(&self) -> bool { matches!(self, Self::Allow) } } -fn is_admin(claims: &Claims) -> bool { claims.roles.iter().any(|r| r == ROLE_ADMIN) } - /// The `username` of the synthetic `Claims` the retention worker /// builds to act on shared / orphan / legacy-owned recordings. /// @@ -219,7 +217,7 @@ fn check_create(claims: &Claims, action: RecordingAction) -> RecordingDecision { if !has_recording_write(claims) { return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); } - if !is_admin(claims) { + if !claims.is_admin() { return RecordingDecision::Deny(DenyReason::NotAdministrator); } RecordingDecision::Allow @@ -276,7 +274,7 @@ pub fn authorize( if !has_recording_write(claims) { return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); } - if !is_admin(claims) { + if !claims.is_admin() { // The system-retention path bypasses user ownership only // for eligible completed recording deletion. Operators // without the @@ -300,7 +298,7 @@ pub fn authorize( if !has_recording_write(claims) { return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); } - if !is_admin(claims) { + if !claims.is_admin() { return RecordingDecision::Deny(DenyReason::NotAdministrator); } return RecordingDecision::Allow; @@ -318,7 +316,7 @@ pub fn authorize( let is_legacy = matches!(meta.owner, shared::model::RecordingOwner::LegacyAdmin); // LegacyAdmin recordings are only accessible to administrators. - if is_legacy && !is_admin(claims) { + if is_legacy && !claims.is_admin() { return RecordingDecision::Deny(DenyReason::LegacyAdminReserved); } if is_legacy && action_requires_owner(action) { @@ -338,7 +336,7 @@ pub fn authorize( if !has_recording_write(claims) { return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); } - if !is_admin(claims) { + if !claims.is_admin() { return RecordingDecision::Deny(DenyReason::NotAdministrator); } RecordingDecision::Allow @@ -385,7 +383,7 @@ fn check_mutate_action(claims: &Claims, subject_id: &UserId, meta: &RecordingMet } // Shared visibility: only admins can mutate ("shared mutation // only to administrators with recording.write"). - if !is_admin(claims) { + if !claims.is_admin() { return RecordingDecision::Deny(DenyReason::NotAdministrator); } RecordingDecision::Allow @@ -412,7 +410,7 @@ pub fn authorize_orphan(claims: &Claims) -> RecordingDecision { if is_system_principal(claims) { return RecordingDecision::Allow; } - if !is_admin(claims) { + if !claims.is_admin() { return RecordingDecision::Deny(DenyReason::NotAdministrator); } if !has_recording_read(claims) { @@ -428,16 +426,15 @@ mod tests { use super::*; use shared::model::{ recording::{AiringStatus, EpgEpisodeMetadata, RecordingOwner}, - Claims, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, + Claims, RoleSet, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, }; fn make_claims( username: &str, subject: Option, - roles: Vec<&str>, + roles: RoleSet, perms: shared::model::permission::PermissionSet, ) -> Claims { - let roles: Vec = roles.into_iter().map(String::from).collect(); Claims { username: username.to_string(), iss: "tuliprox".to_string(), @@ -503,7 +500,7 @@ mod tests { #[test] fn read_private_owner_is_allowed() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], read_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), read_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -517,7 +514,7 @@ mod tests { #[test] fn read_private_non_owner_is_denied() { - let claims = make_claims("bob", Some(subject("web:bob")), vec!["WEB"], read_perms()); + let claims = make_claims("bob", Some(subject("web:bob")), RoleSet::new(), read_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -531,7 +528,7 @@ mod tests { #[test] fn read_shared_with_read_perm_is_allowed() { - let claims = make_claims("bob", Some(subject("web:bob")), vec!["WEB"], read_perms()); + let claims = make_claims("bob", Some(subject("web:bob")), RoleSet::new(), read_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Shared); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -545,7 +542,7 @@ mod tests { #[test] fn read_without_recording_read_perm_is_denied() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], config_read_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), config_read_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -559,7 +556,7 @@ mod tests { #[test] fn read_legacy_admin_recording_requires_administrator() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], read_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), read_perms()); let meta = legacy_meta(); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -575,7 +572,7 @@ mod tests { #[test] fn create_private_with_recording_write_is_allowed() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), write_perms()); let sub = RecordingSubject::new(None, TerminalState::Active, true); let d = authorize( &claims, @@ -588,7 +585,7 @@ mod tests { #[test] fn create_shared_requires_administrator_role() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], read_write_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), read_write_perms()); let sub = RecordingSubject::new(None, TerminalState::Active, true); let d = authorize( &claims, @@ -601,7 +598,7 @@ mod tests { #[test] fn create_shared_with_admin_role_and_recording_write_is_allowed() { - let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], read_write_perms()); + let claims = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, read_write_perms()); let sub = RecordingSubject::new(None, TerminalState::Active, true); let d = authorize( &claims, @@ -616,7 +613,7 @@ mod tests { #[test] fn edit_private_owner_with_recording_write_is_allowed() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), write_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Scheduled, true); let d = authorize( @@ -630,7 +627,7 @@ mod tests { #[test] fn edit_private_non_owner_is_denied() { - let claims = make_claims("bob", Some(subject("web:bob")), vec!["WEB"], write_perms()); + let claims = make_claims("bob", Some(subject("web:bob")), RoleSet::new(), write_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Scheduled, true); let d = authorize( @@ -644,7 +641,7 @@ mod tests { #[test] fn edit_shared_requires_administrator() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), write_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Shared); let sub = RecordingSubject::new(Some(&meta), TerminalState::Scheduled, true); let d = authorize( @@ -658,7 +655,7 @@ mod tests { #[test] fn delete_shared_administrator_is_allowed() { - let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let claims = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, write_perms()); let meta = make_meta(RecordingOwner::User(UserId::from("web:alice")), RecordingVisibility::Shared); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -674,7 +671,7 @@ mod tests { #[test] fn manage_rule_requires_administrator_and_recording_write() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), write_perms()); let sub = RecordingSubject::new(None, TerminalState::Active, true); let d = authorize( &claims, @@ -684,7 +681,7 @@ mod tests { ); assert!(matches!(d, RecordingDecision::Deny(DenyReason::NotAdministrator))); - let admin = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let admin = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, write_perms()); let d = authorize( &admin, admin.subject_id.as_ref().expect("test subject_id present"), @@ -698,7 +695,7 @@ mod tests { #[test] fn system_retention_delete_requires_completed_or_failed_or_cancelled_state() { - let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let claims = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, write_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub_active = RecordingSubject::new(Some(&meta), TerminalState::Active, true); let d = authorize( @@ -724,7 +721,7 @@ mod tests { // built-in admin). The policy must allow the delete even // though the caller is not the owner — this is the // owner-bypass. - let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let claims = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, write_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -738,7 +735,7 @@ mod tests { #[test] fn system_retention_delete_requires_recording_write() { - let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], read_perms()); + let claims = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, read_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -752,7 +749,7 @@ mod tests { #[test] fn system_retention_delete_requires_administrator_role() { - let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let claims = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), write_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize( @@ -766,7 +763,7 @@ mod tests { #[test] fn system_retention_delete_fails_closed_on_invalid_path() { - let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let claims = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, write_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, false); let d = authorize( @@ -788,7 +785,7 @@ mod tests { // the claims carry no subject_id; the resolved subject is the // argument the caller provides. The test confirms the policy // honors the resolved subject (the caller is the owner → Allow). - let claims = make_claims("alice", None, vec!["WEB"], read_perms()); + let claims = make_claims("alice", None, RoleSet::new(), read_perms()); let meta = owner_meta("web:alice", RecordingVisibility::Private); let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); let d = authorize(&claims, &subject("web:alice"), RecordingAction::Read, &sub); @@ -799,16 +796,16 @@ mod tests { #[test] fn orphan_visibility_requires_administrator_and_recording_read() { - let web = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], read_perms()); + let web = make_claims("alice", Some(subject("web:alice")), RoleSet::new(), read_perms()); assert!(matches!(authorize_orphan(&web), RecordingDecision::Deny(DenyReason::NotAdministrator))); - let admin = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], config_read_perms()); + let admin = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, config_read_perms()); assert!(matches!( authorize_orphan(&admin), RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingRead)) )); - let admin_ok = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], read_perms()); + let admin_ok = make_claims("admin", Some(UserId::builtin_admin()), RoleSet::ADMIN, read_perms()); assert!(authorize_orphan(&admin_ok).is_allow()); } @@ -833,7 +830,7 @@ mod tests { let mut perms = shared::model::permission::PermissionSet::new(); perms.set(Permission::RecordingWrite); perms.set(Permission::RecordingRead); - let claims = make_claims(SYSTEM_PRINCIPAL_USERNAME, Some(UserId::builtin_admin()), vec![ROLE_ADMIN], perms); + let claims = make_claims(SYSTEM_PRINCIPAL_USERNAME, Some(UserId::builtin_admin()), RoleSet::ADMIN, perms); assert!(is_system_principal(&claims)); } @@ -845,7 +842,7 @@ mod tests { let mut perms = shared::model::permission::PermissionSet::new(); perms.set(Permission::RecordingWrite); perms.set(Permission::RecordingRead); - let claims = make_claims("alice", Some(UserId::builtin_admin()), vec![ROLE_ADMIN], perms); + let claims = make_claims("alice", Some(UserId::builtin_admin()), RoleSet::ADMIN, perms); assert!(!is_system_principal(&claims)); } @@ -859,7 +856,7 @@ mod tests { let mut perms = shared::model::permission::PermissionSet::new(); perms.set(Permission::RecordingWrite); perms.set(Permission::RecordingRead); - let claims = make_claims(SYSTEM_PRINCIPAL_USERNAME, Some(UserId::builtin_admin()), vec![ROLE_ADMIN], perms); + let claims = make_claims(SYSTEM_PRINCIPAL_USERNAME, Some(UserId::builtin_admin()), RoleSet::ADMIN, perms); let meta = make_meta(RecordingOwner::User(UserId::from("web:alice")), RecordingVisibility::Private); let subject = RecordingSubject::new(Some(&meta), TerminalState::Active, false); assert_eq!( @@ -874,7 +871,7 @@ mod tests { // lock the retention worker out of the orphan catalog. Lock in // the behaviour with a no-permission / no-admin system caller. let perms = shared::model::permission::PermissionSet::new(); - let claims = make_claims(SYSTEM_PRINCIPAL_USERNAME, Some(UserId::builtin_admin()), vec![], perms); + let claims = make_claims(SYSTEM_PRINCIPAL_USERNAME, Some(UserId::builtin_admin()), RoleSet::new(), perms); assert_eq!(authorize_orphan(&claims), RecordingDecision::Allow); } } diff --git a/backend/auth/src/user.rs b/backend/auth/src/user.rs deleted file mode 100644 index ffbcd364a..000000000 --- a/backend/auth/src/user.rs +++ /dev/null @@ -1,14 +0,0 @@ -use zeroize::Zeroize; - -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct UserCredential { - pub username: String, - pub password: String, -} - - -impl UserCredential { - pub fn zeroize(&mut self) { - self.password.zeroize(); - } -} diff --git a/backend/btree/Cargo.toml b/backend/btree/Cargo.toml index 621e10622..62f1c38c4 100644 --- a/backend/btree/Cargo.toml +++ b/backend/btree/Cargo.toml @@ -18,7 +18,6 @@ test-support = [] [dependencies] crc32fast = "1.5.1" -fs2 = "0.4.3" log.workspace = true lz4_flex.workspace = true memmap2 = "0.9.11" diff --git a/backend/btree/src/codec.rs b/backend/btree/src/codec.rs index 0f734f497..83dbaafe1 100644 --- a/backend/btree/src/codec.rs +++ b/backend/btree/src/codec.rs @@ -38,3 +38,36 @@ where { rmp_serde::encode::write(writer, value).map_err(encode_error) } + +#[cfg(test)] +mod tests { + use super::{binary_deserialize, binary_serialize}; + use serde::{Deserialize, Serialize}; + + /// Stand-in for the domain id newtypes (`VirtualId`, `ProviderId`) that are + /// used as persisted B+Tree keys. + #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[repr(transparent)] + #[serde(transparent)] + struct TransparentKey(u32); + + /// The property that lets a bare `u32` key become a newtype without a + /// database migration: `#[serde(transparent)]` encodes exactly as the inner + /// value, so every database already on disk still reads back. + #[test] + fn transparent_newtype_keys_encode_identically_to_the_bare_integer() { + for raw in [0u32, 1, 42, 4_294_967_295] { + let bare = binary_serialize(&raw).expect("u32 encodes"); + let wrapped = binary_serialize(&TransparentKey(raw)).expect("newtype encodes"); + assert_eq!(bare, wrapped, "encoding differs for {raw}"); + + // Cross-reads both ways: bytes written before the newtype existed + // decode into it, and bytes it writes decode as a plain u32. + assert_eq!( + binary_deserialize::(&bare).expect("old bytes decode into the newtype"), + TransparentKey(raw) + ); + assert_eq!(binary_deserialize::(&wrapped).expect("new bytes decode as u32"), raw); + } + } +} diff --git a/backend/btree/src/v3/tree.rs b/backend/btree/src/v3/tree.rs index dd5fdbea5..d4d3fdb4c 100644 --- a/backend/btree/src/v3/tree.rs +++ b/backend/btree/src/v3/tree.rs @@ -3574,7 +3574,6 @@ mod tests { page::{encode_free_page, encode_overflow_page, page_open_count, reset_page_open_count, SlottedPage}, }, }; - use fs2::FileExt as _; use std::{ fs, io, path::{Path, PathBuf}, @@ -4895,13 +4894,13 @@ mod tests { .create(true) .truncate(false) .open(crate::common::sidecar_lock_path(database))?; - match file.try_lock_exclusive() { + match file.try_lock() { Ok(()) => { - fs2::FileExt::unlock(&file)?; + file.unlock()?; Ok(true) } - Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(false), - Err(error) => Err(error), + Err(fs::TryLockError::WouldBlock) => Ok(false), + Err(fs::TryLockError::Error(error)) => Err(error), } } diff --git a/backend/btree/src/v3/wal.rs b/backend/btree/src/v3/wal.rs index 81a2079e9..87beade53 100644 --- a/backend/btree/src/v3/wal.rs +++ b/backend/btree/src/v3/wal.rs @@ -3,7 +3,6 @@ use super::{ page::SlottedPage, }; use crate::common::sidecar_lock_path; -use fs2::FileExt as _; use log::info; use std::{ collections::HashSet, @@ -939,7 +938,7 @@ pub(crate) struct ExclusiveSidecarGuard { impl ExclusiveSidecarGuard { pub(crate) fn acquire(database: &Path) -> io::Result { let file = open_sidecar(database)?; - file.lock_exclusive()?; + file.lock()?; Ok(Self { _file: file }) } @@ -949,17 +948,17 @@ impl ExclusiveSidecarGuard { on_contention: impl FnOnce() -> io::Result<()>, ) -> io::Result { let file = open_sidecar(database)?; - match file.try_lock_exclusive() { + match file.try_lock() { Ok(()) => Err(io::Error::other(format!( "expected final B+Tree sidecar lock contention for {}", database.display() ))), - Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + Err(std::fs::TryLockError::WouldBlock) => { on_contention()?; - file.lock_exclusive()?; + file.lock()?; Ok(Self { _file: file }) } - Err(error) => Err(error), + Err(std::fs::TryLockError::Error(error)) => Err(error), } } } diff --git a/backend/config-loader/src/lib.rs b/backend/config-loader/src/lib.rs index 2fce9607b..e355d59ba 100644 --- a/backend/config-loader/src/lib.rs +++ b/backend/config-loader/src/lib.rs @@ -11,7 +11,7 @@ use shared::{ foundation::prepare_templates, model::{ ApiProxyConfigDto, AppConfigDto, ConfigDto, ConfigInputAliasDto, ConfigPaths, HdHomeRunDeviceOverview, - InputType, MsgKind, PatternTemplate, PlansConfigDto, SourcesConfigDto, TargetUserDto, TemplateDefinitionDto, + InputType, PatternTemplate, PlansConfigDto, Prepare, SourcesConfigDto, TargetUserDto, TemplateDefinitionDto, UserPlanDto, }, utils::PROVIDER_SCHEME_PREFIX, @@ -405,12 +405,17 @@ fn apply_prepared_mappings( prepared_templates: Option<&[PatternTemplate]>, ) { match mapping.mappings.prepare(prepared_templates) { - Ok(()) => { - let mappings: tuliprox_core::model::Mappings = tuliprox_core::model::Mappings::from(&*mapping); - app_config.set_mappings(mappings_file, &mappings); - paths.mapping_files_used = - mapping_paths.map(|items| items.iter().map(|p| p.display().to_string()).collect::>()); - } + Ok(()) => match tuliprox_core::model::CompiledMappings::try_from(&*mapping) { + Ok(mappings) => { + app_config.set_mappings(mappings_file, &mappings); + paths.mapping_files_used = + mapping_paths.map(|items| items.iter().map(|p| p.display().to_string()).collect::>()); + } + Err(err) => { + error!("Failed to compile mapping '{mappings_file}': {err}. Skipping mapping registration."); + paths.mapping_files_used = None; + } + }, Err(err) => { error!("Failed to prepare mapping '{mappings_file}': {err}. Skipping mapping registration."); paths.mapping_files_used = None; @@ -1039,19 +1044,25 @@ pub async fn persist_messaging_templates( // Discord if let Some(discord) = &mut messaging.discord { for (kind, template) in &mut discord.templates { - *template = persist_single_template("discord", Some(kind), template, &templates_dir).await?; + *template = persist_single_template("discord", Some(kind.as_str()), template, &templates_dir).await?; } } // Telegram if let Some(telegram) = &mut messaging.telegram { for (kind, template) in &mut telegram.templates { - *template = persist_single_template("telegram", Some(kind), template, &templates_dir).await?; + *template = persist_single_template("telegram", Some(kind.as_str()), template, &templates_dir).await?; } } // Rest if let Some(rest) = &mut messaging.rest { for (kind, template) in &mut rest.templates { - *template = persist_single_template("rest", Some(kind), template, &templates_dir).await?; + *template = persist_single_template("rest", Some(kind.as_str()), template, &templates_dir).await?; + } + } + // Pushover + if let Some(pushover) = &mut messaging.pushover { + for (kind, template) in &mut pushover.templates { + *template = persist_single_template("pushover", Some(kind.as_str()), template, &templates_dir).await?; } } } @@ -1060,7 +1071,7 @@ pub async fn persist_messaging_templates( async fn persist_single_template( prefix: &str, - kind: Option<&MsgKind>, + event_key: Option<&str>, template: &str, templates_dir: &Path, ) -> Result { @@ -1088,8 +1099,18 @@ async fn persist_single_template( })?; } - let filename = - if let Some(k) = kind { k.template_filename(prefix) } else { concat_string!(prefix, "_default.templ") }; + // Persist under the canonical event id where the key resolves to one, + // so a config written with a legacy `MsgKind` name still lands on the + // filename the registry will look for. + let filename = event_key.map_or_else( + || concat_string!(prefix, "_default.templ"), + |key| { + shared::model::notification::EventId::from_wire(key).map_or_else( + || concat_string!(prefix, "_", &key.replace('.', "_"), ".templ"), + |id| id.template_filename(prefix), + ) + }, + ); let file_path = templates_dir.join(filename); fs::write(&file_path, template) diff --git a/backend/config-loader/src/runtime_config_report.rs b/backend/config-loader/src/runtime_config_report.rs index fcf985eaa..a84cb8316 100644 --- a/backend/config-loader/src/runtime_config_report.rs +++ b/backend/config-loader/src/runtime_config_report.rs @@ -89,6 +89,7 @@ fn runtime_config_to_dto(config: &Config) -> ConfigDto { user_access_control: config.user_access_control, connect_timeout_secs: config.connect_timeout_secs, interner_gc_interval_secs: config.interner_gc_interval_secs, + event_channel_capacity: config.event_channel_capacity, interner_gc_min_pool_size: config.interner_gc_min_pool_size, sleep_timer_mins: config.sleep_timer_mins, update_on_boot: config.update_on_boot, diff --git a/backend/core/src/model/auth_rejection.rs b/backend/core/src/model/auth_rejection.rs new file mode 100644 index 000000000..68f29c499 --- /dev/null +++ b/backend/core/src/model/auth_rejection.rs @@ -0,0 +1,144 @@ +//! The rejection returned by every authentication extractor. +//! +//! `AuthBasic`, `AuthBearer` and [`Fingerprint`](super::Fingerprint) each used +//! to declare `type Rejection = (StatusCode, &'static str)`, defined twice - +//! once in `tuliprox-auth`, once beside `Fingerprint`. That tuple carried no +//! structure, so every arm picked its own status by hand and they all picked +//! wrong: a *missing* `Authorization` header answered `403 Forbidden`, which +//! says "you are authenticated and still may not do this" when the truth is +//! "you did not authenticate". A client cannot tell those apart, and the +//! response carried no `WWW-Authenticate` challenge either. +//! +//! One enum, one `IntoResponse`, correct statuses, and the challenge header +//! comes along for free. + +use axum::{ + http::{header::WWW_AUTHENTICATE, StatusCode}, + response::{IntoResponse, Response}, +}; + +/// The HTTP authentication scheme an extractor was looking for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthScheme { + Basic, + Bearer, +} + +impl AuthScheme { + /// The `WWW-Authenticate` challenge for this scheme. + #[inline] + pub const fn challenge(self) -> &'static str { + match self { + Self::Basic => "Basic", + Self::Bearer => "Bearer", + } + } +} + +/// Why an authentication extractor refused the request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthRejection { + /// No `Authorization` header at all. + MissingHeader(AuthScheme), + /// The header is present but not valid single-byte text, or carries no + /// scheme/credentials split. + MalformedHeader(AuthScheme), + /// The header uses a different scheme than the extractor requires. + WrongScheme(AuthScheme), + /// Basic credentials decoded but carried no `:` separator. + MissingBasicPassword, + /// The peer address is unavailable, so the request cannot be attributed to + /// a client. Not an authentication failure - a malformed connection. + MissingPeerAddress, +} + +impl AuthRejection { + /// The scheme to advertise in the `WWW-Authenticate` challenge, if this + /// rejection warrants one. + #[inline] + pub const fn scheme(self) -> Option { + match self { + Self::MissingHeader(scheme) | Self::MalformedHeader(scheme) | Self::WrongScheme(scheme) => Some(scheme), + Self::MissingBasicPassword => Some(AuthScheme::Basic), + Self::MissingPeerAddress => None, + } + } + + #[inline] + pub const fn status(self) -> StatusCode { + match self { + // Every one of these means "you have not authenticated", which is + // 401 - not the 403 these used to return. + Self::MissingHeader(_) | Self::MalformedHeader(_) | Self::WrongScheme(_) | Self::MissingBasicPassword => { + StatusCode::UNAUTHORIZED + } + Self::MissingPeerAddress => StatusCode::BAD_REQUEST, + } + } + + #[inline] + pub const fn message(self) -> &'static str { + match self { + Self::MissingHeader(_) => "Authorization header is missing", + Self::MalformedHeader(_) => "Authorization header contains invalid characters", + Self::WrongScheme(AuthScheme::Basic) => "`Authorization` header must be a basic auth", + Self::WrongScheme(AuthScheme::Bearer) => "`Authorization` header must be a bearer token", + Self::MissingBasicPassword => "Authorization header contains no password", + Self::MissingPeerAddress => "IP-Addr is missing", + } + } +} + +impl std::fmt::Display for AuthRejection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.message()) } +} + +impl IntoResponse for AuthRejection { + fn into_response(self) -> Response { + let mut response = (self.status(), self.message()).into_response(); + if let Some(scheme) = self.scheme() { + if let Ok(value) = axum::http::HeaderValue::from_str(scheme.challenge()) { + response.headers_mut().insert(WWW_AUTHENTICATE, value); + } + } + response + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_header_is_unauthorized_not_forbidden() { + // The whole point of the type: a missing header is 401, and it used to + // be 403. + let rejection = AuthRejection::MissingHeader(AuthScheme::Bearer); + assert_eq!(rejection.status(), StatusCode::UNAUTHORIZED); + let response = rejection.into_response(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.headers().get(WWW_AUTHENTICATE).and_then(|v| v.to_str().ok()), Some("Bearer")); + } + + #[test] + fn basic_rejections_challenge_with_basic() { + for rejection in [ + AuthRejection::MissingHeader(AuthScheme::Basic), + AuthRejection::MalformedHeader(AuthScheme::Basic), + AuthRejection::WrongScheme(AuthScheme::Basic), + AuthRejection::MissingBasicPassword, + ] { + assert_eq!(rejection.scheme(), Some(AuthScheme::Basic), "{rejection:?}"); + assert_eq!(rejection.status(), StatusCode::UNAUTHORIZED, "{rejection:?}"); + } + } + + #[test] + fn missing_peer_address_is_a_bad_request_without_a_challenge() { + let rejection = AuthRejection::MissingPeerAddress; + assert_eq!(rejection.status(), StatusCode::BAD_REQUEST); + assert_eq!(rejection.scheme(), None); + let response = rejection.into_response(); + assert!(response.headers().get(WWW_AUTHENTICATE).is_none()); + } +} diff --git a/backend/core/src/model/config/app.rs b/backend/core/src/model/config/app.rs index e0bf3394f..38cf1b591 100644 --- a/backend/core/src/model/config/app.rs +++ b/backend/core/src/model/config/app.rs @@ -1,8 +1,8 @@ use crate::{ model::{ - ApiProxyConfig, ApiProxyServerInfo, Config, ConfigInput, ConfigInputOptions, ConfigTarget, - CustomStreamResponse, GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, Mappings, MediaToolCapabilities, - ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput, + ApiProxyConfig, ApiProxyServerInfo, CompiledMappings, CompiledTargetMappings, Config, ConfigInput, + ConfigInputOptions, ConfigTarget, CustomStreamResponse, GracePeriodOptions, HdHomeRunConfig, HdHomeRunFlags, + MediaToolCapabilities, ProxyUserCredentials, ReverseProxyDisabledHeaderConfig, SourcesConfig, TargetOutput, }, utils, }; @@ -67,12 +67,12 @@ impl AppConfig { self.check_target_user() } - pub fn set_mappings(&self, mapping_path: &str, mappings_cfg: &Mappings) { + pub fn set_mappings(&self, mapping_path: &str, mappings_cfg: &CompiledMappings) { self.set_mapping_path(Some(mapping_path)); let sources = self.sources.load(); // Warn only if mappings were actually loaded; target mapping stores still need updating below. - if !mappings_cfg.mappings.mapping.is_empty() { + if !mappings_cfg.mappings.is_empty() { // Collect all mapping_ids referenced by targets let mut referenced_ids: HashSet<&str> = HashSet::new(); for source in &sources.sources { @@ -86,7 +86,7 @@ impl AppConfig { } // Warn about loaded-but-unreferenced mappings (may be intentional template/experiment files) - for mapping in &mappings_cfg.mappings.mapping { + for mapping in &mappings_cfg.mappings { if !referenced_ids.contains(mapping.id.as_str()) { warn!( "Mapping '{}' is loaded but not referenced by any target; it has no effect unless added to a target mapping list", @@ -96,9 +96,9 @@ impl AppConfig { } // Warn about mappings that have neither mapper nor counter - for mapping in &mappings_cfg.mappings.mapping { - let has_mapper = mapping.mapper.as_ref().is_some_and(|m| !m.is_empty()); - let has_counter = mapping.t_counter.as_ref().is_some_and(|c| !c.is_empty()); + for mapping in &mappings_cfg.mappings { + let has_mapper = !mapping.rules.is_empty(); + let has_counter = !mapping.counters.is_empty(); if !has_mapper && !has_counter { warn!("Mapping '{}' has neither mapper nor counter and has no effect", mapping.id); } @@ -113,12 +113,17 @@ impl AppConfig { let mapping = mappings_cfg.get_mapping(mapping_id); if let Some(mappings) = mapping { target_mappings.push(mappings); + } else { + warn!( + "Target '{}' references unknown mapping '{}'; the mapping will not be applied", + target.name, mapping_id + ); } } target.mapping.store(if target_mappings.is_empty() { None } else { - Some(Arc::new(target_mappings)) + Some(Arc::new(CompiledTargetMappings::new(target_mappings))) }); } } @@ -568,11 +573,11 @@ impl AppConfig { mod tests { use super::*; use crate::{ - model::{ConfigSource, Mapper, Mapping, MappingDefinition}, + model::{CompiledMapping, CompiledMappingRule, ConfigSource, MappingProgram}, utils::FileLockManager, }; use shared::{ - foundation::Filter, + foundation::{Filter, MapperScript}, model::{ConfigPaths, ProcessingOrder}, }; @@ -624,27 +629,25 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::Frm, + execution_plan: crate::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }) } - fn mappings_with_one_mapper(mapping_id: &str) -> Mappings { - Mappings { - mappings: MappingDefinition { - templates: None, - mapping: vec![Mapping { - id: mapping_id.to_string(), - mapper: Some(vec![Mapper::default()]), - ..Mapping::default() - }], - }, - } + fn mappings_with_one_mapper(mapping_id: &str) -> CompiledMappings { + CompiledMappings::new(vec![CompiledMapping { + id: mapping_id.to_string(), + rules: vec![CompiledMappingRule { + name: None, + filter: Filter::default(), + program: MappingProgram::Script(MapperScript::parse("", None).expect("empty script should parse")), + }], + ..CompiledMapping::default() + }]) } - fn empty_mappings() -> Mappings { - Mappings { mappings: MappingDefinition { templates: None, mapping: Vec::new() } } - } + fn empty_mappings() -> CompiledMappings { CompiledMappings::default() } #[test] fn set_mappings_clears_existing_target_mappings_when_reload_is_empty() { @@ -658,6 +661,16 @@ mod tests { assert!(target.mapping.load().is_none(), "empty mapping reload must clear stale target mappings"); } + #[test] + fn set_mappings_does_not_attach_an_unrelated_mapping_for_unknown_id() { + let target = target_with_mapping_id("missing"); + let app_config = test_app_config_with_target(Arc::clone(&target)); + + app_config.set_mappings("mappings", &mappings_with_one_mapper("available")); + + assert!(target.mapping.load().is_none(), "an unknown mapping id must not attach another mapping"); + } + #[test] fn set_mapping_path_stores_resolved_path_in_paths() { let app_config = test_app_config_with_target(target_with_mapping_id("map1")); diff --git a/backend/core/src/model/config/base.rs b/backend/core/src/model/config/base.rs index c4d4ea25c..cf96a28b2 100644 --- a/backend/core/src/model/config/base.rs +++ b/backend/core/src/model/config/base.rs @@ -96,6 +96,12 @@ pub struct Config { pub user_access_control: bool, pub connect_timeout_secs: u32, pub interner_gc_interval_secs: u32, + /// Buffer depth of the in-process event broadcast channel. + /// + /// A subscriber that falls this many events behind is told it lagged and + /// resumes from the newest event, having missed the gap. The old fixed + /// depth of 10 was routinely outrun by playlist-update progress ticks. + pub event_channel_capacity: u32, pub interner_gc_min_pool_size: u32, pub sleep_timer_mins: Option, pub update_on_boot: bool, @@ -325,6 +331,7 @@ impl From<&ConfigDto> for Config { user_access_control: dto.user_access_control, connect_timeout_secs: dto.connect_timeout_secs, interner_gc_interval_secs: dto.interner_gc_interval_secs, + event_channel_capacity: dto.event_channel_capacity, interner_gc_min_pool_size: dto.interner_gc_min_pool_size, sleep_timer_mins: dto.sleep_timer_mins, update_on_boot: dto.update_on_boot, diff --git a/backend/core/src/model/config/cache.rs b/backend/core/src/model/config/cache.rs index 4d814be3e..34b024210 100644 --- a/backend/core/src/model/config/cache.rs +++ b/backend/core/src/model/config/cache.rs @@ -39,7 +39,7 @@ fn get_size(dto: &CacheConfigDto) -> usize { None => return 1024, Some(val) => { if let Ok(size) = val.parse_bytes() { - if let Ok(value) = usize::try_from(size) { + if let Ok(value) = usize::try_from(size.get()) { return value; } } diff --git a/backend/core/src/model/config/messaging.rs b/backend/core/src/model/config/messaging.rs index b0c24051c..da1e61139 100644 --- a/backend/core/src/model/config/messaging.rs +++ b/backend/core/src/model/config/messaging.rs @@ -1,8 +1,10 @@ use crate::model::macros; use log::warn; use shared::model::{ - DiscordMessagingConfigDto, DiskAlertConfigDto, MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, - RestMessagingConfigDto, TelegramMessagingConfigDto, + notification::{registry, EventId, EventSubscription, QuietHours, Severity}, + ChannelRoutingDto, CommandMessagingConfigDto, DiscordMessagingConfigDto, DiskAlertConfigDto, + GotifyMessagingConfigDto, MessagingConfigDto, NtfyMessagingConfigDto, PushoverMessagingConfigDto, + RestMessagingConfigDto, SlackMessagingConfigDto, TelegramMessagingConfigDto, }; use std::path::{Path, PathBuf}; @@ -49,12 +51,74 @@ impl From<&DiskAlertConfig> for DiskAlertConfigDto { } } +/// Per-channel routing, resolved at config load. +#[derive(Debug, Clone, Default)] +pub struct ChannelRouting { + /// Overrides the global subscription when non-empty. + pub subscription: Option, + pub min_severity: Option, + pub quiet_hours: Option, + pub max_per_hour: Option, + pub dedup_window: Option, +} + +impl ChannelRouting { + /// Does this channel accept `event` at `severity`? + /// + /// Quiet hours are deliberately *not* checked here: they defer delivery + /// rather than reject it, so they belong to the outbox schedule, not to + /// the routing decision. + #[must_use] + pub fn accepts(&self, event: EventId, severity: Severity) -> bool { + if self.min_severity.is_some_and(|min| severity < min) { + return false; + } + match &self.subscription { + Some(subscription) => subscription.matches(event), + None => true, + } + } +} + +impl From<&ChannelRoutingDto> for ChannelRouting { + fn from(dto: &ChannelRoutingDto) -> Self { + Self { + subscription: if dto.notify_on.is_empty() { + None + } else { + Some(EventSubscription::parse(dto.notify_on.iter())) + }, + min_severity: dto.min_severity.as_deref().and_then(Severity::from_wire), + quiet_hours: dto.quiet_hours.as_deref().and_then(QuietHours::parse), + max_per_hour: dto.max_per_hour, + dedup_window: dto.dedup_window_secs.map(std::time::Duration::from_secs), + } + } +} + +impl From<&ChannelRouting> for ChannelRoutingDto { + fn from(instance: &ChannelRouting) -> Self { + Self { + notify_on: instance + .subscription + .as_ref() + .map(|s| s.patterns().iter().map(ToString::to_string).collect()) + .unwrap_or_default(), + min_severity: instance.min_severity.map(|s| s.wire_name().to_string()), + quiet_hours: None, + max_per_hour: instance.max_per_hour, + dedup_window_secs: instance.dedup_window.map(|d| d.as_secs()), + } + } +} + #[derive(Debug, Clone)] pub struct TelegramMessagingConfig { pub bot_token: String, pub chat_ids: Vec, pub markdown: bool, - pub templates: std::collections::HashMap, + pub templates: std::collections::HashMap, + pub routing: ChannelRouting, } impl TelegramMessagingConfig { @@ -70,7 +134,8 @@ impl From<&TelegramMessagingConfigDto> for TelegramMessagingConfig { bot_token: dto.bot_token.clone(), chat_ids: dto.chat_ids.clone(), markdown: dto.markdown, - templates: dto.templates.clone(), + templates: resolve_template_keys(&dto.templates), + routing: dto.routing.as_ref().map(Into::into).unwrap_or_default(), } } } @@ -81,7 +146,8 @@ impl From<&TelegramMessagingConfig> for TelegramMessagingConfigDto { bot_token: instance.bot_token.clone(), chat_ids: instance.chat_ids.clone(), markdown: instance.markdown, - templates: instance.templates.clone(), + templates: template_keys_to_wire(&instance.templates), + routing: Some(ChannelRoutingDto::from(&instance.routing)), } } } @@ -90,8 +156,10 @@ impl From<&TelegramMessagingConfig> for TelegramMessagingConfigDto { pub struct RestMessagingConfig { pub url: String, pub method: String, + pub signing_secret: Option, pub headers: std::collections::HashMap, - pub templates: std::collections::HashMap, + pub templates: std::collections::HashMap, + pub routing: ChannelRouting, } impl RestMessagingConfig { @@ -112,8 +180,10 @@ impl From<&RestMessagingConfigDto> for RestMessagingConfig { Self { url: dto.url.clone(), method: dto.method.clone().unwrap_or_else(|| "POST".to_string()), + signing_secret: dto.signing_secret.clone(), headers, - templates: dto.templates.clone(), + templates: resolve_template_keys(&dto.templates), + routing: dto.routing.as_ref().map(Into::into).unwrap_or_default(), } } } @@ -121,14 +191,22 @@ impl From<&RestMessagingConfigDto> for RestMessagingConfig { impl From<&RestMessagingConfig> for RestMessagingConfigDto { fn from(model: &RestMessagingConfig) -> Self { let headers = model.headers.iter().map(|(k, v)| format!("{k}: {v}")).collect(); - Self { url: model.url.clone(), method: Some(model.method.clone()), headers, templates: model.templates.clone() } + Self { + url: model.url.clone(), + method: Some(model.method.clone()), + signing_secret: model.signing_secret.clone(), + headers, + templates: template_keys_to_wire(&model.templates), + routing: Some(ChannelRoutingDto::from(&model.routing)), + } } } #[derive(Debug, Clone)] pub struct DiscordMessagingConfig { pub url: String, - pub templates: std::collections::HashMap, + pub templates: std::collections::HashMap, + pub routing: ChannelRouting, } impl DiscordMessagingConfig { @@ -139,12 +217,22 @@ impl DiscordMessagingConfig { macros::from_impl!(DiscordMessagingConfig); impl From<&DiscordMessagingConfigDto> for DiscordMessagingConfig { - fn from(dto: &DiscordMessagingConfigDto) -> Self { Self { url: dto.url.clone(), templates: dto.templates.clone() } } + fn from(dto: &DiscordMessagingConfigDto) -> Self { + Self { + url: dto.url.clone(), + templates: resolve_template_keys(&dto.templates), + routing: dto.routing.as_ref().map(Into::into).unwrap_or_default(), + } + } } impl From<&DiscordMessagingConfig> for DiscordMessagingConfigDto { fn from(instance: &DiscordMessagingConfig) -> Self { - Self { url: instance.url.clone(), templates: instance.templates.clone() } + Self { + url: instance.url.clone(), + templates: template_keys_to_wire(&instance.templates), + routing: Some(ChannelRoutingDto::from(&instance.routing)), + } } } @@ -153,6 +241,14 @@ pub struct PushoverMessagingConfig { pub url: String, pub token: String, pub user: String, + pub templates: std::collections::HashMap, + pub routing: ChannelRouting, +} + +impl PushoverMessagingConfig { + pub fn prepare(&mut self, templates_dir: &Path) { + discover_templates("pushover", &mut self.templates, templates_dir); + } } macros::from_impl!(PushoverMessagingConfig); @@ -165,28 +261,196 @@ impl From<&PushoverMessagingConfigDto> for PushoverMessagingConfig { .map_or_else(|| String::from("https://api.pushover.net/1/messages.json"), ToString::to_string), token: dto.token.clone(), user: dto.user.clone(), + templates: resolve_template_keys(&dto.templates), + routing: dto.routing.as_ref().map(Into::into).unwrap_or_default(), } } } impl From<&PushoverMessagingConfig> for PushoverMessagingConfigDto { fn from(instance: &PushoverMessagingConfig) -> Self { - Self { url: Some(instance.url.clone()), token: instance.token.clone(), user: instance.user.clone() } + Self { + url: Some(instance.url.clone()), + token: instance.token.clone(), + user: instance.user.clone(), + templates: template_keys_to_wire(&instance.templates), + routing: Some(ChannelRoutingDto::from(&instance.routing)), + } + } +} + +#[derive(Debug, Clone)] +pub struct NtfyMessagingConfig { + pub url: String, + pub topic: String, + pub token: Option, + pub templates: std::collections::HashMap, + pub routing: ChannelRouting, +} + +impl NtfyMessagingConfig { + pub fn prepare(&mut self, templates_dir: &Path) { discover_templates("ntfy", &mut self.templates, templates_dir); } +} + +macros::from_impl!(NtfyMessagingConfig); +impl From<&NtfyMessagingConfigDto> for NtfyMessagingConfig { + fn from(dto: &NtfyMessagingConfigDto) -> Self { + Self { + url: dto.url.trim_end_matches('/').to_string(), + topic: dto.topic.clone(), + token: dto.token.clone(), + templates: resolve_template_keys(&dto.templates), + routing: dto.routing.as_ref().map(Into::into).unwrap_or_default(), + } + } +} + +impl From<&NtfyMessagingConfig> for NtfyMessagingConfigDto { + fn from(instance: &NtfyMessagingConfig) -> Self { + Self { + url: instance.url.clone(), + topic: instance.topic.clone(), + token: instance.token.clone(), + templates: template_keys_to_wire(&instance.templates), + routing: Some(ChannelRoutingDto::from(&instance.routing)), + } + } +} + +#[derive(Debug, Clone)] +pub struct GotifyMessagingConfig { + pub url: String, + pub token: String, + pub templates: std::collections::HashMap, + pub routing: ChannelRouting, +} + +impl GotifyMessagingConfig { + pub fn prepare(&mut self, templates_dir: &Path) { + discover_templates("gotify", &mut self.templates, templates_dir); + } +} + +macros::from_impl!(GotifyMessagingConfig); +impl From<&GotifyMessagingConfigDto> for GotifyMessagingConfig { + fn from(dto: &GotifyMessagingConfigDto) -> Self { + Self { + url: dto.url.trim_end_matches('/').to_string(), + token: dto.token.clone(), + templates: resolve_template_keys(&dto.templates), + routing: dto.routing.as_ref().map(Into::into).unwrap_or_default(), + } + } +} + +impl From<&GotifyMessagingConfig> for GotifyMessagingConfigDto { + fn from(instance: &GotifyMessagingConfig) -> Self { + Self { + url: instance.url.clone(), + token: instance.token.clone(), + templates: template_keys_to_wire(&instance.templates), + routing: Some(ChannelRoutingDto::from(&instance.routing)), + } + } +} + +#[derive(Debug, Clone)] +pub struct SlackMessagingConfig { + pub url: String, + pub templates: std::collections::HashMap, + pub routing: ChannelRouting, +} + +impl SlackMessagingConfig { + pub fn prepare(&mut self, templates_dir: &Path) { discover_templates("slack", &mut self.templates, templates_dir); } +} + +macros::from_impl!(SlackMessagingConfig); +impl From<&SlackMessagingConfigDto> for SlackMessagingConfig { + fn from(dto: &SlackMessagingConfigDto) -> Self { + Self { + url: dto.url.clone(), + templates: resolve_template_keys(&dto.templates), + routing: dto.routing.as_ref().map(Into::into).unwrap_or_default(), + } + } +} + +impl From<&SlackMessagingConfig> for SlackMessagingConfigDto { + fn from(instance: &SlackMessagingConfig) -> Self { + Self { + url: instance.url.clone(), + templates: template_keys_to_wire(&instance.templates), + routing: Some(ChannelRoutingDto::from(&instance.routing)), + } + } +} + +#[derive(Debug, Clone)] +pub struct CommandMessagingConfig { + pub program: String, + pub args: Vec, + pub timeout: std::time::Duration, + pub templates: std::collections::HashMap, + pub routing: ChannelRouting, +} + +impl CommandMessagingConfig { + pub fn prepare(&mut self, templates_dir: &Path) { + discover_templates("command", &mut self.templates, templates_dir); + } +} + +macros::from_impl!(CommandMessagingConfig); +impl From<&CommandMessagingConfigDto> for CommandMessagingConfig { + fn from(dto: &CommandMessagingConfigDto) -> Self { + Self { + program: dto.program.clone(), + args: dto.args.clone(), + timeout: std::time::Duration::from_secs(dto.timeout_secs.unwrap_or(30).max(1)), + templates: resolve_template_keys(&dto.templates), + routing: dto.routing.as_ref().map(Into::into).unwrap_or_default(), + } + } +} + +impl From<&CommandMessagingConfig> for CommandMessagingConfigDto { + fn from(instance: &CommandMessagingConfig) -> Self { + Self { + program: instance.program.clone(), + args: instance.args.clone(), + timeout_secs: Some(instance.timeout.as_secs()), + templates: template_keys_to_wire(&instance.templates), + routing: Some(ChannelRoutingDto::from(&instance.routing)), + } } } #[derive(Debug, Clone)] pub struct MessagingConfig { - pub notify_on: Vec, + /// Raw `notify_on` entries as written in config. Glob patterns and + /// legacy `MsgKind` names are both accepted. + pub notify_on: Vec, pub telegram: Option, pub rest: Option, pub pushover: Option, pub discord: Option, + pub ntfy: Option, + pub gotify: Option, + pub slack: Option, + pub command: Option, /// Optional disk-space alert config. When `None`, no alert is fired. pub disk_alert: Option, + /// `notify_on` parsed once at load. Matching runs per notification, so + /// re-parsing the patterns each time would be wasteful. + subscription: EventSubscription, } impl MessagingConfig { + /// The parsed `notify_on` subscription. + #[must_use] + pub fn subscription(&self) -> &EventSubscription { &self.subscription } + pub fn prepare(&mut self, config_path: &str) { let templates_dir = PathBuf::from(config_path).join("messaging_templates"); if let Some(t) = &mut self.telegram { @@ -198,6 +462,21 @@ impl MessagingConfig { if let Some(d) = &mut self.discord { d.prepare(&templates_dir); } + if let Some(p) = &mut self.pushover { + p.prepare(&templates_dir); + } + if let Some(n) = &mut self.ntfy { + n.prepare(&templates_dir); + } + if let Some(g) = &mut self.gotify { + g.prepare(&templates_dir); + } + if let Some(sl) = &mut self.slack { + sl.prepare(&templates_dir); + } + if let Some(c) = &mut self.command { + c.prepare(&templates_dir); + } } } @@ -206,10 +485,15 @@ impl From<&MessagingConfigDto> for MessagingConfig { fn from(dto: &MessagingConfigDto) -> Self { Self { notify_on: dto.notify_on.clone(), + subscription: EventSubscription::parse(dto.notify_on.iter()), telegram: dto.telegram.as_ref().map(Into::into), rest: dto.rest.as_ref().map(Into::into), pushover: dto.pushover.as_ref().map(Into::into), discord: dto.discord.as_ref().map(Into::into), + ntfy: dto.ntfy.as_ref().map(Into::into), + gotify: dto.gotify.as_ref().map(Into::into), + slack: dto.slack.as_ref().map(Into::into), + command: dto.command.as_ref().map(Into::into), disk_alert: dto.disk_alert.as_ref().map(Into::into), } } @@ -223,28 +507,62 @@ impl From<&MessagingConfig> for MessagingConfigDto { rest: instance.rest.as_ref().map(Into::into), pushover: instance.pushover.as_ref().map(Into::into), discord: instance.discord.as_ref().map(Into::into), + ntfy: instance.ntfy.as_ref().map(Into::into), + gotify: instance.gotify.as_ref().map(Into::into), + slack: instance.slack.as_ref().map(Into::into), + command: instance.command.as_ref().map(Into::into), disk_alert: instance.disk_alert.as_ref().map(Into::into), } } } -fn discover_templates(prefix: &str, templates: &mut std::collections::HashMap, templates_dir: &Path) { - let variants = [ - MsgKind::Info, - MsgKind::Stats, - MsgKind::Error, - MsgKind::Watch, - MsgKind::DiskAlert, - MsgKind::RecordingStarted, - MsgKind::RecordingCompleted, - MsgKind::RecordingFailed, - ]; - for kind in variants { - if let std::collections::hash_map::Entry::Vacant(e) = templates.entry(kind) { - let filename = kind.template_filename(prefix); - let file_path = templates_dir.join(filename); - if file_path.exists() { - e.insert(format!("file://{}", file_path.to_string_lossy())); +/// Resolve config template keys to event ids, warning on anything unknown. +/// +/// A typo here used to be indistinguishable from an event that never fires. +fn resolve_template_keys( + raw: &std::collections::HashMap, +) -> std::collections::HashMap { + let mut out = std::collections::HashMap::with_capacity(raw.len()); + for (key, value) in raw { + match EventId::from_wire(key) { + Some(id) => { + out.insert(id, value.clone()); + } + None => warn!("Ignoring messaging template for unknown event `{key}`"), + } + } + out +} + +/// Canonical wire names for round-tripping back into the DTO. +fn template_keys_to_wire( + templates: &std::collections::HashMap, +) -> std::collections::HashMap { + templates.iter().map(|(id, value)| (id.as_str().to_string(), value.clone())).collect() +} + +/// Adopt `{prefix}_{event}.templ` files sitting in the templates directory. +/// +/// Iterates the event registry rather than a hardcoded variant list, which +/// is what previously made a newly added kind silently undiscoverable. +fn discover_templates(prefix: &str, templates: &mut std::collections::HashMap, templates_dir: &Path) { + for descriptor in registry::ALL { + if let std::collections::hash_map::Entry::Vacant(e) = templates.entry(descriptor.id) { + // Canonical name first, then any legacy filename that resolves + // to the same id, so existing `telegram_disk_alert.templ` files + // keep being picked up. + let candidates = std::iter::once(descriptor.id.template_filename(prefix)).chain( + shared::model::notification::LEGACY_ALIASES + .iter() + .filter(|(_, id)| *id == descriptor.id) + .map(|(legacy, _)| format!("{prefix}_{legacy}.templ")), + ); + for filename in candidates { + let file_path = templates_dir.join(filename); + if file_path.exists() { + e.insert(format!("file://{}", file_path.to_string_lossy())); + break; + } } } } diff --git a/backend/core/src/model/config/metadata_update.rs b/backend/core/src/model/config/metadata_update.rs index a77270b92..527646e20 100644 --- a/backend/core/src/model/config/metadata_update.rs +++ b/backend/core/src/model/config/metadata_update.rs @@ -13,8 +13,8 @@ use shared::{ default_tmdb_rate_limit_ms, }, model::{ - ByteSize, FfprobeConfigDto, MetadataLogConfigDto, MetadataUpdateConfigDto, ProbeConfigDto, ResolveConfigDto, - TmdbConfigDto, + ByteSize, Bytes, FfprobeConfigDto, MetadataLogConfigDto, MetadataUpdateConfigDto, ProbeConfigDto, + ResolveConfigDto, TmdbConfigDto, }, utils::parse_duration_seconds, }; @@ -79,11 +79,11 @@ pub struct FfprobeConfig { pub analyze_duration: String, pub analyze_duration_micros: u64, pub probe_size: ByteSize, - pub probe_size_bytes: u64, + pub probe_size_bytes: Bytes, pub live_analyze_duration: String, pub live_analyze_duration_micros: u64, pub live_probe_size: ByteSize, - pub live_probe_size_bytes: u64, + pub live_probe_size_bytes: Bytes, } #[derive(Debug, Clone)] @@ -126,13 +126,13 @@ fn parse_duration_or_default(value: &str, default_value: &str, require_unit: boo .map_or(1, |v| v.max(1)) } -fn parse_size_or_default(value: &ByteSize, default_value: &ByteSize) -> u64 { +fn parse_size_or_default(value: &ByteSize, default_value: &ByteSize) -> Bytes { value .parse_bytes() .ok() - .map(|v| v.max(1)) - .or_else(|| default_value.parse_bytes().ok().map(|v| v.max(1))) - .unwrap_or(1) + .map(Bytes::at_least_1) + .or_else(|| default_value.parse_bytes().ok().map(Bytes::at_least_1)) + .unwrap_or(Bytes::new(1)) } impl From<&MetadataUpdateConfigDto> for MetadataUpdateConfig { diff --git a/backend/core/src/model/config/reverse_proxy.rs b/backend/core/src/model/config/reverse_proxy.rs index 00353c4d4..7cf176e4b 100644 --- a/backend/core/src/model/config/reverse_proxy.rs +++ b/backend/core/src/model/config/reverse_proxy.rs @@ -8,9 +8,9 @@ use shared::{ HLS_CACHE_DIR_SUFFIX, }, model::{ - HlsCacheConfigDto, HlsCorruptSegmentWatchdogConfigDto, HlsManifestRecoveryBurstConfigDto, - HlsSegmentRepairConfigDto, HlsSegmentRepairSizeIncreaseConfigDto, HlsStripMode, ResourceRetryConfigDto, - ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, REGEX_CACHE, + Bytes, HlsCacheConfigDto, HlsCorruptSegmentWatchdogConfigDto, HlsManifestRecoveryBurstConfigDto, + HlsSegmentRepairConfigDto, HlsSegmentRepairSizeIncreaseConfigDto, HlsStripMode, Millis, ResourceRetryConfigDto, + ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, Secs, REGEX_CACHE, }, utils::{hex_to_u8_16, u8_16_to_hex}, }; @@ -199,7 +199,7 @@ pub struct HlsSegmentRepairConfig { pub max_level: shared::model::HlsSegmentRepairMode, pub apply_to_first_segments: u8, pub max_parallel_repairs: usize, - pub postprocess_timeout_ms: u64, + pub postprocess_timeout_ms: Millis, pub size_increase: HlsSegmentRepairSizeIncreaseConfig, pub corrupt_segment_watchdog: HlsCorruptSegmentWatchdogConfig, } @@ -260,23 +260,23 @@ impl From<&HlsCorruptSegmentWatchdogConfig> for HlsCorruptSegmentWatchdogConfigD pub struct HlsCacheConfig { pub cache_path: String, pub strip: StripConfig, - pub cache_duration: u64, - pub cache_bytes: u64, + pub cache_duration: Secs, + pub cache_bytes: Bytes, pub cache_bytes_str: String, - pub cache_bytes_per_session: u64, + pub cache_bytes_per_session: Bytes, pub cache_bytes_per_session_str: String, pub max_segments_prefetch: usize, pub max_concurrent_segment_fetches_per_session: usize, pub max_concurrent_segment_fetches_global: usize, - pub origin_manifest_timeout_ms: u64, - pub origin_segment_timeout_ms: u64, - pub initial_manifest_wait_timeout_secs: u64, - pub session_idle_timeout: u64, + pub origin_manifest_timeout_ms: Millis, + pub origin_segment_timeout_ms: Millis, + pub initial_manifest_wait_timeout_secs: Secs, + pub session_idle_timeout: Secs, pub manifest_recovery_burst: HlsManifestRecoveryBurstConfig, pub segment_repair: HlsSegmentRepairConfig, } -fn parse_hls_byte_size_or_default(value: &shared::model::ByteSize, default_value: &str) -> u64 { +fn parse_hls_byte_size_or_default(value: &shared::model::ByteSize, default_value: &str) -> Bytes { value .parse_bytes() .unwrap_or_else(|_| shared::model::ByteSize::new(default_value).parse_bytes().unwrap_or_default()) @@ -400,8 +400,8 @@ mod tests { ReverseProxyConfig, }; use shared::model::{ - ByteSize, HlsCacheConfigDto, HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripMode, - QosAggregationConfigDto, ReverseProxyConfigDto, StreamHistoryConfigDto, + ByteSize, Bytes, HlsCacheConfigDto, HlsManifestRecoveryBurstLevel, HlsSegmentRepairMode, HlsStripMode, Millis, + QosAggregationConfigDto, ReverseProxyConfigDto, Secs, StreamHistoryConfigDto, }; #[test] @@ -470,18 +470,18 @@ mod tests { HlsCacheConfig { cache_path: default_hls_cache_path(), strip: super::StripConfig { mode: HlsStripMode::Segments, value: 0 }, - cache_duration: 300, - cache_bytes: 10_737_418_240, // 10 * 1024^3 (was 10 * 1e9 under SI decimal before consolidation) + cache_duration: Secs::new(300), + cache_bytes: Bytes::new(10_737_418_240), // 10 * 1024^3 (was 10 * 1e9 under SI decimal before consolidation) cache_bytes_str: "10GB".to_string(), - cache_bytes_per_session: 536_870_912, // 512 * 1024^2 (was 512 * 1e6 under SI decimal before consolidation) + cache_bytes_per_session: Bytes::new(536_870_912), // 512 * 1024^2 (was 512 * 1e6 under SI decimal before consolidation) cache_bytes_per_session_str: "512MB".to_string(), max_segments_prefetch: 6, max_concurrent_segment_fetches_per_session: 2, max_concurrent_segment_fetches_global: 64, - origin_manifest_timeout_ms: 3_000, - origin_segment_timeout_ms: 10_000, - initial_manifest_wait_timeout_secs: 90, - session_idle_timeout: 300, + origin_manifest_timeout_ms: Millis::new(3_000), + origin_segment_timeout_ms: Millis::new(10_000), + initial_manifest_wait_timeout_secs: Secs::new(90), + session_idle_timeout: Secs::new(300), manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(), segment_repair: HlsSegmentRepairConfig { max_level: HlsSegmentRepairMode::Off, @@ -503,8 +503,8 @@ mod tests { let config = HlsCacheConfig::from(&dto); - assert_eq!(config.cache_bytes, 1_073_741_824); - assert_eq!(config.cache_bytes_per_session, 536_870_912); // 512 * 1024^2 (binary since parse_size_base_2 consolidation) + assert_eq!(config.cache_bytes, Bytes::new(1_073_741_824)); + assert_eq!(config.cache_bytes_per_session, Bytes::new(536_870_912)); // 512 * 1024^2 (binary since parse_size_base_2 consolidation) } #[test] diff --git a/backend/core/src/model/config/target.rs b/backend/core/src/model/config/target.rs index ddefcb8aa..c146e3d3c 100644 --- a/backend/core/src/model/config/target.rs +++ b/backend/core/src/model/config/target.rs @@ -1,7 +1,7 @@ use crate::model::{ config::{favourites::ConfigFavourites, trakt::TraktConfig}, macros, - mapping::Mapping, + mapping::CompiledMapping, ConfigRename, ConfigSort, }; use arc_swap::ArcSwapOption; @@ -9,9 +9,9 @@ use shared::{ apply_flags, create_bitset, foundation::{Filter, ValueProvider}, model::{ - ConfigTargetDto, ConfigTargetOptions, HdHomeRunTargetOutputDto, M3uTargetOutputDto, PlaylistItemType, - ProcessingOrder, StrmExportStyle, StrmTargetOutputDto, TargetOutputDto, TargetType, TraktConfigDto, - XtreamTargetOutputDto, + ConfigTargetDto, ConfigTargetOptions, DeduplicateConfig, HdHomeRunTargetOutputDto, M3uTargetOutputDto, + MappingStage, PlaylistItemType, ProcessingOrder, StrmExportStyle, StrmTargetOutputDto, TargetOutputDto, + TargetType, TraktConfigDto, XtreamTargetOutputDto, }, }; use std::sync::Arc; @@ -19,6 +19,78 @@ use std::sync::Arc; create_bitset!(u8, XtreamTargetFlags, SkipLiveDirectSource, SkipVideoDirectSource, SkipSeriesDirectSource); create_bitset!(u8, StrmTargetFlags, Flat, UnderscoreWhitespace, Cleanup, AddQualityToFilename, UseMetadata); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransformStage { + Filter, + Rename, + Map, +} + +#[derive(Debug, Clone)] +pub struct TargetExecutionPlan { + pub transform_stages: Vec, + pub pre_transform_identity_dedup: bool, + pub post_merge_content_dedup: Option, +} + +impl Default for TargetExecutionPlan { + fn default() -> Self { + Self { + transform_stages: vec![TransformStage::Filter, TransformStage::Rename, TransformStage::Map], + pre_transform_identity_dedup: false, + post_merge_content_dedup: None, + } + } +} + +impl TargetExecutionPlan { + fn from_dto(dto: &ConfigTargetDto) -> Self { + let transform_stages = match dto.processing_order { + ProcessingOrder::Frm => vec![TransformStage::Filter, TransformStage::Rename, TransformStage::Map], + ProcessingOrder::Fmr => vec![TransformStage::Filter, TransformStage::Map, TransformStage::Rename], + ProcessingOrder::Rfm => vec![TransformStage::Rename, TransformStage::Filter, TransformStage::Map], + ProcessingOrder::Rmf => vec![TransformStage::Rename, TransformStage::Map, TransformStage::Filter], + ProcessingOrder::Mfr => vec![TransformStage::Map, TransformStage::Filter, TransformStage::Rename], + ProcessingOrder::Mrf => vec![TransformStage::Map, TransformStage::Rename, TransformStage::Filter], + }; + Self { + transform_stages, + pre_transform_identity_dedup: dto.options.as_ref().is_some_and(|options| options.remove_duplicates), + post_merge_content_dedup: dto.options.as_ref().and_then(|options| options.deduplicate), + } + } +} + +#[derive(Debug)] +pub struct CompiledTargetMappings { + pub all: Vec>, + processing: Vec>, + after_epg: Vec>, +} + +impl CompiledTargetMappings { + pub fn new(all: Vec>) -> Self { + let processing = all + .iter() + .filter(|mapping| mapping.stage == MappingStage::Processing && !mapping.rules.is_empty()) + .cloned() + .collect(); + let after_epg = all + .iter() + .filter(|mapping| mapping.stage == MappingStage::AfterEpg && !mapping.rules.is_empty()) + .cloned() + .collect(); + Self { all, processing, after_epg } + } + + pub fn for_stage(&self, stage: MappingStage) -> &[Arc] { + match stage { + MappingStage::Processing => &self.processing, + MappingStage::AfterEpg => &self.after_epg, + } + } +} + #[derive(Clone, Debug)] pub struct ProcessTargets { pub enabled: bool, @@ -248,9 +320,10 @@ pub struct ConfigTarget { pub output: Vec, pub rename: Option>, pub mapping_ids: Option>, - pub mapping: Arc>>, + pub mapping: Arc>, pub favourites: Option>, pub processing_order: ProcessingOrder, + pub execution_plan: TargetExecutionPlan, pub watch: Option>>, pub use_memory_cache: bool, } @@ -308,9 +381,16 @@ impl From<&ConfigTargetDto> for ConfigTarget { mapping: Arc::new(ArcSwapOption::new(None)), favourites: dto.favourites.as_ref().map(|f| f.iter().map(Into::into).collect()), processing_order: dto.processing_order, - watch: dto.watch.as_ref().and_then(|list| { - let compiled: Vec<_> = list - .iter() + execution_plan: TargetExecutionPlan::from_dto(dto), + // An empty `Some` is deliberate and load-bearing: it means the + // operator configured `watch` and every pattern failed to + // compile, which is different from not configuring it at all. + // Collapsing both to `None` is how the feature used to turn + // itself off on a typo with nothing but one `warn!` to show for + // it - `process_watch` now reports the empty case as + // `playlist.watch.disabled`. + watch: dto.watch.as_ref().map(|list| { + list.iter() .filter_map(|s| match shared::model::REGEX_CACHE.get_or_compile(s) { Ok(re) => Some(re), Err(e) => { @@ -318,14 +398,44 @@ impl From<&ConfigTargetDto> for ConfigTarget { None } }) - .collect(); - if compiled.is_empty() { - None - } else { - Some(compiled) - } + .collect() }), use_memory_cache: dto.use_memory_cache, } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execution_plan_compiles_processing_order() { + let dto = ConfigTargetDto { processing_order: ProcessingOrder::Mrf, ..Default::default() }; + + let target = ConfigTarget::from(&dto); + + assert_eq!( + target.execution_plan.transform_stages, + [TransformStage::Map, TransformStage::Rename, TransformStage::Filter] + ); + } + + #[test] + fn execution_plan_preserves_both_deduplication_passes() { + let deduplicate = DeduplicateConfig::default(); + let dto = ConfigTargetDto { + options: Some(ConfigTargetOptions { + remove_duplicates: true, + deduplicate: Some(deduplicate), + ..Default::default() + }), + ..Default::default() + }; + + let target = ConfigTarget::from(&dto); + + assert!(target.execution_plan.pre_transform_identity_dedup); + assert_eq!(target.execution_plan.post_merge_content_dedup, Some(deduplicate)); + } +} diff --git a/backend/core/src/model/config/web_auth.rs b/backend/core/src/model/config/web_auth.rs index c547bba32..650e6e064 100644 --- a/backend/core/src/model/config/web_auth.rs +++ b/backend/core/src/model/config/web_auth.rs @@ -188,6 +188,28 @@ impl WebAuthConfig { permissions } + /// The live permissions for `username`, or `None` when the principal is + /// not a web user at all. + /// + /// `resolve_permissions` cannot express that difference: it answers + /// "no permissions" both for a user with an empty group set and for a + /// principal it has never heard of. A proxy API user is the second case, + /// and treating it as the first would deny every request it makes. + pub fn resolve_permissions_if_known(&self, username: &str) -> Option { + self.t_users + .as_ref()? + .iter() + .any(|candidate| candidate.username.eq_ignore_ascii_case(username)) + .then(|| self.resolve_permissions(username)) + } + + /// The current password version for `username`, or `None` when the + /// principal has no password on file here - proxy API users authenticate + /// against `api_proxy.yml` and carry no password version. + pub fn pwd_version_for(&self, username: &str) -> Option { + self.get_user_password(username).map(Self::pwd_version_from_hash) + } + pub fn pwd_version_from_hash(hash: &str) -> u32 { let digest = blake3::hash(hash.as_bytes()); let bytes = digest.as_bytes(); diff --git a/backend/core/src/model/fingerprint.rs b/backend/core/src/model/fingerprint.rs index 2cba8b2cf..6789a7b6e 100644 --- a/backend/core/src/model/fingerprint.rs +++ b/backend/core/src/model/fingerprint.rs @@ -1,10 +1,12 @@ -type Rejection = (StatusCode, &'static str); +use super::auth_rejection::AuthRejection; use axum::{ extract::{ConnectInfo, FromRequestParts}, - http::{request::Parts, StatusCode}, + http::request::Parts, }; use std::net::SocketAddr; +type Rejection = AuthRejection; + const MAX_HEADER_LENGTH: usize = 512; fn validate_header(value: &str) -> Option { @@ -49,7 +51,7 @@ impl Fingerprint { { let ConnectInfo(addr) = ConnectInfo::::from_request_parts(req, state) .await - .map_err(|_| (StatusCode::BAD_REQUEST, "IP-Addr is missing"))?; + .map_err(|_| AuthRejection::MissingPeerAddress)?; let mut user_agent = None; let mut forwarded_for = None; diff --git a/backend/core/src/model/mapping.rs b/backend/core/src/model/mapping.rs index f751e9216..5a3e7e6f8 100644 --- a/backend/core/src/model/mapping.rs +++ b/backend/core/src/model/mapping.rs @@ -1,93 +1,146 @@ -use crate::model::macros; use shared::{ + error::TuliproxError, foundation::{Filter, MapperScript}, - model::{ - MapperDto, MappingCounter, MappingCounterDefinition, MappingDefinitionDto, MappingDto, MappingStage, - MappingsDto, PatternTemplate, - }, + model::{MapperDto, MappingCounter, MappingDto, MappingStage, MappingsDto, PatternTemplate}, }; +use std::{collections::HashMap, sync::Arc}; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -pub struct Mapper { - pub filter: String, - pub script: String, - #[serde(skip_serializing, skip_deserializing)] - pub t_filter: Option, - #[serde(skip_serializing, skip_deserializing)] - pub t_script: Option, +#[derive(Debug, Clone)] +pub enum MappingProgram { + Script(MapperScript), } -macros::from_impl!(Mapper); -impl From<&MapperDto> for Mapper { - fn from(dto: &MapperDto) -> Self { - Self { - filter: dto.filter.clone(), - script: dto.script.clone(), - t_filter: dto.t_filter.clone(), - t_script: dto.t_script.clone(), - } +#[derive(Debug, Clone)] +pub struct CompiledMappingRule { + pub name: Option, + pub filter: Filter, + pub program: MappingProgram, +} + +impl TryFrom<&MapperDto> for CompiledMappingRule { + type Error = TuliproxError; + + fn try_from(dto: &MapperDto) -> Result { + let filter = + dto.t_filter.clone().ok_or_else(|| TuliproxError::Config("Mapping filter was not prepared".to_string()))?; + let script = + dto.t_script.clone().ok_or_else(|| TuliproxError::Config("Mapping script was not prepared".to_string()))?; + Ok(Self { name: dto.name.clone(), filter, program: MappingProgram::Script(script) }) } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)] -pub struct Mapping { +#[derive(Debug, Clone, Default)] +pub struct CompiledMapping { pub id: String, - #[serde(default)] pub match_as_ascii: bool, - #[serde(default, skip_serializing_if = "MappingStage::is_processing")] pub stage: MappingStage, - pub mapper: Option>, - pub counter: Option>, - #[serde(skip_serializing, skip_deserializing)] - pub t_counter: Option>, - #[serde(skip_serializing, skip_deserializing)] + pub rules: Vec, + pub counters: Vec, pub templates: Option>, } -impl From<&MappingDto> for Mapping { - fn from(dto: &MappingDto) -> Self { - Self { +impl TryFrom<&MappingDto> for CompiledMapping { + type Error = TuliproxError; + + fn try_from(dto: &MappingDto) -> Result { + Ok(Self { id: dto.id.clone(), match_as_ascii: dto.match_as_ascii, stage: dto.stage, - mapper: dto.mapper.as_ref().map(|l| l.iter().map(Mapper::from).collect()), - counter: dto.counter.clone(), - t_counter: dto.t_counter.clone(), + rules: dto + .mapper + .as_deref() + .unwrap_or_default() + .iter() + .enumerate() + .map(|(index, rule)| { + CompiledMappingRule::try_from(rule).map_err(|err| { + let rule_label = rule.name.as_deref().map_or_else(|| (index + 1).to_string(), str::to_string); + TuliproxError::Config(format!("Mapping '{}', rule {rule_label}: {err}", dto.id)) + }) + }) + .collect::, _>>()?, + counters: dto.t_counter.clone().unwrap_or_default(), templates: dto.templates.clone(), + }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct CompiledMappings { + pub mappings: Vec>, + by_id: HashMap, +} + +impl CompiledMappings { + pub fn new(mappings: Vec) -> Self { + let mappings = mappings.into_iter().map(Arc::new).collect::>(); + let mut by_id = HashMap::with_capacity(mappings.len()); + for (index, mapping) in mappings.iter().enumerate() { + by_id.entry(mapping.id.clone()).or_insert(index); } + Self { mappings, by_id } + } + + pub fn get_mapping(&self, mapping_id: &str) -> Option> { + self.by_id.get(mapping_id).map(|index| Arc::clone(&self.mappings[*index])) } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct MappingDefinition { - pub templates: Option>, - pub mapping: Vec, -} +impl TryFrom<&MappingsDto> for CompiledMappings { + type Error = TuliproxError; -macros::from_impl!(MappingDefinition); -impl From<&MappingDefinitionDto> for MappingDefinition { - fn from(dto: &MappingDefinitionDto) -> Self { - Self { templates: dto.templates.clone(), mapping: dto.mapping.iter().map(Into::into).collect() } + fn try_from(dto: &MappingsDto) -> Result { + let mappings = dto.mappings.mapping.iter().map(CompiledMapping::try_from).collect::, _>>()?; + Ok(Self::new(mappings)) } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct Mappings { - pub mappings: MappingDefinition, -} +#[cfg(test)] +mod tests { + use super::*; -impl Mappings { - pub fn get_mapping(&self, mapping_id: &str) -> Option { - for mapping in &self.mappings.mapping { - if mapping.id.eq(mapping_id) { - return Some(mapping.clone()); - } - } - None + #[test] + fn compilation_identifies_unprepared_mapping_rule() { + let dto = MappingDto { + id: "sports".to_string(), + mapper: Some(vec![MapperDto { filter: "Name ~ \".*\"".to_string(), ..Default::default() }]), + ..Default::default() + }; + + let error = CompiledMapping::try_from(&dto).expect_err("unprepared rule must fail compilation"); + + assert!(error.to_string().contains("Mapping 'sports', rule 1")); + assert!(error.to_string().contains("filter was not prepared")); + } + + #[test] + fn compilation_uses_optional_rule_name_in_errors() { + let dto = MappingDto { + id: "sports".to_string(), + mapper: Some(vec![MapperDto { + name: Some("rename premium channels".to_string()), + filter: "Name ~ \".*\"".to_string(), + ..Default::default() + }]), + ..Default::default() + }; + + let error = CompiledMapping::try_from(&dto).expect_err("unprepared rule must fail compilation"); + + assert!(error.to_string().contains("rule rename premium channels")); + } + + #[test] + fn indexed_lookup_returns_the_ordered_shared_mapping() { + let mappings = CompiledMappings::new(vec![ + CompiledMapping { id: "first".to_string(), ..Default::default() }, + CompiledMapping { id: "second".to_string(), ..Default::default() }, + ]); + + let resolved = mappings.get_mapping("second").expect("mapping should resolve"); + + assert!(Arc::ptr_eq(&resolved, &mappings.mappings[1])); + assert!(mappings.get_mapping("missing").is_none()); } } - -macros::from_impl!(Mappings); -impl From<&MappingsDto> for Mappings { - fn from(dto: &MappingsDto) -> Self { Mappings { mappings: MappingDefinition::from(&dto.mappings) } } -} diff --git a/backend/core/src/model/messaging.rs b/backend/core/src/model/messaging.rs index ed38ba002..1c3c80700 100644 --- a/backend/core/src/model/messaging.rs +++ b/backend/core/src/model/messaging.rs @@ -1,13 +1,9 @@ use serde::{Deserialize, Serialize}; use shared::model::{DiskAlert, InputStats, MsgKind, SourceStats}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WatchChanges { - pub target: String, - pub group: String, - pub added: Vec, - pub removed: Vec, -} +// `WatchChanges` and `RecordingLifecycleMessage` moved to `shared` so the +// event taxonomy can name them; re-exported here because `MessageContent` +// and every existing call site refer to them by this path. +pub use shared::model::{RecordingLifecycleMessage, WatchChanges}; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ProcessingStats { @@ -23,25 +19,6 @@ impl ProcessingStats { pub fn new_error(error: String) -> Self { Self { stats: None, errors: Some(error) } } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RecordingLifecycleMessage { - pub event: MsgKind, - #[serde(skip_serializing_if = "Option::is_none")] - pub programme_title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub channel: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub effective_start: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub effective_end: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub visibility: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub output_filename: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub failure_reason: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", content = "data")] pub enum MessageContent { diff --git a/backend/core/src/model/mod.rs b/backend/core/src/model/mod.rs index dac3c00d0..3670f9849 100644 --- a/backend/core/src/model/mod.rs +++ b/backend/core/src/model/mod.rs @@ -3,6 +3,7 @@ mod healthcheck; mod input_source; mod mapping; pub mod messaging; +pub mod notification; pub mod readiness; mod stream_history; mod xmltv; @@ -12,6 +13,7 @@ mod xtream; // in `api`. // Playlist/library update semaphores. No dependencies of their own, and named // by both `api` and `processing`. +pub mod auth_rejection; pub mod batch_result_collector; pub mod custom_stream_flags; pub mod fingerprint; @@ -27,8 +29,9 @@ pub mod user_api_request; pub mod xtream_response; pub use self::{ - batch_result_collector::*, config::*, custom_stream_flags::*, fingerprint::*, healthcheck::*, input_source::*, - mapping::*, messaging::*, playlist_filter::*, playlist_key::*, provider::*, proxy_redirect::*, stalker_record::*, - stream_error::*, stream_history::*, update_guard::*, update_task::*, xmltv::*, xtream::*, + auth_rejection::*, batch_result_collector::*, config::*, custom_stream_flags::*, fingerprint::*, healthcheck::*, + input_source::*, mapping::*, messaging::*, notification::*, playlist_filter::*, playlist_key::*, provider::*, + proxy_redirect::*, stalker_record::*, stream_error::*, stream_history::*, update_guard::*, update_task::*, + xmltv::*, xtream::*, }; pub use shared::model::xtream_const::*; diff --git a/backend/core/src/model/notification.rs b/backend/core/src/model/notification.rs new file mode 100644 index 000000000..67ce04c96 --- /dev/null +++ b/backend/core/src/model/notification.rs @@ -0,0 +1,436 @@ +//! The notification event envelope. +//! +//! [`TemplateContext`](super::TemplateContext) carries one `Option` field +//! per message kind - `message`, `stats`, `watch`, `processing`, `disk`, +//! `recording`, `flat_stats` - which is why adding a kind used to touch the +//! renderer. [`NotificationEvent`] replaces that with a single shape every +//! event fits, so a channel can render any event without knowing its type. +//! +//! The typed payloads ([`ProcessingStats`], [`DiskAlert`], +//! [`RecordingLifecycleMessage`], [`WatchChanges`]) are unchanged; they now +//! travel serialized in [`NotificationEvent::fields`]. + +use super::messaging::{MessageContent, RecordingLifecycleMessage}; +use chrono::Utc; +use shared::{ + model::{ + notification::{registry, EventId, Severity}, + DiskAlert, MsgKind, + }, + utils::human_readable_byte_size, +}; +use std::{fmt::Write, sync::Arc}; + +/// One thing that happened, in the shape every channel can render. +/// +/// `title` and `body` are always populated, which is what lets Pushover, a +/// syslog channel or an email subject line render an event correctly +/// without a per-kind match - the gap that made Pushover emit raw JSON to +/// phones. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct NotificationEvent { + pub id: EventId, + pub severity: Severity, + /// Unix seconds. Matches the outbox's `enqueued_at` representation and + /// avoids pulling chrono's serde feature into the workspace. + pub timestamp: i64, + /// Which tuliprox this came from. Populated from config for multi-host + /// setups where one chat receives several instances. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance: Option>, + /// Suppression identity. Two events with the same key inside the + /// suppression window are the same news told twice. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dedup_key: Option, + /// One-line summary. Always present. + pub title: String, + /// Plain-text fallback body. Always present. + pub body: String, + /// The typed payload, serialized. Templates reach it as `event.fields.*`. + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub fields: serde_json::Value, +} + +impl NotificationEvent { + /// Build an event with the registry's default severity for `id`. + pub fn new(id: EventId, title: impl Into, body: impl Into) -> Self { + Self { + id, + severity: registry::default_severity(id), + timestamp: Utc::now().timestamp(), + instance: None, + dedup_key: None, + title: title.into(), + body: body.into(), + fields: serde_json::Value::Null, + } + } + + /// RFC 3339 rendering of [`NotificationEvent::timestamp`], for templates. + #[must_use] + pub fn timestamp_rfc3339(&self) -> String { + chrono::DateTime::from_timestamp(self.timestamp, 0).unwrap_or_else(Utc::now).to_rfc3339() + } + + #[must_use] + pub fn with_severity(mut self, severity: Severity) -> Self { + self.severity = severity; + self + } + + /// Set the suppression identity. See [`NotificationEvent::dedup_key`]. + #[must_use] + pub fn with_dedup_key(mut self, key: impl Into) -> Self { + self.dedup_key = Some(key.into()); + self + } + + #[must_use] + pub fn with_instance(mut self, instance: Option>) -> Self { + self.instance = instance; + self + } + + /// Attach the typed payload. A payload that fails to serialize is + /// dropped rather than failing the event - `title` and `body` already + /// carry enough for every channel to render something useful. + #[must_use] + pub fn with_fields(mut self, payload: &T) -> Self { + self.fields = serde_json::to_value(payload).unwrap_or(serde_json::Value::Null); + self + } + + /// Lift a legacy [`MessageContent`] into the envelope. + /// + /// Every existing emitter constructs `MessageContent`, so this is the + /// bridge that lets the new pipeline carry them unchanged. + #[must_use] + pub fn from_content(content: &MessageContent) -> Self { + let id = event_id_for(content); + let title = title_for(content); + let body = body_for(content); + let mut event = Self::new(id, title, body); + event.fields = fields_for(content); + event + } +} + +/// Canonical id for a legacy message. +#[must_use] +pub fn event_id_for(content: &MessageContent) -> EventId { + match content { + MessageContent::Info(_) => registry::SYSTEM_INFO, + MessageContent::Error(_) => registry::SYSTEM_ERROR, + MessageContent::Watch(_) => registry::PLAYLIST_WATCH_CHANGED, + MessageContent::ProcessingStats(stats) => { + // Matches the legacy `MessageContent::kind` split: errors with no + // stats are a failed update, anything else is a completed one. + if stats.errors.is_some() && stats.stats.is_none() { + registry::PLAYLIST_UPDATE_FAILED + } else { + registry::PLAYLIST_UPDATE_COMPLETED + } + } + MessageContent::DiskAlert(_) => registry::SYSTEM_DISK_ALERT, + MessageContent::RecordingLifecycle(recording) => match recording.event { + MsgKind::RecordingStarted => registry::RECORDING_STARTED, + MsgKind::RecordingCompleted => registry::RECORDING_COMPLETED, + MsgKind::RecordingFailed => registry::RECORDING_FAILED, + _ => registry::SYSTEM_INFO, + }, + } +} + +/// Human-readable one-liner for a disk alert. +#[must_use] +pub fn disk_alert_text(alert: &DiskAlert) -> String { + format!( + "Disk usage {}: {:.1}% used ({} of {}), {} free.", + alert.level, + alert.percent, + human_readable_byte_size(alert.used_bytes), + human_readable_byte_size(alert.total_bytes), + human_readable_byte_size(alert.free_bytes), + ) +} + +/// Label for a recording lifecycle event. +#[must_use] +pub fn recording_label(event: MsgKind) -> &'static str { + match event { + MsgKind::RecordingStarted => "Recording started", + MsgKind::RecordingCompleted => "Recording completed", + MsgKind::RecordingFailed => "Recording failed", + _ => "Recording lifecycle event", + } +} + +/// Human-readable one-liner for a recording lifecycle event. +#[must_use] +pub fn recording_lifecycle_text(recording: &RecordingLifecycleMessage) -> String { + let label = recording_label(recording.event); + let title = recording.programme_title.as_deref().unwrap_or("Untitled"); + let channel = recording.channel.as_deref().unwrap_or("unknown channel"); + match recording.failure_reason.as_deref() { + Some(reason) => format!("{label}: {title} on {channel} ({reason})"), + None => format!("{label}: {title} on {channel}"), + } +} + +/// Short summary line - the subject line of the event. +fn title_for(content: &MessageContent) -> String { + match content { + MessageContent::Info(s) | MessageContent::Error(s) => first_line(s), + MessageContent::Watch(w) => { + // The totals, not the list lengths: a large change ships a sample + // and the subject line must still report what actually happened. + format!("{} channel(s) added, {} removed in {}/{}", w.added_total, w.removed_total, w.target, w.group) + } + MessageContent::ProcessingStats(stats) => match (&stats.stats, &stats.errors) { + (None, Some(_)) => "Playlist update failed".to_string(), + (Some(sources), _) => { + let inputs: usize = sources.iter().map(|s| s.inputs.len()).sum(); + let targets: usize = sources.iter().map(|s| s.targets.len()).sum(); + format!("Playlist update finished: {inputs} input(s), {targets} target(s)") + } + (None, None) => "Playlist update finished".to_string(), + }, + MessageContent::DiskAlert(alert) => { + format!("Disk usage {}: {:.1}% used", alert.level, alert.percent) + } + MessageContent::RecordingLifecycle(recording) => { + let label = recording_label(recording.event); + match recording.programme_title.as_deref() { + Some(title) => format!("{label}: {title}"), + None => label.to_string(), + } + } + } +} + +/// One direction of a watch change, rendered for a plain-text channel. +/// +/// `names` may be a sample of `total` - or empty, when the change was too +/// large to list - so the omission is spelled out here rather than smuggled +/// into the list itself. +fn push_watch_section(out: &mut String, label: &str, names: &[String], total: usize) { + if total == 0 { + return; + } + let _ = write!(out, "\n\n{label} ({total}):"); + if names.is_empty() { + out.push_str("\n(list suppressed for a large update)"); + return; + } + for name in names { + out.push('\n'); + out.push_str(name); + } + let omitted = total.saturating_sub(names.len()); + if omitted > 0 { + let _ = write!(out, "\n... {omitted} more not listed"); + } +} + +/// The plain-text rendering used when a channel has no template. +/// +/// Preserves the legacy `default_text_for` output for the string and disk +/// variants, so a channel that was already readable stays byte-identical. +fn body_for(content: &MessageContent) -> String { + match content { + MessageContent::Info(s) | MessageContent::Error(s) => s.clone(), + MessageContent::Watch(w) => { + let mut out = format!("Watched group {}/{} changed.", w.target, w.group); + push_watch_section(&mut out, "Added", &w.added, w.added_total); + push_watch_section(&mut out, "Removed", &w.removed, w.removed_total); + out + } + MessageContent::ProcessingStats(stats) => { + let mut out = String::new(); + if let Some(sources) = &stats.stats { + for source in sources { + for input in &source.inputs { + let _ = writeln!( + out, + "{} ({:?}): {} group(s), {} channel(s), {} error(s)", + input.name, + input.input_type, + input.processed_stats.group_count, + input.processed_stats.channel_count, + input.error_count, + ); + } + } + } + if let Some(errors) = &stats.errors { + out.push_str("\nErrors:\n"); + out.push_str(errors); + } + if out.trim().is_empty() { + "Playlist update finished.".to_string() + } else { + out.trim_end().to_string() + } + } + MessageContent::DiskAlert(alert) => disk_alert_text(alert), + MessageContent::RecordingLifecycle(recording) => recording_lifecycle_text(recording), + } +} + +/// The typed payload, serialized for template access. +fn fields_for(content: &MessageContent) -> serde_json::Value { + let value = match content { + MessageContent::Info(s) | MessageContent::Error(s) => serde_json::to_value(s), + MessageContent::Watch(w) => serde_json::to_value(w), + MessageContent::ProcessingStats(ps) => serde_json::to_value(ps), + MessageContent::DiskAlert(alert) => serde_json::to_value(alert), + MessageContent::RecordingLifecycle(recording) => serde_json::to_value(recording), + }; + value.unwrap_or(serde_json::Value::Null) +} + +/// Telegram and Pushover both truncate hard; a title longer than this is +/// never useful and pushes the body out of the preview. +const MAX_TITLE_CHARS: usize = 120; + +fn first_line(s: &str) -> String { + let line = s.lines().next().unwrap_or(s).trim(); + if line.chars().count() > MAX_TITLE_CHARS { + let truncated: String = line.chars().take(MAX_TITLE_CHARS - 1).collect(); + format!("{truncated}\u{2026}") + } else { + line.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::{super::messaging::ProcessingStats, *}; + use shared::model::{DiskAlertLevel, InputStats, InputType, PlaylistStats, SourceStats, TargetStats}; + + fn stats_content() -> MessageContent { + let input = InputStats { + name: "Provider A".to_string(), + input_type: InputType::M3u, + error_count: 2, + raw_stats: PlaylistStats { group_count: 100, channel_count: 1000 }, + processed_stats: PlaylistStats { group_count: 50, channel_count: 500 }, + secs_took: 12, + }; + MessageContent::ProcessingStats(ProcessingStats::new_stats(vec![SourceStats { + inputs: vec![input], + targets: vec![TargetStats::success("Target 1")], + }])) + } + + #[test] + fn legacy_kinds_map_onto_the_canonical_ids() { + assert_eq!(event_id_for(&MessageContent::Info(String::new())), registry::SYSTEM_INFO); + assert_eq!(event_id_for(&MessageContent::Error(String::new())), registry::SYSTEM_ERROR); + assert_eq!(event_id_for(&stats_content()), registry::PLAYLIST_UPDATE_COMPLETED); + } + + #[test] + fn processing_stats_with_only_errors_is_a_failed_update() { + // Mirrors the legacy `MessageContent::kind` split, which reported + // `MsgKind::Error` for a stats message carrying only errors. + let content = MessageContent::ProcessingStats(ProcessingStats::new_error("boom".to_string())); + assert_eq!(event_id_for(&content), registry::PLAYLIST_UPDATE_FAILED); + } + + #[test] + fn every_event_has_a_non_empty_title_and_body() { + // The whole point of the envelope: a channel with no template can + // always render something a human can read. + let contents = vec![ + MessageContent::Info("hello".to_string()), + MessageContent::Error("bang".to_string()), + stats_content(), + MessageContent::ProcessingStats(ProcessingStats::new_error("boom".to_string())), + MessageContent::DiskAlert(DiskAlert { + level: DiskAlertLevel::Critical, + total_bytes: 1_000_000, + free_bytes: 50_000, + used_bytes: 950_000, + percent: 95.0, + }), + MessageContent::RecordingLifecycle(RecordingLifecycleMessage { + event: MsgKind::RecordingCompleted, + programme_title: Some("The Programme".to_string()), + channel: Some("Channel 1".to_string()), + effective_start: None, + effective_end: None, + visibility: None, + output_filename: None, + failure_reason: None, + }), + ]; + for content in contents { + let event = NotificationEvent::from_content(&content); + assert!(!event.title.trim().is_empty(), "empty title for {:?}", event.id); + assert!(!event.body.trim().is_empty(), "empty body for {:?}", event.id); + } + } + + #[test] + fn watch_body_lists_added_and_removed_instead_of_dumping_json() { + // Regression: the legacy default text for a watch change was a raw + // `serde_json` dump, which is what Pushover received. + let content = MessageContent::Watch(super::super::messaging::WatchChanges::new( + "T".to_string(), + "G".to_string(), + vec!["Channel A".to_string()], + vec!["Channel B".to_string()], + )); + let event = NotificationEvent::from_content(&content); + assert!(event.body.contains("Channel A"), "body missing added channel: {}", event.body); + assert!(event.body.contains("Channel B"), "body missing removed channel: {}", event.body); + assert!(!event.body.starts_with('{'), "body is still a json dump: {}", event.body); + } + + #[test] + fn severity_defaults_come_from_the_registry() { + let failed = NotificationEvent::from_content(&MessageContent::ProcessingStats(ProcessingStats::new_error( + "boom".to_string(), + ))); + assert_eq!(failed.severity, Severity::Error); + let ok = NotificationEvent::from_content(&stats_content()); + assert_eq!(ok.severity, Severity::Info); + } + + #[test] + fn fields_carry_the_typed_payload_for_templates() { + let event = NotificationEvent::from_content(&stats_content()); + let rendered = event.fields.to_string(); + assert!(rendered.contains("Provider A"), "typed payload lost: {rendered}"); + } + + #[test] + fn a_long_info_title_is_truncated_to_one_line() { + let long = format!("{}\nsecond line", "x".repeat(500)); + let event = NotificationEvent::from_content(&MessageContent::Info(long)); + assert!(event.title.chars().count() <= 120, "title not truncated: {}", event.title.len()); + assert!(!event.title.contains('\n'), "title spans lines"); + // The body keeps everything. + assert!(event.body.contains("second line")); + } + + #[test] + fn builders_override_severity_and_dedup_key() { + let event = NotificationEvent::new(registry::SYSTEM_INFO, "t", "b") + .with_severity(Severity::Critical) + .with_dedup_key("disk:critical"); + assert_eq!(event.severity, Severity::Critical); + assert_eq!(event.dedup_key.as_deref(), Some("disk:critical")); + } + + #[test] + fn envelope_round_trips_through_json() { + let event = NotificationEvent::from_content(&stats_content()).with_dedup_key("k"); + let bytes = serde_json::to_vec(&event).expect("serialize"); + let restored: NotificationEvent = serde_json::from_slice(&bytes).expect("deserialize"); + assert_eq!(restored.id, event.id); + assert_eq!(restored.title, event.title); + assert_eq!(restored.dedup_key, event.dedup_key); + } +} diff --git a/backend/core/src/model/provider.rs b/backend/core/src/model/provider.rs index 95d2051bd..3010316ae 100644 --- a/backend/core/src/model/provider.rs +++ b/backend/core/src/model/provider.rs @@ -29,6 +29,18 @@ pub enum ProviderAllocation { } impl ProviderAllocation { + /// The provider this allocation landed on, if it landed on one. + /// + /// Both success variants carry a provider and callers that only need + /// "which one" should not have to match on why it was granted. + #[must_use] + pub fn provider(&self) -> Option<&Arc> { + match self { + Self::Exhausted => None, + Self::Available(config) | Self::GracePeriod(config) => Some(config), + } + } + pub fn short_key(&self) -> &str { match self { ProviderAllocation::Exhausted => "exhausted", diff --git a/backend/core/src/model/xtream.rs b/backend/core/src/model/xtream.rs index fa954e366..cb2a3d824 100644 --- a/backend/core/src/model/xtream.rs +++ b/backend/core/src/model/xtream.rs @@ -134,7 +134,6 @@ mod tests { }; use arc_swap::{ArcSwap, ArcSwapOption}; use shared::{ - error::TuliproxError, foundation::Filter, model::{ConfigPaths, InputFetchMethod, InputType, PlaylistItemType, ProcessingOrder}, utils::Internable, @@ -198,6 +197,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::default()), favourites: None, processing_order: ProcessingOrder::Frm, + execution_plan: crate::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, }; @@ -216,7 +216,11 @@ mod tests { assert!(result.is_err(), "missing server info must not degrade to an empty base_url"); let err = result.err().unwrap_or_else(|| unreachable!()); - assert!(matches!(err, TuliproxError::ApiXtream(_)), "missing server info should surface as ApiXtream error"); + assert_eq!( + err.kind(), + shared::error::ErrorKind::ApiXtream, + "missing server info should surface as ApiXtream error" + ); assert!( err.to_string().contains("No server info configured"), "error should explicitly mention missing server info: {err}" diff --git a/backend/core/src/utils/clock.rs b/backend/core/src/utils/clock.rs new file mode 100644 index 000000000..a8dc5df58 --- /dev/null +++ b/backend/core/src/utils/clock.rs @@ -0,0 +1,136 @@ +//! A clock seam, so "what time is it" can be a dependency rather than a global. +//! +//! # Why a trait at all +//! +//! `fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis() +//! .try_into().unwrap_or_default() }` was duplicated **character for character +//! in 14 files** across `backend/app` and `backend/hls`. This module holds the +//! single copy. +//! +//! # Why a generic parameter and not `Arc` +//! +//! [`SystemClock`] is zero-sized. A struct that owns one is exactly the size it +//! was before, `now_ms()` inlines to the same instructions the direct call +//! emitted, and there is no vtable and no allocation anywhere in the production +//! path. Hold it as `C: Clock` with `SystemClock` as the default type +//! parameter — `struct Deadlines { clock: C }` — so +//! existing construction sites keep compiling and only tests name the other +//! implementor. The `Arc` in [`ManualClock`] is confined to the test-support +//! type, which is the point. +//! +//! # What deliberately does not use this +//! +//! Most of the deadline logic in `tuliprox-hls` already takes `now_ms: u64` as a +//! function parameter — `HlsAccessLease`, `HlsAvailabilityReevaluationCycle` and +//! their neighbours are already injectable and already tested that way. Passing +//! the instant in is a better pattern than reaching for a clock, so those are +//! left alone; this trait is for the callers that have to *produce* the instant. +//! +//! `HlsTerminalCommitClock` is also left alone. It fakes time with an +//! `AtomicU64` sentinel, which costs an atomic load per read in production and +//! would be a natural fit here, but it is owned by `HlsProxy` — so making it +//! generic would push a type parameter onto `HlsProxy` and from there onto +//! `AppState`. That trade is not worth it, and a type parameter that infects the +//! root state is exactly the case where the enum or the status quo wins. + +use shared::model::Millis; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; + +/// Milliseconds since the Unix epoch. +/// +/// Saturates to `0` rather than panicking on a pre-epoch or out-of-range clock, +/// matching the behaviour of the 14 copies this replaces. +#[inline] +#[must_use] +pub fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } + +/// Reads wall-clock time. +/// +/// Implementors are held as a generic parameter; see the module docs for why +/// this is never a trait object. +pub trait Clock: Clone + Send + Sync + 'static { + fn now_ms(&self) -> Millis; +} + +/// The real clock. Zero-sized: no field, no vtable, no allocation. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemClock; + +impl Clock for SystemClock { + #[inline] + fn now_ms(&self) -> Millis { Millis::new(current_time_millis()) } +} + +/// A clock the caller sets, for tests that need time to be deterministic. +/// +/// Cloning shares the underlying instant, so a clone handed to the code under +/// test observes advances made through the original. +#[derive(Debug, Clone, Default)] +pub struct ManualClock(Arc); + +impl ManualClock { + #[must_use] + pub fn new(now_ms: u64) -> Self { Self(Arc::new(AtomicU64::new(now_ms))) } + + pub fn set(&self, now_ms: u64) { self.0.store(now_ms, Ordering::Release); } + + /// Move time forward, saturating rather than wrapping. + pub fn advance(&self, delta_ms: u64) { + self.0.fetch_update(Ordering::Release, Ordering::Acquire, |now| Some(now.saturating_add(delta_ms))).ok(); + } +} + +impl Clock for ManualClock { + #[inline] + fn now_ms(&self) -> Millis { Millis::new(self.0.load(Ordering::Acquire)) } +} + +#[cfg(test)] +mod tests { + use super::{Clock, ManualClock, SystemClock}; + use shared::model::Millis; + + /// The shape production code is expected to use: a generic parameter + /// defaulted to the ZST, so existing construction sites keep compiling. + #[allow(dead_code)] + struct Deadlines { + clock: C, + budget_ms: u64, + } + + #[test] + fn system_clock_is_zero_sized_so_owning_one_costs_nothing() { + assert_eq!(size_of::(), 0); + // The whole point: adding the clock did not change the layout. + assert_eq!(size_of::>(), size_of::()); + } + + #[test] + fn system_clock_reports_a_plausible_epoch_millisecond() { + // 2020-01-01 in ms; a sanity floor rather than a precise assertion. + assert!(SystemClock.now_ms() > Millis::new(1_577_836_800_000)); + } + + #[test] + fn manual_clock_is_deterministic_and_shared_across_clones() { + let clock = ManualClock::new(1_000); + let handed_to_code_under_test = clock.clone(); + assert_eq!(handed_to_code_under_test.now_ms(), Millis::new(1_000)); + + clock.advance(500); + assert_eq!(handed_to_code_under_test.now_ms(), Millis::new(1_500)); + + clock.set(42); + assert_eq!(handed_to_code_under_test.now_ms(), Millis::new(42)); + } + + #[test] + fn manual_clock_advance_saturates() { + let clock = ManualClock::new(u64::MAX - 1); + clock.advance(10); + assert_eq!(clock.now_ms(), Millis::new(u64::MAX)); + } +} diff --git a/backend/core/src/utils/file/mapping_reader.rs b/backend/core/src/utils/file/mapping_reader.rs index 35887551b..262272142 100644 --- a/backend/core/src/utils/file/mapping_reader.rs +++ b/backend/core/src/utils/file/mapping_reader.rs @@ -1,11 +1,11 @@ use crate::{ - model::Mappings, + model::CompiledMappings, utils::{config_file_reader, open_file}, }; use log::warn; use shared::{ error::TuliproxError, - model::{MappingDefinitionDto, MappingDto, MappingsDto, PatternTemplate}, + model::{MappingDefinitionDto, MappingDto, MappingsDto, PatternTemplate, Prepare}, }; use std::{ collections::HashMap, @@ -55,6 +55,15 @@ fn merge_mappings(mappings: Vec<(PathBuf, MappingDto)>) -> Result Result, Mappings)>, TuliproxError> { +) -> Result, CompiledMappings)>, TuliproxError> { match read_mappings_file(mappings_file, resolve_env)? { - Some((paths, dto)) => Ok(Some((paths, Mappings::from(&dto)))), + Some((paths, dto)) => CompiledMappings::try_from(&dto).map(|mappings| Some((paths, mappings))), None => Ok(None), } } diff --git a/backend/core/src/utils/mod.rs b/backend/core/src/utils/mod.rs index 5c8924458..43652026d 100644 --- a/backend/core/src/utils/mod.rs +++ b/backend/core/src/utils/mod.rs @@ -1,6 +1,7 @@ pub mod atomic_json_store; mod binary_utils; pub mod byte_range; +mod clock; mod compression; mod crypto_utils; mod epg_id; @@ -68,6 +69,7 @@ macro_rules! with { pub use self::{ atomic_json_store::*, binary_utils::*, + clock::*, compression::*, crypto_utils::*, epg_id::*, diff --git a/backend/dvr/Cargo.toml b/backend/dvr/Cargo.toml index f54c3193e..9807cda12 100644 --- a/backend/dvr/Cargo.toml +++ b/backend/dvr/Cargo.toml @@ -14,7 +14,6 @@ tuliprox-auth = { workspace = true } tuliprox-core = { workspace = true } tuliprox-messaging = { workspace = true } tuliprox-repository = { workspace = true } -tuliprox-session = { workspace = true } arc-swap.workspace = true chrono.workspace = true chrono-tz = "0.10.4" diff --git a/backend/dvr/src/recording/recording_catalog_access.rs b/backend/dvr/src/recording/recording_catalog_access.rs index a52184b1a..a778c277c 100644 --- a/backend/dvr/src/recording/recording_catalog_access.rs +++ b/backend/dvr/src/recording/recording_catalog_access.rs @@ -112,7 +112,7 @@ pub fn authorize_catalog_entry( } // Orphan entries are administrator-only. if entry.is_orphan_only() { - let admin = claims.roles.iter().any(|r| r == shared::model::ROLE_ADMIN); + let admin = claims.is_admin(); if !admin { return Err(CatalogAccessError::Forbidden); } @@ -244,7 +244,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: perms.into(), pwd_version: 0, subject_id: subject, @@ -330,7 +330,7 @@ mod tests { #[test] fn legacy_admin_recording_visible_to_admins() { let mut claims = make_claims("admin", Some(UserId::builtin_admin()), Permission::RecordingRead); - claims.roles.push(shared::model::ROLE_ADMIN.to_string()); + claims.roles.set(shared::model::Role::Admin); let entry = make_orphan_entry(); let d = authorize_catalog_entry(&claims, &UserId::builtin_admin(), &entry); assert!(d.is_ok()); @@ -341,7 +341,7 @@ mod tests { // Even an admin without `recording.read` cannot see orphan // entries (the read permission is the table-stakes gate). let mut claims = make_claims("admin", Some(UserId::builtin_admin()), Permission::ConfigRead); - claims.roles.push(shared::model::ROLE_ADMIN.to_string()); + claims.roles.set(shared::model::Role::Admin); let entry = make_orphan_entry(); let d = authorize_catalog_entry(&claims, &UserId::builtin_admin(), &entry); assert!(matches!(d, Err(CatalogAccessError::MissingPermission))); diff --git a/backend/dvr/src/recording/recording_ctx.rs b/backend/dvr/src/recording/recording_ctx.rs index b9faeb4b2..bebdccfbe 100644 --- a/backend/dvr/src/recording/recording_ctx.rs +++ b/backend/dvr/src/recording/recording_ctx.rs @@ -8,24 +8,24 @@ use crate::download::DownloadQueue; use arc_swap::ArcSwap; use reqwest::Client; +use shared::model::EventSink; use std::sync::Arc; use tuliprox_core::model::AppConfig; -use tuliprox_session::EventManager; /// Everything the DVR reads from the running server. #[derive(Clone)] -pub struct RecordingCtx { +pub struct RecordingCtx { /// Resolved configuration; re-read on each use because it is hot-swapped. pub app_config: Arc, /// The recording queue: scheduled, active and finished tasks. pub downloads: Arc, /// Where `RecordingChanged` and `RecordingRulesChanged` are published. - pub event_manager: Arc, + pub events: E, /// Shared HTTP client, swapped when the proxy configuration changes. pub http_client: Arc>, } -impl RecordingCtx { +impl RecordingCtx { /// The configured storage directory. pub fn storage_dir(&self) -> String { self.app_config.config.load().storage_dir.clone() } } diff --git a/backend/dvr/src/recording/recording_rule_scheduler.rs b/backend/dvr/src/recording/recording_rule_scheduler.rs index c61d02c16..7b2992458 100644 --- a/backend/dvr/src/recording/recording_rule_scheduler.rs +++ b/backend/dvr/src/recording/recording_rule_scheduler.rs @@ -26,7 +26,8 @@ use log::{debug, error}; use shared::model::{ recording::{RecordingProvenance, RecordingVisibility}, recording_rule::{RecordingRule, RuleBody, RuleSource, RuleVisibility, TombstoneSet}, - Claims, EpgProgramme, Permission, PermissionSet, XtreamCluster, CURRENT_PERMISSION_SCHEMA_VERSION, ROLE_ADMIN, + Claims, EpgProgramme, EventSink, Permission, PermissionSet, RoleSet, XtreamCluster, + CURRENT_PERMISSION_SCHEMA_VERSION, }; use tokio_util::sync::CancellationToken; use tuliprox_repository::recording_rule_repository::RecordingRuleRepository; @@ -189,7 +190,10 @@ pub fn plan_materializations( out } -pub fn spawn_recording_rule_scheduler(ctx: &RecordingCtx, cancel_token: &CancellationToken) { +pub fn spawn_recording_rule_scheduler( + ctx: &RecordingCtx, + cancel_token: &CancellationToken, +) { let ctx = ctx.clone(); let cancel_token = cancel_token.clone(); tokio::spawn(async move { @@ -205,7 +209,7 @@ pub fn spawn_recording_rule_scheduler(ctx: &RecordingCtx, cancel_token: &Cancell }); } -async fn materialize_due_rules(ctx: &RecordingCtx) -> Result<(), String> { +async fn materialize_due_rules(ctx: &RecordingCtx) -> Result<(), String> { // `recording.enabled: false` has to stop the scheduler too. Without // this the routes refuse every request while this loop keeps quietly // materializing tasks the worker will then run — the worst of both @@ -299,7 +303,7 @@ fn warn_about_inert_new_episode_rules(rules: &[RecordingRule], epg_programmes: & ); } -pub async fn reconcilable_tasks(ctx: &RecordingCtx) -> Vec { +pub async fn reconcilable_tasks(ctx: &RecordingCtx) -> Vec { let mut tasks = Vec::new(); for task in ctx.downloads.queue.lock().await.iter() { push_reconcilable(&mut tasks, task); @@ -333,7 +337,7 @@ fn push_reconcilable(tasks: &mut Vec, task: &FileDownload) { fn scheduler_claims(owner_id: shared::model::UserId, visibility: RuleVisibility) -> Claims { let mut permissions = PermissionSet::new(); permissions.set(Permission::RecordingWrite); - let roles = if visibility == RuleVisibility::Shared { vec![ROLE_ADMIN.to_string()] } else { Vec::new() }; + let roles = if visibility == RuleVisibility::Shared { RoleSet::ADMIN } else { RoleSet::new() }; let now = Utc::now().timestamp(); Claims { username: "recording-scheduler".to_string(), diff --git a/backend/dvr/src/recording/recording_service.rs b/backend/dvr/src/recording/recording_service.rs index 4fc8c6880..ac2c1b4c9 100644 --- a/backend/dvr/src/recording/recording_service.rs +++ b/backend/dvr/src/recording/recording_service.rs @@ -14,7 +14,7 @@ use crate::{ }; use shared::model::{ recording::{RecordingMetadata, RecordingOwner, RecordingProvenance, RecordingSource, RecordingVisibility}, - UserId, XtreamCluster, + EventSink, UserId, XtreamCluster, }; use std::{collections::HashMap, path::Path, sync::Arc}; use tuliprox_auth::{authorize, authorize_orphan, RecordingAction, RecordingDecision, RecordingSubject, TerminalState}; @@ -256,7 +256,9 @@ impl RecordingService { pub fn new(downloads: Arc, app_config: Arc) -> Self { Self { downloads, app_config } } /// Convenience constructor from the DVR's context. - pub fn from_ctx(ctx: &RecordingCtx) -> Self { Self::new(ctx.downloads.clone(), ctx.app_config.clone()) } + pub fn from_ctx(ctx: &RecordingCtx) -> Self { + Self::new(ctx.downloads.clone(), ctx.app_config.clone()) + } fn subject_id(claims: &shared::model::Claims) -> Result { claims.subject_id.clone().ok_or(ServiceError::UnknownOwner) @@ -1505,7 +1507,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: Permission::RecordingWrite.into(), pwd_version: 0, subject_id: Some(UserId::from("web:alice")), @@ -1538,7 +1540,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: Permission::RecordingWrite.into(), pwd_version: 0, subject_id: Some(UserId::from("web:alice")), @@ -1580,7 +1582,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: Permission::RecordingWrite.into(), pwd_version: 0, subject_id: Some(UserId::from("web:alice")), @@ -1688,7 +1690,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: Permission::RecordingWrite.into(), pwd_version: 0, subject_id: Some(UserId::from("web:alice")), @@ -1721,7 +1723,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: Permission::RecordingWrite.into(), pwd_version: 0, subject_id: Some(UserId::from("web:alice")), @@ -1761,7 +1763,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: Permission::RecordingWrite.into(), pwd_version: 0, subject_id: Some(UserId::from("web:alice")), diff --git a/backend/dvr/src/recording/recording_supervisor/mod.rs b/backend/dvr/src/recording/recording_supervisor/mod.rs index a2d9f976f..8ecef2bbd 100644 --- a/backend/dvr/src/recording/recording_supervisor/mod.rs +++ b/backend/dvr/src/recording/recording_supervisor/mod.rs @@ -26,13 +26,13 @@ //! - [`health`] — last-tick timestamps and counters //! - [`startup`] — crash-recovery reconciliation at boot //! - [`retention`] — age / count / disk-pressure sweeps -//! - [`outbox`] — durable, per-channel notification retry +//! - notification retry lives in `tuliprox-messaging::outbox` //! - [`mod`] (this file) — shared helpers, the entry point, and the //! cross-cutting tests use super::recording_ctx::RecordingCtx; use log::info; -use shared::model::{Claims, Permission, PermissionSet, CURRENT_PERMISSION_SCHEMA_VERSION, ROLE_ADMIN}; +use shared::model::{Claims, EventSink, Permission, PermissionSet, RoleSet, CURRENT_PERMISSION_SCHEMA_VERSION}; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -41,7 +41,6 @@ use tokio_util::sync::CancellationToken; use tuliprox_core::model::{AppConfig, RecordingConfig}; pub mod health; -pub mod outbox; pub mod retention; pub mod startup; @@ -49,9 +48,12 @@ pub mod startup; // (`crate::recording::recording_supervisor::*`) keep // working without an import change. pub use health::{supervisor_health, SupervisorHealth}; -pub use outbox::{notification_outbox, spawn_notification_outbox, NotificationOutbox}; pub use retention::spawn_retention_supervisor; pub use startup::run_startup_reconciliation; +// The outbox now lives in `tuliprox-messaging` and carries every +// notification, not just recording lifecycle ones. Re-exported here so +// existing `recording_supervisor::*` callers need no import change. +pub use tuliprox_messaging::outbox::{notification_outbox, NotificationOutbox}; /// The effective recording configuration, cloned out of the `ArcSwap` /// guard so no guard is held across an await. @@ -101,7 +103,7 @@ pub fn system_claims() -> Claims { iss: "tuliprox".to_string(), iat: now, exp: now + 3600, - roles: vec![ROLE_ADMIN.to_string()], + roles: RoleSet::ADMIN, permissions, pwd_version: 0, subject_id: Some(shared::model::UserId::builtin_admin()), @@ -124,7 +126,10 @@ impl Drop for PassGuard { /// Start every DVR supervisor. Called once the HTTP listener is bound so /// the reconciliation pass cannot delay the bind. -pub async fn start_recording_supervisors(ctx: &RecordingCtx, cancel_token: &CancellationToken) { +pub async fn start_recording_supervisors( + ctx: &RecordingCtx, + cancel_token: &CancellationToken, +) { if !recording_enabled(&ctx.app_config) { info!("Recording is disabled; DVR supervisors not started"); return; @@ -132,7 +137,6 @@ pub async fn start_recording_supervisors(ctx: &RecordingCtx, cancel_token: &Canc // Reconcile before anything else can materialize or sweep, so the // scheduler never plans against half-repaired state. run_startup_reconciliation(ctx).await; - spawn_notification_outbox(ctx, cancel_token); spawn_retention_supervisor(ctx, cancel_token); } diff --git a/backend/dvr/src/recording/recording_supervisor/outbox.rs b/backend/dvr/src/recording/recording_supervisor/outbox.rs deleted file mode 100644 index 35ecb73ed..000000000 --- a/backend/dvr/src/recording/recording_supervisor/outbox.rs +++ /dev/null @@ -1,393 +0,0 @@ -//! Notification outbox. -//! -//! The recorder used to `tokio::spawn(send_message(..))` straight from -//! its persist path, so a transient provider error lost the -//! notification permanently and a crash between the persist and the -//! spawn lost it too. The worker in this module owns delivery instead: -//! entries are persisted to -//! `storage_dir/recording_notification_outbox.json` before the first -//! attempt, retried per channel with capped exponential backoff, and -//! dead-lettered with a log line after `max_attempts`. -//! -//! Per-channel retry is what makes the retry at-most-once *per channel*: -//! a message that reached Telegram but not Discord is retried only -//! against Discord, so a retry can never duplicate a delivered message. - -use super::{ - super::recording_ctx::RecordingCtx, - health::{supervisor_health, SupervisorHealth}, - now_ts, recording_config, -}; -use log::{debug, error, info}; -use std::{ - path::PathBuf, - sync::{atomic::Ordering, Arc, OnceLock}, - time::Duration, -}; -use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; -use tuliprox_core::model::{MessageContent, RecordingNotificationConfig}; -use tuliprox_messaging::{configured_channels, send_message_to_channel, MessagingChannel}; - -/// Outbox file name under `storage_dir`. -const NOTIFICATION_OUTBOX_FILE: &str = "recording_notification_outbox.json"; - -/// One queued notification, per channel. -/// -/// `pending` is what makes the retry at-most-once *per channel*: a -/// message that reached Telegram but not Discord is retried only against -/// Discord, so a retry can never duplicate a delivered message. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct OutboxEntry { - id: u64, - content: MessageContent, - pending: Vec, - attempts: u32, - enqueued_at: i64, - next_attempt_at: i64, -} - -#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] -struct OutboxFile { - #[serde(default)] - next_id: u64, - #[serde(default)] - entries: Vec, -} - -/// Sender side of the outbox. Cloneable and cheap; `enqueue` never -/// blocks the caller. -#[derive(Debug, Clone)] -pub struct NotificationOutbox { - sender: mpsc::Sender, -} - -impl NotificationOutbox { - /// Hand a notification to the outbox worker. - /// - /// Never blocks and never awaits: a recording must not stall because - /// a messaging provider is slow. Returns the notification back when - /// the outbox cannot take it (bounded channel full, or the worker has - /// shut down) so the caller can decide — the download worker falls - /// back to a direct best-effort send. - pub fn enqueue(&self, content: MessageContent) -> Option { - match self.sender.try_send(content) { - Ok(()) => None, - Err(mpsc::error::TrySendError::Full(content)) => { - error!("Recording notification outbox is full; falling back to a direct send"); - Some(content) - } - Err(mpsc::error::TrySendError::Closed(content)) => Some(content), - } - } -} - -/// The process-wide outbox handle, installed by -/// [`spawn_notification_outbox`]. -static OUTBOX: OnceLock = OnceLock::new(); - -/// The installed outbox, if the supervisor has started. Callers that -/// find `None` (unit tests, early startup) fall back to a direct send. -pub fn notification_outbox() -> Option<&'static NotificationOutbox> { OUTBOX.get() } - -/// Start the notification outbox worker. -/// -/// Idempotent: calling it twice installs only the first worker. -pub fn spawn_notification_outbox(ctx: &RecordingCtx, cancel_token: &CancellationToken) { - let config = notification_config(ctx); - let (sender, receiver) = mpsc::channel(config.outbox_buffer); - if OUTBOX.set(NotificationOutbox { sender }).is_err() { - debug!("Recording notification outbox already installed"); - return; - } - let ctx = ctx.clone(); - let cancel_token = cancel_token.clone(); - tokio::spawn(async move { - run_notification_outbox(ctx, receiver, cancel_token).await; - }); -} - -fn notification_config(ctx: &RecordingCtx) -> RecordingNotificationConfig { - recording_config(&ctx.app_config).map_or_else(RecordingNotificationConfig::default, |cfg| cfg.notifications) -} - -fn outbox_path(ctx: &RecordingCtx) -> PathBuf { - PathBuf::from(ctx.app_config.config.load().storage_dir.as_str()).join(NOTIFICATION_OUTBOX_FILE) -} - -async fn run_notification_outbox( - ctx: RecordingCtx, - mut receiver: mpsc::Receiver, - cancel_token: CancellationToken, -) { - let path = outbox_path(&ctx); - let mut file = load_outbox(&path).await; - if !file.entries.is_empty() { - info!("Recording notification outbox recovered {} undelivered notification(s)", file.entries.len()); - } - // Once every sender is gone `recv()` completes instantly and forever, - // so the loop must stop polling it or it would spin. - let mut senders_gone = false; - loop { - let sleep_for = next_wakeup(&file, now_ts()); - let mut received_content = None; - if senders_gone { - // Nothing new can arrive, but the existing backlog still - // deserves its remaining attempts. - if file.entries.is_empty() { - break; - } - tokio::select! { - () = cancel_token.cancelled() => break, - () = tokio::time::sleep(sleep_for) => {} - } - } else { - tokio::select! { - () = cancel_token.cancelled() => break, - message = receiver.recv() => { - match message { - Some(content) => received_content = Some(content), - None => senders_gone = true, - } - } - () = tokio::time::sleep(sleep_for) => {} - } - } - if let Some(content) = received_content { - admit(&mut file, content); - // Drain whatever else is already queued so a burst of - // completions costs one persist, not one per event. - while let Ok(content) = receiver.try_recv() { - admit(&mut file, content); - } - persist_outbox(&path, &file).await; - } - if drain_due_entries(&ctx, &mut file).await { - persist_outbox(&path, &file).await; - } - supervisor_health() - .notification_outbox_depth - .store(super::super::recording_math::sat_i64_from_u64(file.entries.len() as u64), Ordering::Relaxed); - } - debug!("Recording notification outbox stopped"); -} - -/// How long to sleep before the next delivery attempt is due. -fn next_wakeup(file: &OutboxFile, now: i64) -> Duration { - file.entries - .iter() - .map(|entry| entry.next_attempt_at.saturating_sub(now).max(0)) - .min() - .map_or(Duration::from_hours(1), |secs| Duration::from_secs(u64::try_from(secs).unwrap_or(0))) -} - -/// Accept a new notification into the outbox. -fn admit(file: &mut OutboxFile, content: MessageContent) { - let now = now_ts(); - file.next_id = file.next_id.wrapping_add(1); - file.entries.push(OutboxEntry { - id: file.next_id, - content, - // Resolved at attempt time, not here: the channel set is read from - // the live config so a reload between enqueue and delivery is - // honoured. - pending: Vec::new(), - attempts: 0, - enqueued_at: now, - next_attempt_at: now, - }); -} - -/// Attempt every due entry. Returns `true` when the outbox changed and -/// has to be persisted. -async fn drain_due_entries(ctx: &RecordingCtx, file: &mut OutboxFile) -> bool { - let now = now_ts(); - if !file.entries.iter().any(|entry| entry.next_attempt_at <= now) { - return false; - } - SupervisorHealth::stamp(&supervisor_health().notification_last_drain, now); - let config = notification_config(ctx); - let client = ctx.http_client.load_full(); - let app_config = Arc::clone(&ctx.app_config); - let mut changed = false; - let mut keep: Vec = Vec::with_capacity(file.entries.len()); - for mut entry in std::mem::take(&mut file.entries) { - if entry.next_attempt_at > now { - keep.push(entry); - continue; - } - changed = true; - if entry.pending.is_empty() && entry.attempts == 0 { - entry.pending = configured_channels(&app_config, entry.content.kind()); - } - if entry.pending.is_empty() { - // No channel wants this message kind. Nothing to deliver and - // nothing to retry. - continue; - } - entry.attempts = entry.attempts.saturating_add(1); - let mut still_pending = Vec::new(); - for channel in std::mem::take(&mut entry.pending) { - match send_message_to_channel(&app_config, &client, &entry.content, channel).await { - // Delivered, or the channel is no longer configured: either - // way it must not be retried. - Some(true) | None => {} - Some(false) => still_pending.push(channel), - } - } - if still_pending.is_empty() { - debug!("Recording notification {} delivered after {} attempt(s)", entry.id, entry.attempts); - continue; - } - entry.pending = still_pending; - if entry.attempts >= config.max_attempts { - supervisor_health().notification_dead_lettered.fetch_add(1, Ordering::Relaxed); - error!( - target: "recording::audit", - "recording_notification_dead_lettered: kind={:?} attempts={} channels={:?} enqueued_at={}", - entry.content.kind(), entry.attempts, entry.pending, entry.enqueued_at - ); - continue; - } - entry.next_attempt_at = now.saturating_add(backoff_secs(&config, entry.attempts)); - keep.push(entry); - } - file.entries = keep; - changed -} - -/// Capped exponential backoff: `initial * 2^(attempts-1)`, clamped to -/// `backoff_max_secs`. -fn backoff_secs(config: &RecordingNotificationConfig, attempts: u32) -> i64 { - let shift = attempts.saturating_sub(1).min(16); - let delay = config.backoff_initial_secs.saturating_mul(1u64 << shift).min(config.backoff_max_secs); - super::super::recording_math::sat_i64_from_u64(delay) -} - -async fn load_outbox(path: &std::path::Path) -> OutboxFile { - match tokio::fs::read(path).await { - Ok(bytes) => match serde_json::from_slice::(&bytes) { - Ok(file) => file, - Err(err) => { - // A corrupt outbox must not stop the server. Losing - // undelivered notifications is the lesser failure. - error!("Recording notification outbox at {} is unreadable, starting empty: {err}", path.display()); - OutboxFile::default() - } - }, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => OutboxFile::default(), - Err(err) => { - error!("Could not read the recording notification outbox at {}: {err}", path.display()); - OutboxFile::default() - } - } -} - -async fn persist_outbox(path: &std::path::Path, file: &OutboxFile) { - match serde_json::to_vec_pretty(file) { - Ok(bytes) => { - if let Err(err) = tuliprox_core::utils::atomic_json_store::write_json_atomic(path, &bytes).await { - error!("Could not persist the recording notification outbox: {err}"); - } - } - Err(err) => error!("Could not serialize the recording notification outbox: {err}"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use shared::model::MsgKind; - use tuliprox_core::model::RecordingLifecycleMessage; - - fn config() -> RecordingNotificationConfig { - RecordingNotificationConfig { - outbox_buffer: 8, - max_attempts: 4, - backoff_initial_secs: 5, - backoff_max_secs: 100, - } - } - - fn lifecycle() -> MessageContent { - MessageContent::RecordingLifecycle(RecordingLifecycleMessage { - event: MsgKind::RecordingCompleted, - programme_title: Some("Programme".into()), - channel: Some("Channel".into()), - effective_start: Some(1_700_000_000), - effective_end: Some(1_700_003_600), - visibility: Some("shared".into()), - output_filename: Some("programme.ts".into()), - failure_reason: None, - }) - } - - #[test] - fn backoff_grows_then_saturates_at_the_configured_ceiling() { - let config = config(); - assert_eq!(backoff_secs(&config, 1), 5); - assert_eq!(backoff_secs(&config, 2), 10); - assert_eq!(backoff_secs(&config, 3), 20); - assert_eq!(backoff_secs(&config, 4), 40); - assert_eq!(backoff_secs(&config, 5), 80); - // Clamped, and never overflows however many attempts are asked for. - assert_eq!(backoff_secs(&config, 6), 100); - assert_eq!(backoff_secs(&config, u32::MAX), 100); - } - - #[test] - fn admitted_entries_are_due_immediately_and_get_distinct_ids() { - let mut file = OutboxFile::default(); - admit(&mut file, lifecycle()); - admit(&mut file, lifecycle()); - assert_eq!(file.entries.len(), 2); - assert_ne!(file.entries[0].id, file.entries[1].id); - let now = now_ts(); - assert!(file.entries.iter().all(|entry| entry.next_attempt_at <= now)); - // Channels are resolved at attempt time, from the live config. - assert!(file.entries.iter().all(|entry| entry.pending.is_empty())); - } - - #[test] - fn next_wakeup_is_zero_when_something_is_already_due() { - let mut file = OutboxFile::default(); - admit(&mut file, lifecycle()); - assert_eq!(next_wakeup(&file, now_ts()), Duration::from_secs(0)); - } - - #[test] - fn next_wakeup_picks_the_earliest_pending_attempt() { - let mut file = OutboxFile::default(); - admit(&mut file, lifecycle()); - admit(&mut file, lifecycle()); - file.entries[0].next_attempt_at = 1_000; - file.entries[1].next_attempt_at = 400; - assert_eq!(next_wakeup(&file, 100), Duration::from_mins(5)); - } - - #[test] - fn next_wakeup_on_an_empty_outbox_is_a_long_idle_sleep() { - assert_eq!(next_wakeup(&OutboxFile::default(), 0), Duration::from_hours(1)); - } - - #[test] - fn outbox_file_round_trips_through_json() { - let mut file = OutboxFile::default(); - admit(&mut file, lifecycle()); - file.entries[0].pending = vec![MessagingChannel::Telegram, MessagingChannel::Discord]; - file.entries[0].attempts = 2; - let bytes = serde_json::to_vec(&file).expect("serialize"); - let restored: OutboxFile = serde_json::from_slice(&bytes).expect("deserialize"); - assert_eq!(restored.entries.len(), 1); - assert_eq!(restored.entries[0].attempts, 2); - assert_eq!(restored.entries[0].pending, vec![MessagingChannel::Telegram, MessagingChannel::Discord]); - } - - #[test] - fn a_full_outbox_hands_the_notification_back_instead_of_blocking() { - let (sender, _receiver) = mpsc::channel(1); - let outbox = NotificationOutbox { sender }; - assert!(outbox.enqueue(lifecycle()).is_none()); - assert!(outbox.enqueue(lifecycle()).is_some()); - } -} diff --git a/backend/dvr/src/recording/recording_supervisor/retention.rs b/backend/dvr/src/recording/recording_supervisor/retention.rs index be602469f..efc57799c 100644 --- a/backend/dvr/src/recording/recording_supervisor/retention.rs +++ b/backend/dvr/src/recording/recording_supervisor/retention.rs @@ -23,14 +23,13 @@ use crate::{ recording_worker_runner::{DeleteOutcome, DiskConfig}, }; use log::{debug, error, info}; -use shared::model::Claims; +use shared::model::{Claims, EventMessage, EventSink}; use std::{ path::PathBuf, sync::{atomic::AtomicBool, Arc}, time::Duration, }; use tokio_util::sync::CancellationToken; -use tuliprox_session::EventMessage; /// Floor on how often the recording root is measured. `statvfs` is cheap /// but not free, and it would be wasteful to re-measure on a tick that @@ -43,7 +42,10 @@ const MIN_DISK_PRESSURE_INTERVAL_SECS: u64 = 30; const DEFAULT_WATERMARK_CHECK_INTERVAL_SECS: u64 = 60; /// Start the retention supervisor. -pub fn spawn_retention_supervisor(ctx: &RecordingCtx, cancel_token: &CancellationToken) { +pub fn spawn_retention_supervisor( + ctx: &RecordingCtx, + cancel_token: &CancellationToken, +) { let ctx = ctx.clone(); let cancel_token = cancel_token.clone(); let running = Arc::new(AtomicBool::new(false)); @@ -80,21 +82,21 @@ pub fn spawn_retention_supervisor(ctx: &RecordingCtx, cancel_token: &Cancellatio deleted += run_disk_pressure_sweep(&ctx).await; } if deleted > 0 { - let _ = ctx.event_manager.send_event(EventMessage::RecordingChanged); + ctx.events.emit(EventMessage::RecordingChanged); } } debug!("Retention supervisor stopped"); }); } -fn policy_sweep_interval_secs(ctx: &RecordingCtx) -> i64 { +fn policy_sweep_interval_secs(ctx: &RecordingCtx) -> i64 { let secs = recording_config(&ctx.app_config) .and_then(|cfg| cfg.retention.map(|retention| retention.sweep_interval_secs)) .unwrap_or_else(shared::model::default_recording_retention_sweep_interval_secs); i64::try_from(secs.max(1)).unwrap_or(3600) } -fn watermark_check_interval(ctx: &RecordingCtx) -> Duration { +fn watermark_check_interval(ctx: &RecordingCtx) -> Duration { let secs = recording_config(&ctx.app_config) .and_then(|cfg| cfg.disk.and_then(|disk| disk.cleanup_interval_secs)) .filter(|secs| *secs > 0) @@ -105,7 +107,7 @@ fn watermark_check_interval(ctx: &RecordingCtx) -> Duration { } /// Age + count retention. -async fn run_policy_sweep(ctx: &RecordingCtx, now: i64) -> u64 { +async fn run_policy_sweep(ctx: &RecordingCtx, now: i64) -> u64 { let Some(config) = recording_config(&ctx.app_config) else { return 0; }; @@ -147,7 +149,7 @@ async fn run_policy_sweep(ctx: &RecordingCtx, now: i64) -> u64 { /// Free-space driven retention. Only runs when both watermarks are /// configured and the recording root is measurable. -async fn run_disk_pressure_sweep(ctx: &RecordingCtx) -> u64 { +async fn run_disk_pressure_sweep(ctx: &RecordingCtx) -> u64 { let Some(config) = recording_config(&ctx.app_config) else { return 0; }; diff --git a/backend/dvr/src/recording/recording_supervisor/startup.rs b/backend/dvr/src/recording/recording_supervisor/startup.rs index b8ea83bf5..4db7f3e2e 100644 --- a/backend/dvr/src/recording/recording_supervisor/startup.rs +++ b/backend/dvr/src/recording/recording_supervisor/startup.rs @@ -33,13 +33,15 @@ use crate::{ recording_reconciliation::ReconcileAction, }; use log::{debug, error, info, warn}; -use shared::model::recording_rule::{RecordingRulesFile, RecordingTombstone, TombstoneKind}; +use shared::model::{ + recording_rule::{RecordingRulesFile, RecordingTombstone, TombstoneKind}, + EventMessage, EventSink, +}; use std::{collections::HashSet, path::PathBuf}; use tuliprox_repository::recording_rule_repository::RecordingRuleRepository; -use tuliprox_session::EventMessage; /// Repair the DVR state left behind by the previous process. -pub async fn run_startup_reconciliation(ctx: &RecordingCtx) { +pub async fn run_startup_reconciliation(ctx: &RecordingCtx) { if !recording_enabled(&ctx.app_config) { debug!("Recording disabled; skipping DVR startup reconciliation"); return; @@ -49,12 +51,12 @@ pub async fn run_startup_reconciliation(ctx: &RecordingCtx) { SupervisorHealth::stamp(&supervisor_health().reconciliation_last_run, now_ts()); if stuck > 0 || drift > 0 { info!("DVR startup reconciliation: repaired {stuck} interrupted deletion(s), {drift} rule drift item(s)"); - let _ = ctx.event_manager.send_event(EventMessage::RecordingChanged); + ctx.events.emit(EventMessage::RecordingChanged); } } /// Finish or undo every deletion the previous process left half-done. -async fn recover_stuck_deletions(ctx: &RecordingCtx) -> usize { +async fn recover_stuck_deletions(ctx: &RecordingCtx) -> usize { let (_revision, tasks) = ctx.downloads.committed_snapshot().await; let pending: Vec = tasks .into_iter() @@ -112,7 +114,7 @@ async fn recover_stuck_deletions(ctx: &RecordingCtx) -> usize { } /// Apply the reconciliation plan for queue/rule drift. -async fn reconcile_rule_drift(ctx: &RecordingCtx) -> usize { +async fn reconcile_rule_drift(ctx: &RecordingCtx) -> usize { let storage_dir = ctx.app_config.config.load().storage_dir.clone(); let repo = RecordingRuleRepository::new(storage_dir); let mut file = match repo.load().await { @@ -141,7 +143,7 @@ async fn reconcile_rule_drift(ctx: &RecordingCtx) -> usize { error!("DVR reconciliation could not persist repaired tombstones: {err}"); return applied; } - let _ = ctx.event_manager.send_event(EventMessage::RecordingRulesChanged); + ctx.events.emit(EventMessage::RecordingRulesChanged); } applied } diff --git a/backend/dvr/src/recording/recording_ws.rs b/backend/dvr/src/recording/recording_ws.rs index 97a601814..b0487c60e 100644 --- a/backend/dvr/src/recording/recording_ws.rs +++ b/backend/dvr/src/recording/recording_ws.rs @@ -75,7 +75,7 @@ pub fn task_visible_to(task_meta: Option<&RecordingMetadata>, claims: &Claims, s RecordingVisibility::Private => owner == subject_id, RecordingVisibility::Shared => true, }, - RecordingOwner::LegacyAdmin => claims.roles.iter().any(|r| r == shared::model::ROLE_ADMIN), + RecordingOwner::LegacyAdmin => claims.is_admin(), } } @@ -157,7 +157,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: vec!["ADMIN".to_string()], + roles: shared::model::RoleSet::ADMIN, permissions: Permission::RecordingRead.into(), pwd_version: 0, subject_id: Some(UserId::builtin_admin()), @@ -173,7 +173,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: Permission::RecordingRead.into(), pwd_version: 0, subject_id: Some(UserId::from("web:alice")), @@ -292,7 +292,7 @@ mod tests { iss: "tuliprox".to_string(), iat: 0, exp: 0, - roles: Vec::new(), + roles: shared::model::RoleSet::new(), permissions: Permission::RecordingRead.into(), pwd_version: 0, subject_id: Some(UserId::from("web:bob")), diff --git a/backend/hls/src/availability/mod.rs b/backend/hls/src/availability/mod.rs index 3900c4820..fdfdc49f5 100644 --- a/backend/hls/src/availability/mod.rs +++ b/backend/hls/src/availability/mod.rs @@ -348,7 +348,7 @@ impl HlsDetailedTerminalResolution { } } -pub(super) fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +pub(super) use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests; diff --git a/backend/hls/src/gc.rs b/backend/hls/src/gc.rs index 462720ac5..f93299144 100644 --- a/backend/hls/src/gc.rs +++ b/backend/hls/src/gc.rs @@ -43,10 +43,10 @@ pub struct GarbageCollectionPolicy { impl GarbageCollectionPolicy { pub fn from_config(config: &HlsCacheConfig) -> Self { Self { - cache_duration_ms: config.cache_duration.saturating_mul(1_000), - cache_bytes_global: config.cache_bytes, - cache_bytes_per_session: config.cache_bytes_per_session, - session_idle_timeout_ms: config.session_idle_timeout.saturating_mul(1_000), + cache_duration_ms: config.cache_duration.as_millis().get(), + cache_bytes_global: config.cache_bytes.get(), + cache_bytes_per_session: config.cache_bytes_per_session.get(), + session_idle_timeout_ms: config.session_idle_timeout.as_millis().get(), temp_file_retention_ms: DEFAULT_TEMP_FILE_RETENTION_MS, failed_segment_retention_ms: DEFAULT_FAILED_SEGMENT_RETENTION_MS, } @@ -2387,8 +2387,11 @@ mod tests { let (first, second) = tokio::join!(first, second); - assert!(first.expect("first task").is_ok()); - assert!(second.expect("second task").is_ok()); + let first = first.expect("first task"); + let second = second.expect("second task"); + + assert!(first.is_ok(), "first cache write failed: {first:?}"); + assert!(second.is_ok(), "second cache write failed: {second:?}"); let session = session.read().await; assert!(!session.segments.contains_key(&1)); assert!(!session.segments.contains_key(&2)); diff --git a/backend/hls/src/lifecycle.rs b/backend/hls/src/lifecycle.rs index 6e9dbc79a..1378d8293 100644 --- a/backend/hls/src/lifecycle.rs +++ b/backend/hls/src/lifecycle.rs @@ -136,7 +136,7 @@ enum LifecycleWait { Notify, } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests { diff --git a/backend/hls/src/manager.rs b/backend/hls/src/manager.rs index e9f982f3c..726789786 100644 --- a/backend/hls/src/manager.rs +++ b/backend/hls/src/manager.rs @@ -433,12 +433,12 @@ impl HlsProxyRuntimeConfig { Self { enabled, segment_fetch_policy: SegmentFetchPolicy::from_config(config), - cache_duration_seconds: config.cache_duration, + cache_duration_seconds: config.cache_duration.get(), strip: config.strip.clone(), - origin_manifest_timeout_ms: config.origin_manifest_timeout_ms, - initial_manifest_wait_timeout_secs: config.initial_manifest_wait_timeout_secs, + origin_manifest_timeout_ms: config.origin_manifest_timeout_ms.get(), + initial_manifest_wait_timeout_secs: config.initial_manifest_wait_timeout_secs.get(), manifest_recovery_burst: config.manifest_recovery_burst.clone(), - transient_resource_ttl_ms: config.cache_duration.saturating_mul(1_000), + transient_resource_ttl_ms: config.cache_duration.as_millis().get(), gc_policy: GarbageCollectionPolicy::from_config(config), rewrite_secret_fingerprint: build_rewrite_secret_fingerprint(rewrite_secret), } @@ -494,7 +494,7 @@ impl HlsProxyManager { pub fn with_cache_settings(cache_path: impl Into, cache_duration_seconds: u64) -> Self { let default_dto = shared::model::HlsCacheConfigDto { - cache_duration: cache_duration_seconds, + cache_duration: shared::model::Secs::new(cache_duration_seconds), cache_path: Some(cache_path.into().to_string_lossy().to_string()), ..Default::default() }; @@ -503,7 +503,8 @@ impl HlsProxyManager { let global_fetch_semaphore = Arc::new(Semaphore::new(segment_fetch_policy.max_global_segment_fetches)); let sessions = Arc::new(HlsSessionStore::new()); let segment_cache = Arc::new(HlsSegmentCache::with_cache_path(PathBuf::from(&default_config.cache_path))); - segment_cache.update_cache_limits(default_config.cache_bytes, default_config.cache_bytes_per_session); + segment_cache + .update_cache_limits(default_config.cache_bytes.get(), default_config.cache_bytes_per_session.get()); let segment_repair = Arc::new(HlsSegmentRepairManager::new(default_config.segment_repair.clone())); let metrics = Arc::new(HlsCacheMetrics::default()); let qos = Arc::new(HlsQosRegistry::default()); @@ -570,7 +571,7 @@ impl HlsProxyManager { let global_fetch_semaphore = Arc::new(Semaphore::new(segment_fetch_policy.max_global_segment_fetches)); let sessions = Arc::new(HlsSessionStore::new()); let segment_cache = Arc::new(HlsSegmentCache::with_cache_path(PathBuf::from(&config.cache_path))); - segment_cache.update_cache_limits(config.cache_bytes, config.cache_bytes_per_session); + segment_cache.update_cache_limits(config.cache_bytes.get(), config.cache_bytes_per_session.get()); let segment_repair = Arc::new(HlsSegmentRepairManager::new(config.segment_repair.clone())); let metrics = Arc::new(HlsCacheMetrics::default()); let qos = Arc::new(HlsQosRegistry::default()); @@ -899,7 +900,7 @@ impl HlsProxyManager { }; let runtime_config = HlsProxyRuntimeConfig::from_config_with_enabled(&hls_config, &rewrite_secret, enabled); let cache_path_changed = self.gc.update_cache_path(PathBuf::from(&hls_config.cache_path)).await; - self.segment_cache.update_cache_limits(hls_config.cache_bytes, hls_config.cache_bytes_per_session); + self.segment_cache.update_cache_limits(hls_config.cache_bytes.get(), hls_config.cache_bytes_per_session.get()); if cache_path_changed { self.clear_runtime_cache_state_for_cache_path_change().await; } @@ -2746,7 +2747,7 @@ pub fn exec_hls_lifecycle(ctx: &HlsCtx, cancel_token: &CancellationToken) { }); } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests { @@ -3520,10 +3521,10 @@ mod tests { updated_dto.max_segments_prefetch = 4; updated_dto.max_concurrent_segment_fetches_per_session = 5; updated_dto.max_concurrent_segment_fetches_global = 6; - updated_dto.origin_manifest_timeout_ms = 1_234; - updated_dto.origin_segment_timeout_ms = 5_678; - updated_dto.cache_duration = 99; - updated_dto.session_idle_timeout = 55; + updated_dto.origin_manifest_timeout_ms = shared::model::Millis::new(1_234); + updated_dto.origin_segment_timeout_ms = shared::model::Millis::new(5_678); + updated_dto.cache_duration = shared::model::Secs::new(99); + updated_dto.session_idle_timeout = shared::model::Secs::new(55); updated_dto.manifest_recovery_burst.level = shared::model::HlsManifestRecoveryBurstLevel::Balanced; updated_dto.strip = HlsStripConfigDto { mode: HlsStripMode::Seconds, value: 7 }; let app_config = test_app_config(config_with_hls_cache(updated_dto)); diff --git a/backend/hls/src/manifest_fetch/fetch.rs b/backend/hls/src/manifest_fetch/fetch.rs index 91d7d8ba4..1d412413f 100644 --- a/backend/hls/src/manifest_fetch/fetch.rs +++ b/backend/hls/src/manifest_fetch/fetch.rs @@ -180,7 +180,7 @@ pub(super) fn next_retry_delay_ms( .unwrap_or_else(|| retry_policy.delay_for_attempt_ms(attempt_index + 1, jitter_ms).unwrap_or_default()) } -pub(super) fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +pub(super) use tuliprox_core::utils::current_time_millis; #[cfg(any(test, feature = "test-support"))] pub(super) async fn refresh_from_live_hls_entrypoint_with_retries( diff --git a/backend/hls/src/manifest_fetch/model.rs b/backend/hls/src/manifest_fetch/model.rs index cff736ea1..0a587de86 100644 --- a/backend/hls/src/manifest_fetch/model.rs +++ b/backend/hls/src/manifest_fetch/model.rs @@ -316,7 +316,7 @@ pub(super) fn request_hls_session_idle_timeout_secs_from_config(app_config: &App .reverse_proxy .as_ref() .and_then(|reverse_proxy| reverse_proxy.hls_cache.as_ref()) - .map_or(DEFAULT_HLS_SESSION_IDLE_TIMEOUT_SECS, |hls_cache| hls_cache.session_idle_timeout) + .map_or(DEFAULT_HLS_SESSION_IDLE_TIMEOUT_SECS, |hls_cache| hls_cache.session_idle_timeout.get()) .max(1) } diff --git a/backend/hls/src/map_fetcher.rs b/backend/hls/src/map_fetcher.rs index ae23a2b23..8aa790b1b 100644 --- a/backend/hls/src/map_fetcher.rs +++ b/backend/hls/src/map_fetcher.rs @@ -492,7 +492,7 @@ fn build_map_origin_headers( build_hls_origin_resource_headers(source_headers, provider_session_headers, byte_range) } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests { diff --git a/backend/hls/src/playback.rs b/backend/hls/src/playback.rs index bde4575ec..0da3fe96a 100644 --- a/backend/hls/src/playback.rs +++ b/backend/hls/src/playback.rs @@ -6,7 +6,7 @@ use crate::{ HlsPlaybackFamilyKey, ProxySessionId, }; use log::warn; -use shared::model::{PlaylistItemType, UserConnectionPermission}; +use shared::model::UserConnectionPermission; use std::sync::Arc; use tuliprox_core::model::{Fingerprint, ProxyUserCredentials}; use tuliprox_session::{ @@ -93,7 +93,6 @@ async fn validate_hls_access_lease_admission( &ctx.admission_ctx(), &user, fingerprint, - PlaylistItemType::LiveHls, Some(&user_session), &lease.user_session_token, true, diff --git a/backend/hls/src/refresh/mod.rs b/backend/hls/src/refresh/mod.rs index e98d1d4d6..768d82b2d 100644 --- a/backend/hls/src/refresh/mod.rs +++ b/backend/hls/src/refresh/mod.rs @@ -1262,7 +1262,7 @@ async fn cancel_superseded_terminal_work_after_media_progress( request.hls_proxy.cancel_superseded_terminal_work_for_session(&proxy_session_id); } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests; diff --git a/backend/hls/src/response.rs b/backend/hls/src/response.rs index c758e3c4a..d52905751 100644 --- a/backend/hls/src/response.rs +++ b/backend/hls/src/response.rs @@ -1220,7 +1220,7 @@ fn next_hls_body_log_id() -> String { format!("{value:08x}") } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests { diff --git a/backend/hls/src/segment_fetcher.rs b/backend/hls/src/segment_fetcher.rs index a3bfdb399..de8ca35f8 100644 --- a/backend/hls/src/segment_fetcher.rs +++ b/backend/hls/src/segment_fetcher.rs @@ -82,9 +82,9 @@ impl SegmentFetchPolicy { max_global_segment_fetches: config.max_concurrent_segment_fetches_global.max(1), max_session_segment_fetches: config.max_concurrent_segment_fetches_per_session.max(1), max_prefetch_queue_depth: config.max_segments_prefetch, - origin_segment_timeout_ms: config.origin_segment_timeout_ms.max(1), + origin_segment_timeout_ms: config.origin_segment_timeout_ms.get().max(1), effective_repair_postprocess_timeout_ms: if postprocess_enabled { - config.segment_repair.postprocess_timeout_ms.max(100) + config.segment_repair.postprocess_timeout_ms.get().max(100) } else { 0 }, @@ -1723,7 +1723,7 @@ fn build_segment_origin_headers( build_hls_origin_resource_headers(source_headers, provider_session_headers, byte_range) } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests { diff --git a/backend/hls/src/segment_repair.rs b/backend/hls/src/segment_repair.rs index a42928fa8..445b827f2 100644 --- a/backend/hls/src/segment_repair.rs +++ b/backend/hls/src/segment_repair.rs @@ -555,7 +555,7 @@ impl HlsSegmentRepairRuntime { self.repair_enabled() || self.config.corrupt_segment_watchdog.mode.is_enabled() } - fn postprocess_timeout_ms(&self) -> u64 { self.config.postprocess_timeout_ms.max(100) } + fn postprocess_timeout_ms(&self) -> u64 { self.config.postprocess_timeout_ms.get().max(100) } } fn log_segment_repair_config(config: &HlsSegmentRepairConfig) { diff --git a/backend/hls/src/terminal_commit.rs b/backend/hls/src/terminal_commit.rs index e37df9638..bc91a097e 100644 --- a/backend/hls/src/terminal_commit.rs +++ b/backend/hls/src/terminal_commit.rs @@ -1247,7 +1247,7 @@ const fn terminal_commit_outcome_label(outcome: HlsTerminalCommitOutcome) -> &'s } } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests { diff --git a/backend/hls/src/transient_fetcher.rs b/backend/hls/src/transient_fetcher.rs index 60e8cba2d..f762abe40 100644 --- a/backend/hls/src/transient_fetcher.rs +++ b/backend/hls/src/transient_fetcher.rs @@ -819,7 +819,7 @@ impl Drop for HlsTransientOriginIoGuard { } } -fn current_time_millis() -> u64 { chrono::Utc::now().timestamp_millis().try_into().unwrap_or_default() } +use tuliprox_core::utils::current_time_millis; #[cfg(test)] mod tests { diff --git a/backend/iptv/Cargo.toml b/backend/iptv/Cargo.toml index 523f7fcea..0614c414d 100644 --- a/backend/iptv/Cargo.toml +++ b/backend/iptv/Cargo.toml @@ -11,7 +11,6 @@ workspace = true [dependencies] shared = { workspace = true } tuliprox-core = { workspace = true } -tuliprox-messaging = { workspace = true } tuliprox-parser = { workspace = true } tuliprox-repository = { workspace = true } base64.workspace = true @@ -29,5 +28,6 @@ tokio-util = { version = "0.7.19", features = ["io", "io-util"] } url.workspace = true [dev-dependencies] +http = "1.4.0" tempfile = "3.27.0" tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/backend/iptv/src/capabilities.rs b/backend/iptv/src/capabilities.rs new file mode 100644 index 000000000..58fdee905 --- /dev/null +++ b/backend/iptv/src/capabilities.rs @@ -0,0 +1,218 @@ +//! What a provider has already told us about itself. +//! +//! Capability knowledge was scattered and then thrown away on every refresh. Whether a +//! portal supports `get_all_channels` was inferred from the *shape of the error* it +//! returned; which bootstrap recipe worked was discovered by walking a five-entry fallback +//! chain from the top; which of three endpoint candidates answered was rediscovered per +//! call. None of it survived the run, so the next refresh re-probed endpoints already +//! known to 404 and re-walked a chain whose answer was already known. +//! +//! Re-probing is not free in the way a cache miss usually is. A handshake chain replayed +//! from scratch against a portal with stale credentials looks, from the provider's side, +//! a great deal like credential stuffing — which is the failure mode the Xtream side +//! already carries a standing TODO about. +//! +//! A snapshot is a hint, never a contract: a provider that starts supporting an action is +//! free to do so, which is what [`ProviderCapabilities::is_stale`] and the re-probe +//! interval are for. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// How long a snapshot is trusted before the client re-probes what it says is missing. +/// A day: long enough to be worth persisting, short enough that a provider fixing its +/// portal is picked up without anyone clearing state by hand. +pub const CAPABILITY_TTL_SECS: u64 = 24 * 60 * 60; + +/// What one provider has proven about itself, as of `observed_at_epoch_secs`. +/// +/// `BTreeSet` rather than `HashSet` so the persisted form has a stable field order and a +/// snapshot that has not changed does not rewrite the file with reshuffled contents. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderCapabilities { + /// Actions the provider answered in a way that means "I do not implement this". + /// Distinct from an action that merely failed — see + /// [`StalkerError::is_unsupported_catalog_action`](crate::stalker::error::StalkerError::is_unsupported_catalog_action). + #[serde(default)] + pub unsupported_actions: BTreeSet, + /// The bootstrap recipe that last completed a handshake, so the fallback chain can + /// start where it previously ended rather than at the top. + #[serde(default)] + pub bootstrap_recipe: Option, + /// The endpoint candidate that last answered. + #[serde(default)] + pub endpoint: Option, + /// When this snapshot was taken. Zero means "never observed". + #[serde(default)] + pub observed_at_epoch_secs: u64, +} + +impl ProviderCapabilities { + /// True once the snapshot is old enough that its negative claims should be re-tested. + /// + /// A clock that has gone backwards reports "not stale" rather than treating the + /// snapshot as infinitely old, which would re-probe every action on every call. + #[must_use] + pub fn is_stale(&self, now_epoch_secs: u64) -> bool { + if self.observed_at_epoch_secs == 0 { + return true; + } + now_epoch_secs.saturating_sub(self.observed_at_epoch_secs) >= CAPABILITY_TTL_SECS + } + + /// Whether `action` is known not to work, according to a snapshot still worth + /// believing. A stale snapshot answers `false` — the action gets re-probed. + #[must_use] + pub fn is_unsupported(&self, action: &str, now_epoch_secs: u64) -> bool { + !self.is_stale(now_epoch_secs) && self.unsupported_actions.contains(action) + } + + /// Record that `action` is not implemented by this provider. Returns whether this + /// changed anything, so a caller can skip a write that would persist nothing new. + pub fn record_unsupported(&mut self, action: &str, now_epoch_secs: u64) -> bool { + self.observed_at_epoch_secs = now_epoch_secs; + self.unsupported_actions.insert(action.to_string()) + } + + /// Record that `action` worked after all, undoing an earlier negative claim. + pub fn record_supported(&mut self, action: &str, now_epoch_secs: u64) -> bool { + self.observed_at_epoch_secs = now_epoch_secs; + self.unsupported_actions.remove(action) + } + + /// Record the recipe and endpoint that completed a handshake. Returns whether either + /// changed. + pub fn record_handshake(&mut self, recipe: &str, endpoint: &str, now_epoch_secs: u64) -> bool { + self.observed_at_epoch_secs = now_epoch_secs; + let changed = self.bootstrap_recipe.as_deref() != Some(recipe) || self.endpoint.as_deref() != Some(endpoint); + self.bootstrap_recipe = Some(recipe.to_string()); + self.endpoint = Some(endpoint.to_string()); + changed + } + + /// The recipe that last completed a handshake, when the snapshot is still worth + /// believing. + #[must_use] + pub fn remembered_recipe(&self, now_epoch_secs: u64) -> Option<&str> { + self.bootstrap_recipe.as_deref().filter(|_| !self.is_stale(now_epoch_secs)) + } + + /// Reorder `candidates` so the remembered one is tried first, keeping the rest in + /// their configured order. + /// + /// Reordering rather than replacing: the remembered answer is a hint, and a portal + /// that has moved must still be reachable through the others. + #[must_use] + pub fn prefer_remembered(&self, candidates: Vec, key: K, now_epoch_secs: u64) -> Vec + where + K: Fn(&T) -> String, + { + let Some(remembered) = self.endpoint.as_deref().filter(|_| !self.is_stale(now_epoch_secs)) else { + return candidates; + }; + let Some(position) = candidates.iter().position(|candidate| key(candidate) == remembered) else { + return candidates; + }; + let mut candidates = candidates; + candidates.swap(0, position); + candidates + } +} + +#[cfg(test)] +mod tests { + use super::{ProviderCapabilities, CAPABILITY_TTL_SECS}; + + const NOW: u64 = 1_700_000_000; + + #[test] + fn a_never_observed_snapshot_is_stale_and_claims_nothing() { + let capabilities = ProviderCapabilities::default(); + assert!(capabilities.is_stale(NOW)); + assert!(!capabilities.is_unsupported("get_all_channels", NOW)); + } + + #[test] + fn a_negative_claim_expires_so_a_fixed_portal_is_retried() { + let mut capabilities = ProviderCapabilities::default(); + assert!(capabilities.record_unsupported("get_all_channels", NOW)); + + assert!(capabilities.is_unsupported("get_all_channels", NOW)); + assert!(capabilities.is_unsupported("get_all_channels", NOW + CAPABILITY_TTL_SECS - 1)); + assert!(!capabilities.is_unsupported("get_all_channels", NOW + CAPABILITY_TTL_SECS)); + } + + #[test] + fn recording_the_same_claim_twice_reports_no_change() { + let mut capabilities = ProviderCapabilities::default(); + assert!(capabilities.record_unsupported("get_all_channels", NOW)); + assert!(!capabilities.record_unsupported("get_all_channels", NOW), "a no-op must not trigger a write"); + } + + #[test] + fn an_action_that_starts_working_can_be_taken_back() { + let mut capabilities = ProviderCapabilities::default(); + capabilities.record_unsupported("get_all_channels", NOW); + assert!(capabilities.record_supported("get_all_channels", NOW)); + assert!(!capabilities.is_unsupported("get_all_channels", NOW)); + } + + #[test] + fn a_backwards_clock_does_not_invalidate_the_snapshot() { + let mut capabilities = ProviderCapabilities::default(); + capabilities.record_unsupported("get_all_channels", NOW); + assert!(capabilities.is_unsupported("get_all_channels", 1)); + } + + #[test] + fn the_remembered_endpoint_is_tried_first_and_the_rest_still_follow() { + let mut capabilities = ProviderCapabilities::default(); + capabilities.record_handshake("GenericSafe", "portal.php", NOW); + + let ordered = capabilities.prefer_remembered( + vec!["server/load.php", "portal.php", "c/"], + |candidate| (*candidate).to_string(), + NOW, + ); + + assert_eq!(ordered, vec!["portal.php", "server/load.php", "c/"]); + } + + #[test] + fn an_unknown_or_stale_remembered_endpoint_leaves_the_order_alone() { + let mut capabilities = ProviderCapabilities::default(); + capabilities.record_handshake("GenericSafe", "gone.php", NOW); + let candidates = vec!["server/load.php", "portal.php"]; + + let key = |candidate: &&str| (*candidate).to_string(); + assert_eq!(capabilities.prefer_remembered(candidates.clone(), key, NOW), candidates); + assert_eq!(capabilities.prefer_remembered(candidates.clone(), key, NOW + CAPABILITY_TTL_SECS), candidates); + } + + #[test] + fn a_stale_snapshot_stops_recommending_its_recipe() { + let mut capabilities = ProviderCapabilities::default(); + capabilities.record_handshake("StrictMag", "portal.php", NOW); + + assert_eq!(capabilities.remembered_recipe(NOW), Some("StrictMag")); + assert_eq!(capabilities.remembered_recipe(NOW + CAPABILITY_TTL_SECS), None); + } + + #[test] + fn recording_an_unchanged_handshake_reports_no_change() { + let mut capabilities = ProviderCapabilities::default(); + assert!(capabilities.record_handshake("GenericSafe", "portal.php", NOW)); + assert!(!capabilities.record_handshake("GenericSafe", "portal.php", NOW + 5)); + } + + #[test] + fn the_persisted_form_round_trips() -> Result<(), serde_json::Error> { + let mut capabilities = ProviderCapabilities::default(); + capabilities.record_unsupported("get_all_channels", NOW); + capabilities.record_handshake("StrictMag", "server/load.php", NOW); + + let restored: ProviderCapabilities = serde_json::from_str(&serde_json::to_string(&capabilities)?)?; + assert_eq!(restored, capabilities); + Ok(()) + } +} diff --git a/backend/iptv/src/capability_store.rs b/backend/iptv/src/capability_store.rs new file mode 100644 index 000000000..2e4869710 --- /dev/null +++ b/backend/iptv/src/capability_store.rs @@ -0,0 +1,183 @@ +//! Where a [`ProviderCapabilities`] snapshot survives a restart. +//! +//! Split from the value type so the knowledge is useful before anyone decides where it +//! lives: the in-memory store is enough for a single run, and the JSON store makes it +//! outlast one. Both are held as `impl CapabilityStore` rather than a trait object. + +use crate::capabilities::ProviderCapabilities; +use log::warn; +use parking_lot::RwLock; +use std::{ + collections::HashMap, + future::Future, + path::{Path, PathBuf}, +}; + +pub trait CapabilityStore: Send + Sync { + /// The snapshot for `input`, or the default when nothing is stored. A store that + /// cannot be read reports the default rather than failing: capability knowledge is an + /// optimisation, and losing it must never stop a refresh. + fn load(&self, input: &str) -> impl Future + Send; + + /// Persist the snapshot for `input`. + fn save(&self, input: &str, capabilities: &ProviderCapabilities) -> impl Future + Send; +} + +/// Keeps snapshots for the lifetime of the process. What the client falls back to when no +/// storage directory is configured. +#[derive(Debug, Default)] +pub struct InMemoryCapabilityStore { + entries: RwLock>, +} + +impl InMemoryCapabilityStore { + #[must_use] + pub fn new() -> Self { Self::default() } +} + +impl CapabilityStore for InMemoryCapabilityStore { + fn load(&self, input: &str) -> impl Future + Send { + let stored = self.entries.read().get(input).cloned().unwrap_or_default(); + async move { stored } + } + + fn save(&self, input: &str, capabilities: &ProviderCapabilities) -> impl Future + Send { + self.entries.write().insert(input.to_string(), capabilities.clone()); + async {} + } +} + +/// One JSON file per input, written atomically through the workspace's existing helper so +/// a crash mid-write cannot leave a half-file that fails to parse on the next start. +#[derive(Debug, Clone)] +pub struct JsonCapabilityStore { + dir: PathBuf, +} + +impl JsonCapabilityStore { + #[must_use] + pub fn new(dir: impl Into) -> Self { Self { dir: dir.into() } } + + /// The file for `input`. The name is derived rather than taken verbatim: input names + /// come from user config and can contain separators. + #[must_use] + pub fn path_for(&self, input: &str) -> PathBuf { + self.dir.join(format!("{}.capabilities.json", sanitize_component(input))) + } +} + +impl CapabilityStore for JsonCapabilityStore { + fn load(&self, input: &str) -> impl Future + Send { + let path = self.path_for(input); + async move { read_snapshot(&path).await } + } + + fn save(&self, input: &str, capabilities: &ProviderCapabilities) -> impl Future + Send { + let path = self.path_for(input); + let dir = self.dir.clone(); + let encoded = serde_json::to_vec(capabilities); + async move { + let encoded = match encoded { + Ok(encoded) => encoded, + Err(err) => { + warn!("Could not encode provider capabilities for {}: {err}", path.display()); + return; + } + }; + if let Err(err) = tokio::fs::create_dir_all(&dir).await { + warn!("Could not create capability directory {}: {err}", dir.display()); + return; + } + if let Err(err) = tuliprox_core::utils::write_json_atomic(&path, &encoded).await { + warn!("Could not persist provider capabilities to {}: {err}", path.display()); + } + } + } +} + +async fn read_snapshot(path: &Path) -> ProviderCapabilities { + let Ok(bytes) = tokio::fs::read(path).await else { + // A missing file is the normal first-run case, not a problem worth logging. + return ProviderCapabilities::default(); + }; + match serde_json::from_slice(&bytes) { + Ok(capabilities) => capabilities, + Err(err) => { + // Corrupt state must not be fatal: re-probing is always available. + warn!("Ignoring unreadable provider capabilities at {}: {err}", path.display()); + ProviderCapabilities::default() + } + } +} + +fn sanitize_component(value: &str) -> String { + value + .chars() + .map(|ch| match ch { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch, + _ => '_', + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{CapabilityStore, InMemoryCapabilityStore, JsonCapabilityStore}; + use crate::capabilities::ProviderCapabilities; + + const NOW: u64 = 1_700_000_000; + + fn observed() -> ProviderCapabilities { + let mut capabilities = ProviderCapabilities::default(); + capabilities.record_unsupported("get_all_channels", NOW); + capabilities.record_handshake("StrictMag", "portal.php", NOW); + capabilities + } + + #[tokio::test] + async fn an_unknown_input_loads_the_default_rather_than_failing() { + let store = InMemoryCapabilityStore::new(); + assert_eq!(store.load("never-seen").await, ProviderCapabilities::default()); + } + + #[tokio::test] + async fn in_memory_snapshots_survive_within_the_run() { + let store = InMemoryCapabilityStore::new(); + store.save("provider", &observed()).await; + assert_eq!(store.load("provider").await, observed()); + assert_eq!(store.load("other").await, ProviderCapabilities::default(), "inputs must not share state"); + } + + #[tokio::test] + async fn a_json_snapshot_survives_a_restart() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + let store = JsonCapabilityStore::new(dir.path()); + + store.save("provider one", &observed()).await; + // A second store over the same directory stands in for the next process. + assert_eq!(JsonCapabilityStore::new(dir.path()).load("provider one").await, observed()); + Ok(()) + } + + #[tokio::test] + async fn an_input_name_with_separators_cannot_escape_the_directory() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + let store = JsonCapabilityStore::new(dir.path()); + + let path = store.path_for("../../etc/passwd"); + + assert_eq!(path.parent(), Some(dir.path())); + assert!(!path.to_string_lossy().contains("..")); + Ok(()) + } + + #[tokio::test] + async fn a_corrupt_snapshot_is_ignored_rather_than_fatal() -> std::io::Result<()> { + let dir = tempfile::tempdir()?; + let store = JsonCapabilityStore::new(dir.path()); + tokio::fs::write(store.path_for("provider"), b"{ this is not json").await?; + + assert_eq!(store.load("provider").await, ProviderCapabilities::default()); + Ok(()) + } +} diff --git a/backend/iptv/src/clock.rs b/backend/iptv/src/clock.rs new file mode 100644 index 000000000..0bf72c17b --- /dev/null +++ b/backend/iptv/src/clock.rs @@ -0,0 +1,20 @@ +//! Epoch-seconds view of the workspace clock. +//! +//! Stalker portals speak in whole seconds — cookie `Max-Age`, `Expires`, session age — +//! while [`tuliprox_core::utils::Clock`] reports milliseconds. This is the one conversion, +//! and the one place in the crate that reads the wall clock without being handed an +//! instant by its caller. + +use tuliprox_core::utils::{Clock, SystemClock}; + +/// Unix-epoch seconds according to `clock`. +#[inline] +pub fn epoch_secs(clock: &C) -> u64 { clock.now_ms().get() / 1_000 } + +/// Unix-epoch seconds according to the system clock. +/// +/// For the call sites that have no clock to hand. Prefer taking the instant as a parameter +/// — every expiry rule in this crate does — or holding a `C: Clock`. +#[inline] +#[must_use] +pub fn system_epoch_secs() -> u64 { epoch_secs(&SystemClock) } diff --git a/backend/iptv/src/epg.rs b/backend/iptv/src/epg.rs new file mode 100644 index 000000000..5858d7adf --- /dev/null +++ b/backend/iptv/src/epg.rs @@ -0,0 +1,269 @@ +//! One shape for "get this input's EPG", whatever kind of provider it is. +//! +//! EPG acquisition was wired per provider family and never met in the middle. Stalker has +//! three EPG calls in this crate, streaming programme records straight into the Stalker +//! repository. M3U and Xtream have none here at all: their XMLTV download lives in +//! `tuliprox-processing`, produces files on disk, and is reached from a completely +//! separate call site. Neither knew the other existed. +//! +//! What they genuinely have in common is the question — *does this input have an EPG, and +//! what did fetching it produce?* — so that is what [`EpgProvider`] unifies. What they do +//! not have in common is the answer's shape, and pretending otherwise would mean either +//! materialising a multi-hundred-megabyte XMLTV document into records in memory, or +//! teaching the Stalker client to write XMLTV it has no reason to write. [`EpgOutcome`] +//! says which of the two happened instead of forcing one into the other. + +use crate::stalker::{ + client::StalkerApiClient, error::StalkerError, profile::StalkerHandshake, transport::StalkerTransport, +}; +use shared::error::TuliproxError; +use std::{ + future::Future, + sync::atomic::{AtomicU64, Ordering}, +}; +/// A single programme. +/// +/// Aliased rather than redefined: this is already the workspace's only programme record, +/// and it lives in `tuliprox-core` rather than in any provider's module. The name it was +/// given there is the one thing about it that is Stalker-specific. +pub use tuliprox_core::model::StalkerProgramRecord as EpgProgramRecord; +use tuliprox_core::{model::ConfigInput, utils::Clock}; + +/// Where a provider delivers programme records. +/// +/// Batched rather than one-at-a-time because both the portal and the store on the other +/// end work in batches, and because a bulk EPG response can carry hundreds of thousands +/// of programmes — the whole reason this is a sink and not a `Vec` return. +pub trait EpgRecordSink: Send { + fn accept(&mut self, batch: Vec) -> impl Future> + Send; +} + +/// Collects everything in memory. For small windows — a single channel's short EPG — and +/// for tests. +#[derive(Debug, Default)] +pub struct CollectEpgSink { + records: Vec, +} + +impl CollectEpgSink { + #[must_use] + pub fn new() -> Self { Self::default() } + + #[must_use] + pub fn into_records(self) -> Vec { self.records } +} + +impl EpgRecordSink for CollectEpgSink { + fn accept(&mut self, mut batch: Vec) -> impl Future> + Send { + self.records.append(&mut batch); + async { Ok(()) } + } +} + +/// Counts without keeping. Useful when the interesting effect is elsewhere — a provider +/// that has already written each batch as it passed. +#[derive(Debug, Default)] +pub struct CountingEpgSink { + count: u64, +} + +impl CountingEpgSink { + #[must_use] + pub fn new() -> Self { Self::default() } + + #[must_use] + pub fn count(&self) -> u64 { self.count } +} + +impl EpgRecordSink for CountingEpgSink { + fn accept(&mut self, batch: Vec) -> impl Future> + Send { + self.count = self.count.saturating_add(u64::try_from(batch.len()).unwrap_or(u64::MAX)); + async { Ok(()) } + } +} + +/// What fetching an input's EPG produced. +/// +/// `G` is the provider's own guide handle — whatever the caller needs to read the files a +/// file-based provider left behind. Providers that stream records have no guide and use +/// `()`. +#[derive(Debug)] +pub enum EpgOutcome { + /// The provider streamed `count` programme records into the sink. + Records { count: u64 }, + /// The provider produced a guide over files on disk; the caller parses them lazily. + Guide(G), + /// The input has no EPG configured. Not a failure. + NotConfigured, +} + +impl EpgOutcome { + /// Whether anything was produced at all. + #[must_use] + pub fn is_empty(&self) -> bool { + match self { + Self::Records { count } => *count == 0, + Self::Guide(_) => false, + Self::NotConfigured => true, + } + } + + /// The guide, if this provider produced one. + #[must_use] + pub fn into_guide(self) -> Option { + match self { + Self::Guide(guide) => Some(guide), + _ => None, + } + } +} + +/// Everything every EPG provider needs. +pub struct EpgFetchRequest<'a> { + pub input: &'a ConfigInput, + /// How many hours ahead to fetch. Providers that fetch whole documents ignore it. + pub period_hours: u32, + /// Records per batch handed to the sink. + pub batch_size: usize, +} + +impl<'a> EpgFetchRequest<'a> { + #[must_use] + pub fn new(input: &'a ConfigInput) -> Self { Self { input, period_hours: 24, batch_size: 512 } } + + #[must_use] + pub fn period_hours(mut self, hours: u32) -> Self { + self.period_hours = hours; + self + } + + #[must_use] + pub fn batch_size(mut self, size: usize) -> Self { + self.batch_size = size.max(1); + self + } +} + +/// Fetch one input's EPG. +pub trait EpgProvider { + /// The guide handle this provider hands back, or `()` when it streams records. + type Guide; + + fn name(&self) -> &'static str; + + fn fetch( + &self, + request: &EpgFetchRequest<'_>, + sink: &mut impl EpgRecordSink, + ) -> impl Future, TuliproxError>> + Send; +} + +/// Stalker's bulk EPG, streamed straight through to the sink. +pub struct StalkerEpgProvider<'a, Tr: StalkerTransport, C: Clock> { + client: &'a StalkerApiClient, + handshake: &'a StalkerHandshake, +} + +impl<'a, Tr: StalkerTransport, C: Clock> StalkerEpgProvider<'a, Tr, C> { + pub fn new(client: &'a StalkerApiClient, handshake: &'a StalkerHandshake) -> Self { + Self { client, handshake } + } +} + +impl EpgProvider for StalkerEpgProvider<'_, Tr, C> { + type Guide = (); + + fn name(&self) -> &'static str { "stalker" } + + async fn fetch( + &self, + request: &EpgFetchRequest<'_>, + sink: &mut impl EpgRecordSink, + ) -> Result, TuliproxError> { + // The callback holds the sink across an await, so it needs an async-aware lock; + // and the count is tracked here rather than asked of the sink afterwards, because + // a sink that discards its input still has to report what passed through it. + let sink = tokio::sync::Mutex::new(sink); + let delivered = AtomicU64::new(0); + self.client + .stream_bulk_epg(self.handshake, request.period_hours, request.batch_size, |batch| { + let taken = u64::try_from(batch.len()).unwrap_or(u64::MAX); + let sink = &sink; + let delivered = &delivered; + async move { + sink.lock() + .await + .accept(batch) + .await + .map_err(|err| StalkerError::Io(std::io::Error::other(err.to_string())))?; + delivered.fetch_add(taken, Ordering::Relaxed); + Ok(()) + } + }) + .await + .map_err(|err| crate::error::stalker_error_to_tuliprox(&err))?; + Ok(EpgOutcome::Records { count: delivered.load(Ordering::Relaxed) }) + } +} + +#[cfg(test)] +mod tests { + use super::{CollectEpgSink, CountingEpgSink, EpgFetchRequest, EpgOutcome, EpgProgramRecord, EpgRecordSink}; + + fn program(title: &str) -> EpgProgramRecord { + EpgProgramRecord { + channel_id: Some("1".to_string()), + title: title.to_string(), + start_epoch: Some(1_700_000_000), + stop_epoch: Some(1_700_003_600), + description: None, + category: None, + } + } + + #[tokio::test] + async fn a_collecting_sink_keeps_every_batch_in_order() -> Result<(), shared::error::TuliproxError> { + let mut sink = CollectEpgSink::new(); + sink.accept(vec![program("A"), program("B")]).await?; + sink.accept(vec![program("C")]).await?; + + let titles: Vec = sink.into_records().into_iter().map(|record| record.title).collect(); + assert_eq!(titles, vec!["A", "B", "C"]); + Ok(()) + } + + #[tokio::test] + async fn a_counting_sink_reports_throughput_without_retaining_it() -> Result<(), shared::error::TuliproxError> { + let mut sink = CountingEpgSink::new(); + sink.accept(vec![program("A"), program("B")]).await?; + sink.accept(Vec::new()).await?; + assert_eq!(sink.count(), 2); + Ok(()) + } + + #[test] + fn an_unconfigured_epg_is_empty_but_not_a_failure() { + let outcome: EpgOutcome<()> = EpgOutcome::NotConfigured; + assert!(outcome.is_empty()); + assert!(outcome.into_guide().is_none()); + } + + #[test] + fn a_guide_outcome_hands_the_guide_back() { + let outcome = EpgOutcome::Guide("guide-handle"); + assert!(!outcome.is_empty()); + assert_eq!(outcome.into_guide(), Some("guide-handle")); + } + + #[test] + fn a_record_outcome_is_empty_only_when_nothing_arrived() { + assert!(EpgOutcome::<()>::Records { count: 0 }.is_empty()); + assert!(!EpgOutcome::<()>::Records { count: 1 }.is_empty()); + } + + #[test] + fn a_batch_size_of_zero_is_refused_rather_than_looping_forever() { + let input = tuliprox_core::model::ConfigInput::default(); + assert_eq!(EpgFetchRequest::new(&input).batch_size(0).batch_size, 1); + } +} diff --git a/backend/iptv/src/error.rs b/backend/iptv/src/error.rs new file mode 100644 index 000000000..6ee03e271 --- /dev/null +++ b/backend/iptv/src/error.rs @@ -0,0 +1,267 @@ +//! One question every provider failure can answer. +//! +//! The three provider families report failure in two incompatible ways: Stalker has a +//! typed [`StalkerError`] with fifteen variants, while M3U and Xtream hand back +//! `Vec` drawn from a workspace-wide enum of forty-odd categories. The +//! dispatcher had no way to ask either of them the questions it actually cares about — +//! *is this worth retrying? is the provider down, or is the config wrong?* — so it did +//! not ask. Every error was counted, logged and treated identically. +//! +//! Rather than introduce a third error type for everything to convert through, +//! [`ProviderErrorKind`] is a classification both existing types map onto. Errors keep +//! their identity; what is unified is the judgement. + +use crate::stalker::error::{StalkerError, StalkerErrorKind}; +use shared::error::{ErrorKind, TuliproxError}; + +/// What kind of failure a provider hit, independent of which error type carried it. +/// +/// Ordered by how much attention it deserves: [`Self::Config`] is the most severe, +/// because it will not fix itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ProviderErrorKind { + /// The provider or the network failed in a way that may not repeat. The only kind + /// worth retrying verbatim. + Transient, + /// The provider answered, but not in a shape we can use. Retrying gets the same + /// answer; the fix is a parser change or a different endpoint. + Protocol, + /// A limit we imposed was reached — a body cap, a page limit. Retrying hits it again; + /// the fix is to raise the limit or fetch less. + Capacity, + /// The provider rejected our identity. The fix is a new session, not a retry. + Auth, + /// The input is misconfigured. Nothing at runtime will fix it. + Config, +} + +/// The serializable form carried on the event bus. +/// +/// Exhaustive on purpose: a new [`ProviderErrorKind`] variant must decide what +/// it looks like to a subscriber rather than defaulting into one. +impl From for shared::model::ProviderFailureKind { + fn from(kind: ProviderErrorKind) -> Self { + match kind { + ProviderErrorKind::Transient => Self::Transient, + ProviderErrorKind::Protocol => Self::Protocol, + ProviderErrorKind::Capacity => Self::Capacity, + ProviderErrorKind::Auth => Self::Auth, + ProviderErrorKind::Config => Self::Config, + } + } +} + +impl ProviderErrorKind { + /// Whether repeating the same call could plausibly succeed. + /// + /// [`Self::Auth`] is deliberately excluded: it is recoverable, but by re-handshaking, + /// which is a different call. Reporting it as retryable is how a client ends up + /// hammering a portal with a token that portal has already refused. + #[must_use] + pub const fn is_retryable(self) -> bool { matches!(self, Self::Transient) } + + /// Whether this will still be true on the next scheduled refresh. Config problems + /// need a human; the rest may not. + #[must_use] + pub const fn needs_operator(self) -> bool { matches!(self, Self::Config) } + + /// Classify a workspace error — the form M3U and Xtream report in. + #[must_use] + pub fn of_tuliprox(error: &TuliproxError) -> Self { Self::of_error_kind(error.kind()) } + + /// Classify a [`shared::error::ErrorKind`]. + #[must_use] + pub fn of_error_kind(kind: ErrorKind) -> Self { + match kind { + // Anything named `Config*` is a config problem; enumerated rather than + // matched on the name so a renamed variant is a compile error, not a + // silent reclassification. + ErrorKind::Config + | ErrorKind::ConfigApp + | ErrorKind::ConfigCache + | ErrorKind::ConfigBase + | ErrorKind::ConfigApiProxy + | ErrorKind::ConfigEpg + | ErrorKind::ConfigHdhomerun + | ErrorKind::ConfigInput + | ErrorKind::ConfigIpCheck + | ErrorKind::ConfigLibrary + | ErrorKind::ConfigMetadataUpdate + | ErrorKind::ConfigPanelApi + | ErrorKind::ConfigProxy + | ErrorKind::ConfigProxyType + | ErrorKind::ConfigProxyUserStatus + | ErrorKind::ConfigQosAggregation + | ErrorKind::ConfigRateLimit + | ErrorKind::ConfigReverseProxy + | ErrorKind::ConfigSort + | ErrorKind::ConfigSource + | ErrorKind::ConfigStream + | ErrorKind::ConfigStreamHistory + | ErrorKind::ConfigVideoDownload + | ErrorKind::ConfigTarget + | ErrorKind::ConfigWebUi + | ErrorKind::UrlParse + | ErrorKind::FilterParse + | ErrorKind::RegexCompile => Self::Config, + + // The account was refused. + ErrorKind::ProxyUser => Self::Auth, + + // Reached the provider, could not use the answer. + ErrorKind::Parse | ErrorKind::Mapper | ErrorKind::ApiXtream | ErrorKind::Probe => Self::Protocol, + + // Storage and network trouble: worth another attempt. + ErrorKind::Io + | ErrorKind::Download + | ErrorKind::ProviderConnection + | ErrorKind::Server + | ErrorKind::Task + | ErrorKind::Crypto + | ErrorKind::Errors + | ErrorKind::Repository + | ErrorKind::RepositoryEpg + | ErrorKind::RepositoryXtream + | ErrorKind::RepositoryM3u + | ErrorKind::RepositoryStalker + | ErrorKind::RepositoryLibrary + | ErrorKind::RepositoryStorage + | ErrorKind::RepositoryNetwork + | ErrorKind::RepositoryPlaylist + | ErrorKind::RepositoryTrakt => Self::Transient, + } + } + + /// The kind that most deserves attention out of `errors`, or `None` when there are + /// none. Used to decide what a partly-failed fetch means for the input as a whole. + #[must_use] + pub fn worst_of<'a>(errors: impl IntoIterator) -> Option { + errors.into_iter().map(Self::of_tuliprox).max() + } +} + +impl From for ProviderErrorKind { + fn from(kind: StalkerErrorKind) -> Self { + match kind { + StalkerErrorKind::Auth => Self::Auth, + StalkerErrorKind::Transient => Self::Transient, + StalkerErrorKind::Protocol => Self::Protocol, + StalkerErrorKind::Capacity => Self::Capacity, + StalkerErrorKind::Config => Self::Config, + } + } +} + +impl From<&StalkerError> for ProviderErrorKind { + fn from(error: &StalkerError) -> Self { error.kind().into() } +} + +/// Convert a Stalker failure into the workspace error type without losing its +/// classification. +/// +/// The previous conversion flattened every Stalker failure to `ProviderConnection`, which +/// reads as "the network had a bad moment" — so a misconfigured portal URL and a rejected +/// password were both reported as connection trouble and were both counted as retryable. +#[must_use] +pub fn stalker_error_to_tuliprox(error: &StalkerError) -> TuliproxError { + let message = format!("Stalker client error: {error}"); + match ProviderErrorKind::from(error) { + ProviderErrorKind::Config => TuliproxError::ConfigInput(message), + ProviderErrorKind::Auth => TuliproxError::ProxyUser(message), + ProviderErrorKind::Protocol => TuliproxError::Parse(message), + ProviderErrorKind::Capacity | ProviderErrorKind::Transient => TuliproxError::ProviderConnection(message), + } +} + +#[cfg(test)] +mod tests { + use super::ProviderErrorKind; + use crate::stalker::{action::StalkerAction, error::StalkerError}; + use shared::error::TuliproxError; + + #[test] + fn only_transient_failures_are_worth_repeating() { + assert!(ProviderErrorKind::Transient.is_retryable()); + for kind in [ + ProviderErrorKind::Auth, + ProviderErrorKind::Protocol, + ProviderErrorKind::Capacity, + ProviderErrorKind::Config, + ] { + assert!(!kind.is_retryable(), "{kind:?} must not be retried verbatim"); + } + } + + #[test] + fn a_config_problem_is_the_one_that_needs_a_human() { + assert!(ProviderErrorKind::Config.needs_operator()); + assert!(!ProviderErrorKind::Transient.needs_operator()); + } + + #[test] + fn workspace_errors_classify_by_category() { + assert_eq!( + ProviderErrorKind::of_tuliprox(&TuliproxError::ConfigInput("bad url".to_string())), + ProviderErrorKind::Config + ); + assert_eq!( + ProviderErrorKind::of_tuliprox(&TuliproxError::Download("timeout".to_string())), + ProviderErrorKind::Transient + ); + assert_eq!( + ProviderErrorKind::of_tuliprox(&TuliproxError::Parse("bad json".to_string())), + ProviderErrorKind::Protocol + ); + } + + #[test] + fn stalker_and_workspace_errors_reach_the_same_verdict() { + let stalker = StalkerError::NoEndpoint { portal: "p".into() }; + let workspace = TuliproxError::ConfigInput("no endpoint".to_string()); + + assert_eq!(ProviderErrorKind::from(&stalker), ProviderErrorKind::of_tuliprox(&workspace)); + } + + /// The point of ordering the enum: a fetch that hit one blip and one bad config must + /// be judged on the config problem. + #[test] + fn the_worst_failure_in_a_batch_decides() { + let errors = vec![ + TuliproxError::Download("timeout".to_string()), + TuliproxError::ConfigInput("bad url".to_string()), + TuliproxError::Download("timeout".to_string()), + ]; + assert_eq!(ProviderErrorKind::worst_of(&errors), Some(ProviderErrorKind::Config)); + assert_eq!(ProviderErrorKind::worst_of(&[]), None); + } + + /// Flattening every Stalker failure to one workspace category was how a rejected + /// password ended up indistinguishable from a network blip. + #[test] + fn converting_a_stalker_error_preserves_what_kind_it_was() { + let cases = [ + (StalkerError::NoEndpoint { portal: "p".into() }, ProviderErrorKind::Config), + (StalkerError::TokenRejected { status: 401, url: None }, ProviderErrorKind::Auth), + (StalkerError::HtmlResponse { snippet: "".into() }, ProviderErrorKind::Protocol), + ( + StalkerError::BadStatus { + status: 502, + action: StalkerAction::GetOrderedList, + body_snippet: String::new(), + }, + ProviderErrorKind::Transient, + ), + ]; + + for (error, expected) in cases { + let converted = super::stalker_error_to_tuliprox(&error); + assert_eq!(ProviderErrorKind::of_tuliprox(&converted), expected, "{error}"); + } + } + + #[test] + fn a_converted_error_still_says_what_went_wrong() { + let converted = super::stalker_error_to_tuliprox(&StalkerError::TokenRejected { status: 403, url: None }); + assert!(converted.message().contains("rejected the token")); + } +} diff --git a/backend/iptv/src/lib.rs b/backend/iptv/src/lib.rs index 96a02c094..d9d8ebddd 100644 --- a/backend/iptv/src/lib.rs +++ b/backend/iptv/src/lib.rs @@ -1,3 +1,10 @@ +pub mod capabilities; +pub mod capability_store; +pub mod clock; +pub mod epg; +pub mod error; pub mod m3u; +pub mod provider; +pub mod redaction; pub mod stalker; pub mod xtream; diff --git a/backend/iptv/src/provider.rs b/backend/iptv/src/provider.rs new file mode 100644 index 000000000..16c23622b --- /dev/null +++ b/backend/iptv/src/provider.rs @@ -0,0 +1,249 @@ +//! One shape for "fetch this input's playlist", whatever kind of provider it is. +//! +//! The three provider families in this crate were modelled three different ways — M3U as +//! free functions, Xtream as free functions with a different arity, Stalker as a struct +//! client whose orchestration lives in `tuliprox-processing` — and each returned a +//! differently-shaped tuple. The dispatcher paid for that: a ninety-line `match` whose +//! arms each hand-assembled a six-element tuple, padding the fields their provider did not +//! produce with literal zeros and `false`s, and then destructured the lot positionally. +//! Two of those six elements were dead on arrival. +//! +//! [`PlaylistFetch`] is that result with names, and [`PlaylistProvider`] is the one method +//! every family can implement. Providers are constructed per-input with whatever they +//! individually need — an event sink, a refresh mode — so `fetch` takes only what all of +//! them take. +//! +//! Dispatch stays a `match` and stays statically dispatched: the win is that each arm now +//! names one type instead of assembling a tuple by position. + +use crate::error::ProviderErrorKind; +use shared::{ + error::TuliproxError, + model::{PlaylistGroup, XtreamCluster}, +}; +use std::{future::Future, sync::Arc}; +use tuliprox_core::model::{AppConfig, Config, ConfigInput}; + +/// What a provider produced for one input. +/// +/// `errors` is not exclusive with `groups`: a provider that fetched two of three clusters +/// reports both, and the dispatcher decides what that means for the input's cache status. +#[derive(Debug, Default)] +pub struct PlaylistFetch { + pub groups: Vec, + pub errors: Vec, + /// The provider wrote the playlist to disk itself, so the caller must not. + pub persisted: bool, + /// The fetch stopped part-way and should be resumed rather than treated as a result. + /// Only the resumable Stalker refresh sets this. + pub partial: bool, +} + +impl PlaylistFetch { + /// A successful fetch of `groups`. + #[must_use] + pub fn groups(groups: Vec) -> Self { Self { groups, ..Self::default() } } + + /// A fetch that produced nothing and failed for one reason. + #[must_use] + pub fn failed(error: TuliproxError) -> Self { Self { errors: vec![error], ..Self::default() } } + + /// A provider that has nothing to do for this input — a batch input whose members are + /// fetched individually, say. Distinct from a failure. + #[must_use] + pub fn nothing_to_do() -> Self { Self::default() } + + #[must_use] + pub fn with_errors(mut self, errors: Vec) -> Self { + self.errors = errors; + self + } + + #[must_use] + pub fn persisted(mut self, persisted: bool) -> Self { + self.persisted = persisted; + self + } + + #[must_use] + pub fn partial(mut self, partial: bool) -> Self { + self.partial = partial; + self + } + + /// Whether the fetch completed without errors. A fetch that produced no groups and no + /// errors counts as successful — an empty catalog is a legitimate answer. + #[must_use] + pub fn is_ok(&self) -> bool { self.errors.is_empty() && !self.partial } + + /// The failure that most deserves attention, or `None` when there was none. + /// + /// This is the question the dispatcher could not previously ask: providers reported + /// failure in two incompatible error types, so every error was counted, logged and + /// treated identically whether it was a timeout or a malformed portal URL. + #[must_use] + pub fn error_kind(&self) -> Option { ProviderErrorKind::worst_of(&self.errors) } + + /// Whether re-running this fetch unchanged could plausibly succeed. False when there + /// was nothing to retry. + #[must_use] + pub fn is_retryable(&self) -> bool { self.error_kind().is_some_and(ProviderErrorKind::is_retryable) } + + /// Whether the fetch failed for a reason no amount of retrying will fix. + #[must_use] + pub fn needs_operator(&self) -> bool { self.error_kind().is_some_and(ProviderErrorKind::needs_operator) } +} + +/// Everything every provider needs, and nothing that only one of them does. +/// +/// Provider-specific inputs — the event sink, the Stalker refresh mode — belong to the +/// provider value, which the dispatcher builds per input. +pub struct PlaylistFetchRequest<'a> { + pub app_config: &'a Arc, + pub config: &'a Arc, + pub client: &'a reqwest::Client, + pub input: &'a ConfigInput, + /// Which Xtream clusters still need fetching. `None` means "whatever the input + /// allows"; providers that have no clusters ignore it. + pub xtream_clusters: Option<&'a [XtreamCluster]>, +} + +/// Fetch one input's playlist. +pub trait PlaylistProvider { + /// A short name for this provider, for logs and errors. + fn name(&self) -> &'static str; + + fn fetch(&self, request: &PlaylistFetchRequest<'_>) -> impl Future + Send; +} + +/// Plain M3U playlist over HTTP or from a file. +#[derive(Debug, Clone, Copy, Default)] +pub struct M3uProvider; + +impl PlaylistProvider for M3uProvider { + fn name(&self) -> &'static str { "m3u" } + + async fn fetch(&self, request: &PlaylistFetchRequest<'_>) -> PlaylistFetch { + let (groups, errors) = + crate::m3u::download_m3u_playlist(request.app_config, request.client, request.config, request.input).await; + PlaylistFetch::groups(groups).with_errors(errors) + } +} + +/// Xtream Codes player API. Carries the event sink because account state — expiry, +/// suspension — is discovered during login and published as it is found. +pub struct XtreamProvider<'e, E> { + events: &'e E, +} + +impl<'e, E> XtreamProvider<'e, E> { + pub fn new(events: &'e E) -> Self { Self { events } } +} + +impl PlaylistProvider for XtreamProvider<'_, E> { + fn name(&self) -> &'static str { "xtream" } + + async fn fetch(&self, request: &PlaylistFetchRequest<'_>) -> PlaylistFetch { + let (groups, errors, persisted) = crate::xtream::download_xtream_playlist( + request.app_config, + request.client, + self.events, + request.input, + request.xtream_clusters, + ) + .await; + PlaylistFetch::groups(groups).with_errors(errors).persisted(persisted) + } +} + +/// A provider that is configured but cannot fetch — a catalog importer that does not exist +/// yet, or a staged input that reached the dispatcher without being resolved. +/// +/// Modelled as a provider rather than as a `match` arm returning a hand-built error so +/// that "this input type produces nothing, and here is why" is one thing with one shape. +pub struct UnsupportedProvider { + name: &'static str, + reason: String, +} + +impl UnsupportedProvider { + #[must_use] + pub fn new(name: &'static str, reason: impl Into) -> Self { Self { name, reason: reason.into() } } +} + +impl PlaylistProvider for UnsupportedProvider { + fn name(&self) -> &'static str { self.name } + + fn fetch(&self, _request: &PlaylistFetchRequest<'_>) -> impl Future { + std::future::ready(PlaylistFetch::failed(TuliproxError::Download(self.reason.clone()))) + } +} + +/// A provider whose members are fetched individually elsewhere; fetching the container +/// itself is a no-op rather than an error. +#[derive(Debug, Clone, Copy, Default)] +pub struct BatchContainerProvider; + +impl PlaylistProvider for BatchContainerProvider { + fn name(&self) -> &'static str { "batch" } + + fn fetch(&self, _request: &PlaylistFetchRequest<'_>) -> impl Future { + std::future::ready(PlaylistFetch::nothing_to_do()) + } +} + +#[cfg(test)] +mod tests { + use super::PlaylistFetch; + use shared::error::TuliproxError; + + #[test] + fn an_empty_catalog_is_a_success_not_a_failure() { + let fetch = PlaylistFetch::groups(Vec::new()); + assert!(fetch.is_ok()); + assert!(fetch.groups.is_empty()); + } + + #[test] + fn a_partial_fetch_is_not_reported_as_ok_even_without_errors() { + // The resumable Stalker refresh returns rows it has, with more to come; treating + // that as a complete result would cache a half-catalog. + assert!(!PlaylistFetch::groups(Vec::new()).partial(true).is_ok()); + } + + #[test] + fn a_failed_fetch_carries_its_reason() { + let fetch = PlaylistFetch::failed(TuliproxError::Download("portal unreachable".to_string())); + assert!(!fetch.is_ok()); + assert_eq!(fetch.errors.len(), 1); + assert!(!fetch.persisted); + } + + #[test] + fn nothing_to_do_is_distinct_from_failure() { + let fetch = PlaylistFetch::nothing_to_do(); + assert!(fetch.is_ok(), "a batch container producing nothing must not fail its input"); + assert_eq!(fetch.error_kind(), None); + } + + #[test] + fn a_blip_is_retryable_and_a_bad_config_is_not() { + let blip = PlaylistFetch::failed(TuliproxError::Download("timeout".to_string())); + assert!(blip.is_retryable()); + assert!(!blip.needs_operator()); + + let misconfigured = PlaylistFetch::failed(TuliproxError::ConfigInput("bad portal url".to_string())); + assert!(!misconfigured.is_retryable(), "retrying a bad URL forever helps nobody"); + assert!(misconfigured.needs_operator()); + } + + #[test] + fn a_fetch_is_judged_on_its_worst_failure() { + let fetch = PlaylistFetch::groups(Vec::new()).with_errors(vec![ + TuliproxError::Download("timeout".to_string()), + TuliproxError::ConfigInput("bad portal url".to_string()), + ]); + assert!(fetch.needs_operator(), "one blip must not hide a config problem"); + assert!(!fetch.is_retryable()); + } +} diff --git a/backend/iptv/src/redaction.rs b/backend/iptv/src/redaction.rs new file mode 100644 index 000000000..bad0dc760 --- /dev/null +++ b/backend/iptv/src/redaction.rs @@ -0,0 +1,198 @@ +//! What must never reach a log line, a stored error, or a debug dump. +//! +//! Provider URLs carry credentials in three places at once — userinfo (`user:pass@host`), +//! query parameters (`token=`, `mac=`, `password=`), and Stalker's base64 `cmd` — and this +//! crate had three unrelated answers to that: `safe_stalker_url` for error URLs, an inline +//! key list inside the debug-dump writer, and `sanitize_sensitive_info` from `shared` on +//! the Xtream side. The sibling `tuliprox-media-server` crate already has one module for +//! the same job; this is its counterpart, so the key list is defined once and every path +//! that renders a provider string goes through it. + +use shared::utils::sanitize_sensitive_info; + +const REDACTED: &str = "[redacted]"; + +/// Keys whose values are secret wherever they appear — as a query parameter, as a JSON +/// object key, or as a cookie name. Matched case-insensitively, as a substring for JSON +/// keys (portals prefix and suffix them freely) and whole-word for query parameters. +pub const SENSITIVE_KEYS: &[&str] = &[ + "token", + "password", + "passwd", + "credential", + "secret", + "auth", + "email", + "phone", + "account", + "login", + "username", + "mac", + "cmd", + "url", +]; + +/// Query parameters that carry a credential. A narrower list than [`SENSITIVE_KEYS`]: +/// dropping the whole query string is the norm for URLs, so this is for the cases where +/// the surrounding text has to survive. +pub const SENSITIVE_QUERY_KEYS: &[&str] = + &["token", "password", "passwd", "mac", "login", "username", "auth", "authorization", "api_key", "apikey", "cmd"]; + +/// True when a JSON object key or cookie name should have its value replaced. +/// +/// Substring rather than equality: portals emit `stb_token`, `account_id`, `user_email` +/// and a dozen other decorations of the same field. +#[must_use] +pub fn is_sensitive_key(key: &str) -> bool { + let lowered = key.to_ascii_lowercase(); + SENSITIVE_KEYS.iter().any(|sensitive| lowered.contains(sensitive)) +} + +/// Render a provider URL safely: userinfo, query and fragment removed. +/// +/// Anything that is not an `http`/`https` URL is refused outright rather than echoed — +/// a value that failed to parse is exactly the one most likely to be a raw credential. +#[must_use] +pub fn safe_url(value: &str) -> String { + let Ok(mut url) = url::Url::parse(value) else { + return "[redacted invalid URL]".to_string(); + }; + if !matches!(url.scheme(), "http" | "https") || url.set_username("").is_err() || url.set_password(None).is_err() { + return "[redacted invalid URL]".to_string(); + } + url.set_query(None); + url.set_fragment(None); + url.into() +} + +/// Redact credentials from free text that has to stay readable — a log message quoting a +/// URL, an error snippet. Keeps the structure, replaces the values. +#[must_use] +pub fn redact_text(value: &str) -> String { redact_query_like_tokens(&sanitize_sensitive_info(value)) } + +/// Replace the values of `key=` / `key:` pairs, leaving everything else intact. +fn redact_query_like_tokens(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + let mut rest = value; + 'outer: while !rest.is_empty() { + for key in SENSITIVE_QUERY_KEYS { + let Some(separator) = matched_key_separator(rest, key) else { continue }; + result.push_str(&rest[..key.len()]); + result.push(separator); + result.push_str(REDACTED); + rest = &rest[key.len() + separator.len_utf8()..]; + rest = skip_value(rest); + continue 'outer; + } + let Some(ch) = rest.chars().next() else { break }; + result.push(ch); + rest = &rest[ch.len_utf8()..]; + } + result +} + +/// The separator following `key` at the start of `text`, when `text` starts with that key +/// used as a `key=value` pair rather than as part of a longer word. +fn matched_key_separator(text: &str, key: &str) -> Option { + let candidate = text.get(..key.len())?; + if !candidate.eq_ignore_ascii_case(key) { + return None; + } + let separator = text.get(key.len()..)?.chars().next()?; + matches!(separator, '=' | ':').then_some(separator) +} + +/// Consume the value that follows a redacted key, honouring quotes. +fn skip_value(mut rest: &str) -> &str { + let quote = rest.chars().next().filter(|ch| matches!(ch, '"' | '\'')); + if let Some(quote) = quote { + rest = &rest[quote.len_utf8()..]; + while let Some(ch) = rest.chars().next() { + rest = &rest[ch.len_utf8()..]; + if ch == quote { + break; + } + } + return rest; + } + while let Some(ch) = rest.chars().next() { + if matches!(ch, '&' | ' ' | '\n' | '\r' | '\t' | '"' | '\'') { + break; + } + rest = &rest[ch.len_utf8()..]; + } + rest +} + +/// Replace every sensitive value in a JSON document, in place. +pub fn redact_json(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(fields) => { + for (key, field) in fields { + if is_sensitive_key(key) { + *field = serde_json::Value::String(REDACTED.to_string()); + } else { + redact_json(field); + } + } + } + serde_json::Value::Array(values) => values.iter_mut().for_each(redact_json), + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::{is_sensitive_key, redact_json, redact_text, safe_url}; + + #[test] + fn safe_url_drops_userinfo_query_and_fragment() { + let safe = safe_url("https://user:pass@portal.example/server/load.php?token=secret&mac=00:11:22#frag"); + assert_eq!(safe, "https://portal.example/server/load.php"); + } + + #[test] + fn a_value_that_is_not_a_url_is_refused_rather_than_echoed() { + assert_eq!(safe_url("not a url at all"), "[redacted invalid URL]"); + assert_eq!(safe_url("file:///etc/passwd"), "[redacted invalid URL]"); + } + + #[test] + fn sensitive_keys_match_the_decorations_portals_actually_emit() { + for key in ["token", "stb_token", "Account_Number", "user_email", "MAC", "cmd"] { + assert!(is_sensitive_key(key), "{key} should be treated as sensitive"); + } + for key in ["title", "category_id", "number", "genre"] { + assert!(!is_sensitive_key(key), "{key} should not be redacted"); + } + } + + #[test] + fn redact_text_keeps_the_structure_and_drops_the_values() { + let redacted = redact_text("GET /load.php?token=abc123&title=News&mac=00:1A:79:DE:AD:BE failed"); + assert!(!redacted.contains("abc123")); + assert!(!redacted.contains("00:1A:79:DE:AD:BE")); + assert!(redacted.contains("title=News")); + assert!(redacted.contains("failed")); + } + + #[test] + fn redact_text_does_not_corrupt_non_ascii() { + let redacted = redact_text("https://portal.example/épisode?token=sëcret&title=café"); + assert!(!redacted.contains("sëcret")); + assert!(redacted.contains("épisode")); + assert!(redacted.contains("title=café")); + } + + #[test] + fn redact_json_walks_nested_documents() { + let mut value = serde_json::json!({ + "js": {"token": "secret", "channels": [{"cmd": "ffmpeg http://host/x", "title": "News"}]} + }); + redact_json(&mut value); + let rendered = value.to_string(); + assert!(!rendered.contains("secret")); + assert!(!rendered.contains("ffmpeg")); + assert!(rendered.contains("News")); + } +} diff --git a/backend/iptv/src/stalker/action.rs b/backend/iptv/src/stalker/action.rs new file mode 100644 index 000000000..511ca64f8 --- /dev/null +++ b/backend/iptv/src/stalker/action.rs @@ -0,0 +1,187 @@ +//! The Stalker portal calls this client makes, as a closed set. +//! +//! Actions used to be `&'static str` threaded from the call site into +//! `send_json`, into the body-cap lookup, and into six error variants as a `String`. +//! The cap lookup matched on those strings with a silent fallback, and the strings it +//! matched were not the strings the call sites passed: catalog fetches announced +//! themselves as `get_ordered_list` and `get_all_channels` while the lookup tested for +//! `ordered_list` and `all_channels`. Both fell through to the fallback, so a user who +//! raised `ordered_list_mb` got the default anyway and never found out. +//! +//! An enum cannot have that bug. Every action names its cap, the compiler checks the +//! mapping is total, and the error variants carry something comparable instead of a +//! `String` that call sites had to spell identically to be matched later. + +use crate::stalker::client::StalkerBodyCaps; +use std::fmt; + +/// Cap for actions whose responses are small and fixed-shape — handshakes, profile, +/// capabilities. Unchanged from the fallback these previously landed on. +pub const DEFAULT_ACTION_BYTES: u64 = 8 * 1024 * 1024; + +/// One call against a Stalker portal. +/// +/// This is the label an action is known by in caps, errors and capability snapshots. It +/// is deliberately not the `action=` query parameter: two of these (`Handshake` and +/// `HandshakePortal`) send the same query against different endpoints, and telling them +/// apart in an error is the whole point. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StalkerAction { + Handshake, + HandshakeExtra, + HandshakePortal, + DoAuth, + GetProfile, + GetCapabilities, + GetGenres, + GetCategories, + GetOrderedList, + GetAllChannels, + SeriesInfo, + CreateLink, + GetShortEpg, + GetEpg, + GetBulkEpg, +} + +impl StalkerAction { + /// The stable name for this action, as it appears in logs, errors and persisted + /// capability snapshots. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Handshake => "handshake", + Self::HandshakeExtra => "handshake-extra", + Self::HandshakePortal => "handshake-portal", + Self::DoAuth => "do_auth", + Self::GetProfile => "get_profile", + Self::GetCapabilities => "get_capabilities", + Self::GetGenres => "get_genres", + Self::GetCategories => "get_categories", + Self::GetOrderedList => "get_ordered_list", + Self::GetAllChannels => "get_all_channels", + Self::SeriesInfo => "series_info", + Self::CreateLink => "create_link", + Self::GetShortEpg => "get_short_epg", + Self::GetEpg => "get_epg", + Self::GetBulkEpg => "get_epg_bulk", + } + } + + /// The response body cap for this action. + /// + /// Exhaustive by construction: adding an action without deciding what it may return + /// does not compile, which is what the old string match could not enforce. + #[must_use] + pub const fn cap_bytes(self, caps: &StalkerBodyCaps) -> u64 { + match self { + Self::CreateLink => caps.create_link_bytes, + // Catalog responses. `GetOrderedList` and `GetAllChannels` belong here and + // silently did not before. + Self::GetOrderedList | Self::GetAllChannels | Self::SeriesInfo | Self::GetGenres | Self::GetCategories => { + caps.ordered_list_bytes + } + Self::GetShortEpg | Self::GetEpg | Self::GetBulkEpg => caps.get_epg_bytes, + Self::Handshake + | Self::HandshakeExtra + | Self::HandshakePortal + | Self::DoAuth + | Self::GetProfile + | Self::GetCapabilities => DEFAULT_ACTION_BYTES, + } + } + + /// Whether a catalog fetch of this action can fall back to another strategy when the + /// portal does not implement it. + #[must_use] + pub const fn is_optional_catalog_shortcut(self) -> bool { matches!(self, Self::GetAllChannels) } +} + +impl fmt::Display for StalkerAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } +} + +#[cfg(test)] +mod tests { + use super::{StalkerAction, DEFAULT_ACTION_BYTES}; + use crate::stalker::client::StalkerBodyCaps; + + const ALL: [StalkerAction; 15] = [ + StalkerAction::Handshake, + StalkerAction::HandshakeExtra, + StalkerAction::HandshakePortal, + StalkerAction::DoAuth, + StalkerAction::GetProfile, + StalkerAction::GetCapabilities, + StalkerAction::GetGenres, + StalkerAction::GetCategories, + StalkerAction::GetOrderedList, + StalkerAction::GetAllChannels, + StalkerAction::SeriesInfo, + StalkerAction::CreateLink, + StalkerAction::GetShortEpg, + StalkerAction::GetEpg, + StalkerAction::GetBulkEpg, + ]; + + fn distinct_caps() -> StalkerBodyCaps { + StalkerBodyCaps { create_link_bytes: 111, ordered_list_bytes: 222, get_epg_bytes: 333 } + } + + /// The defect the enum exists to prevent: a catalog fetch that quietly ignored the + /// configured `ordered_list` cap because the label it announced and the label the + /// lookup tested for were different strings. + #[test] + fn catalog_actions_use_the_catalog_cap() { + let caps = distinct_caps(); + for action in [ + StalkerAction::GetOrderedList, + StalkerAction::GetAllChannels, + StalkerAction::SeriesInfo, + StalkerAction::GetGenres, + StalkerAction::GetCategories, + ] { + assert_eq!(action.cap_bytes(&caps), 222, "{action} must honour the configured catalog cap"); + } + } + + #[test] + fn playback_and_epg_actions_use_their_own_caps() { + let caps = distinct_caps(); + assert_eq!(StalkerAction::CreateLink.cap_bytes(&caps), 111); + for action in [StalkerAction::GetShortEpg, StalkerAction::GetEpg, StalkerAction::GetBulkEpg] { + assert_eq!(action.cap_bytes(&caps), 333, "{action} must honour the configured EPG cap"); + } + } + + #[test] + fn session_actions_keep_the_fixed_default() { + let caps = distinct_caps(); + for action in [ + StalkerAction::Handshake, + StalkerAction::HandshakeExtra, + StalkerAction::HandshakePortal, + StalkerAction::DoAuth, + StalkerAction::GetProfile, + StalkerAction::GetCapabilities, + ] { + assert_eq!(action.cap_bytes(&caps), DEFAULT_ACTION_BYTES); + } + } + + #[test] + fn every_action_has_a_distinct_name() { + let mut names: Vec<&str> = ALL.iter().map(|action| action.as_str()).collect(); + names.sort_unstable(); + let count = names.len(); + names.dedup(); + assert_eq!(names.len(), count, "two actions share a label and would be indistinguishable in errors"); + } + + /// The two handshake calls send the same `action=` query against different endpoints; + /// an error must still say which one failed. + #[test] + fn the_two_handshake_calls_are_distinguishable() { + assert_ne!(StalkerAction::Handshake.as_str(), StalkerAction::HandshakePortal.as_str()); + } +} diff --git a/backend/iptv/src/stalker/auth.rs b/backend/iptv/src/stalker/auth.rs index 05adb5f49..4e19a0d27 100644 --- a/backend/iptv/src/stalker/auth.rs +++ b/backend/iptv/src/stalker/auth.rs @@ -1,16 +1,19 @@ use crate::stalker::{ + action::StalkerAction, client::StalkerApiClient, error::{safe_stalker_url, StalkerError, StalkerResult}, presets::stalker_mag_preset_spec, profile::{StalkerHandshake, StalkerProviderProfile, StalkerRawProviderProfile}, recipes::{detect_fingerprint, fallback_recipes_for, recipe_spec_for}, session::StalkerSession, + transport::StalkerTransport, url_factory::StalkerLoadUrl, }; use log::{debug, info, warn}; use serde::Deserialize; use serde_json::Value; use shared::model::stalker::{StalkerAuthMode, StalkerBootstrapRecipe, StalkerPortalCapabilitiesDto}; +use tuliprox_core::utils::Clock; /// The fields we expect in a successful handshake response. The portal wraps the result /// in `{"js": {...}}` — we accept both wrapped and unwrapped shapes for robustness. @@ -43,15 +46,22 @@ pub struct StalkerHandshakeJs { /// call against the next recipe on any 4xx/5xx. Once a recipe succeeds we follow up with /// a `get_profile` call to extract account info and a `get_capabilities` call (best /// effort) to populate the `StalkerPortalCapabilitiesDto`. -pub async fn handshake(client: &StalkerApiClient) -> StalkerResult { +pub async fn handshake( + client: &StalkerApiClient, +) -> StalkerResult { let config = client.config(); let preset = config.mag_preset; - let chain = fallback_recipes_for(config.auth_mode, preset); + // Start from the recipe that last worked. The rest of the chain still follows, so a + // portal that has changed its mind is still reachable - this only spares us replaying + // four rejected handshakes against a portal whose answer we already know, which is + // the part that looks like credential stuffing from the provider's side. + let chain = prefer_remembered_recipe(client, fallback_recipes_for(config.auth_mode, preset)); let mut last_err: Option = None; for recipe in chain { match attempt_recipe(client, recipe).await { Ok(handshake) => { info!("Stalker handshake succeeded with recipe {recipe:?}"); + client.record_successful_handshake(&format!("{recipe:?}"), &handshake.session.load_url); return Ok(handshake); } Err(err) => { @@ -67,9 +77,28 @@ pub async fn handshake(client: &StalkerApiClient) -> StalkerResult StalkerResult { +/// Move the remembered recipe to the front of `chain`, keeping the rest in order. +fn prefer_remembered_recipe( + client: &StalkerApiClient, + chain: Vec, +) -> Vec { + let Some(remembered) = client.remembered_recipe() else { + return chain; + }; + let Some(position) = chain.iter().position(|recipe| format!("{recipe:?}") == remembered) else { + return chain; + }; + let mut chain = chain; + chain.swap(0, position); + chain +} + +async fn attempt_recipe( + client: &StalkerApiClient, + recipe: StalkerBootstrapRecipe, +) -> StalkerResult { let spec = recipe_spec_for(recipe); - let candidates = client.load_url_candidates().to_vec(); + let candidates = client.ordered_load_urls(); if candidates.is_empty() { return Err(StalkerError::NoEndpoint { portal: safe_stalker_url(client.portal_url()) }); } @@ -136,14 +165,14 @@ async fn attempt_recipe(client: &StalkerApiClient, recipe: StalkerBootstrapRecip })) } -async fn perform_handshake_against( - client: &StalkerApiClient, +async fn perform_handshake_against( + client: &StalkerApiClient, load_url: &StalkerLoadUrl, spec: &crate::stalker::recipes::StalkerRecipeSpec, ) -> StalkerResult<(StalkerSession, u16)> { let config = client.config(); let preset_spec = stalker_mag_preset_spec(config.mag_preset); - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(load_url)).query(&[ ("type", "stb"), ("action", "handshake"), ("JsHttpRequest", "1-xml"), @@ -151,10 +180,14 @@ async fn perform_handshake_against( ]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, None, spec.token_in_query || preset_spec.emit_token_query); - let response = client.send_with_cap(builder, "handshake", client.cap_for_action("handshake")).await?; + let response = client + .send_with_cap(builder, StalkerAction::Handshake, client.cap_for_action(StalkerAction::Handshake)) + .await?; client.ingest_response_cookies(&response); let status = response.status(); - let body = client.read_body_with_cap(response, "handshake", client.cap_for_action("handshake")).await?; + let body = client + .read_body_with_cap(response, StalkerAction::Handshake, client.cap_for_action(StalkerAction::Handshake)) + .await?; if !status.is_success() { if matches!(status.as_u16(), 401 | 403 | 456) { return Err(StalkerError::TokenRejected { @@ -167,7 +200,7 @@ async fn perform_handshake_against( url: Some(load_url.load_url.as_str().into()), }); } - let parsed: StalkerHandshakeResponse = client.decode_body_bytes(&body, "handshake")?; + let parsed: StalkerHandshakeResponse = client.decode_body_bytes(&body, StalkerAction::Handshake)?; let token = parsed.js.as_ref().and_then(|js| js.token.clone()).or(parsed.token.clone()).or_else(|| { // Some portals stash the token under the `text` field as a stringified object. parsed.text.as_ref().and_then(|t| { @@ -208,7 +241,7 @@ async fn perform_handshake_against( /// The account credentials to authenticate with, when the auth mode wants them. `MacOnly` /// explicitly opts out; every other mode forwards configured non-blank credentials. -fn account_credentials(client: &StalkerApiClient) -> Option<(String, String)> { +fn account_credentials(client: &StalkerApiClient) -> Option<(String, String)> { let config = client.config(); if matches!(config.auth_mode, StalkerAuthMode::MacOnly) { return None; @@ -221,15 +254,15 @@ fn account_credentials(client: &StalkerApiClient) -> Option<(String, String)> { /// Authenticate the account on the portal (`action=do_auth`). Stalker portals that pair /// MAC identities with account credentials reject all catalog calls until this step has /// been performed once per session. -async fn perform_do_auth( - client: &StalkerApiClient, +async fn perform_do_auth( + client: &StalkerApiClient, session: &StalkerSession, load_url: &StalkerLoadUrl, spec: &crate::stalker::recipes::StalkerRecipeSpec, login: &str, password: &str, ) -> StalkerResult<()> { - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(load_url)).query(&[ ("type", "stb"), ("action", "do_auth"), ("login", login), @@ -238,7 +271,7 @@ async fn perform_do_auth( ]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(session), spec.token_in_query); - let value: Value = client.send_json(builder, "do_auth").await?; + let value: Value = client.send_json(builder, StalkerAction::DoAuth).await?; // Portals answer `{"js": true}` on success and `{"js": false}` on bad credentials; // anything else (object payloads, missing key) is treated as success. if matches!(value.get("js"), Some(Value::Bool(false))) { @@ -250,20 +283,21 @@ async fn perform_do_auth( Ok(()) } -async fn perform_handshake_extra( - client: &StalkerApiClient, +async fn perform_handshake_extra( + client: &StalkerApiClient, session: &mut StalkerSession, load_url: &StalkerLoadUrl, spec: &crate::stalker::recipes::StalkerRecipeSpec, ) -> StalkerResult<()> { let mut builder = client - .http() .get(&load_url.load_url) .headers(client.common_headers(load_url)) .query(&[("type", "stb"), ("action", "handshake-extra")]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(session), spec.token_in_query); - let response = client.send_with_cap(builder, "handshake-extra", client.cap_for_action("handshake-extra")).await?; + let response = client + .send_with_cap(builder, StalkerAction::HandshakeExtra, client.cap_for_action(StalkerAction::HandshakeExtra)) + .await?; client.ingest_response_cookies(&response); let status = response.status(); if status.is_success() { @@ -271,27 +305,28 @@ async fn perform_handshake_extra( } else { Err(StalkerError::BadStatus { status: status.as_u16(), - action: "handshake-extra".to_string(), + action: StalkerAction::HandshakeExtra, body_snippet: String::new(), }) } } -async fn perform_portal_handshake( - client: &StalkerApiClient, +async fn perform_portal_handshake( + client: &StalkerApiClient, session: &StalkerSession, load_url: &StalkerLoadUrl, spec: &crate::stalker::recipes::StalkerRecipeSpec, ) -> StalkerResult<()> { let target = load_url; let mut builder = client - .http() .get(&target.load_url) .headers(client.common_headers(target)) .query(&[("type", "stb"), ("action", "handshake")]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(session), spec.token_in_query); - let response = client.send_with_cap(builder, "handshake-portal", client.cap_for_action("handshake-portal")).await?; + let response = client + .send_with_cap(builder, StalkerAction::HandshakePortal, client.cap_for_action(StalkerAction::HandshakePortal)) + .await?; client.ingest_response_cookies(&response); let status = response.status(); if status.is_success() { @@ -299,26 +334,26 @@ async fn perform_portal_handshake( } else { Err(StalkerError::BadStatus { status: status.as_u16(), - action: "handshake-portal".to_string(), + action: StalkerAction::HandshakePortal, body_snippet: String::new(), }) } } -async fn fetch_profile( - client: &StalkerApiClient, +async fn fetch_profile( + client: &StalkerApiClient, session: &StalkerSession, load_url: &StalkerLoadUrl, spec: &crate::stalker::recipes::StalkerRecipeSpec, ) -> StalkerResult { - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(load_url)).query(&[ ("type", "stb"), ("action", "get_profile"), ("HttpRequest", "1-xml"), ]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(session), spec.token_in_query); - let raw: serde_json::Value = client.send_json(builder, "get_profile").await?; + let raw: serde_json::Value = client.send_json(builder, StalkerAction::GetProfile).await?; // The portal sometimes returns the account fields under `js.*` and sometimes at the // top level. Merge both into a single object before deserialising. let merged = match raw { @@ -340,20 +375,19 @@ async fn fetch_profile( .map_err(|err| StalkerError::BodyDecode { message: format!("get_profile decode: {err}") }) } -async fn fetch_capabilities( - client: &StalkerApiClient, +async fn fetch_capabilities( + client: &StalkerApiClient, session: &StalkerSession, load_url: &StalkerLoadUrl, spec: &crate::stalker::recipes::StalkerRecipeSpec, ) -> StalkerResult { let mut builder = client - .http() .get(&load_url.load_url) .headers(client.common_headers(load_url)) .query(&[("type", "stb"), ("action", "get_capabilities")]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(session), spec.token_in_query); - let raw = client.send_json::(builder, "get_capabilities").await.ok(); + let raw = client.send_json::(builder, StalkerAction::GetCapabilities).await.ok(); let Some(value) = raw else { return Ok(StalkerPortalCapabilitiesDto::default()); }; diff --git a/backend/iptv/src/stalker/catalog.rs b/backend/iptv/src/stalker/catalog.rs index a5bff45c3..037d0493f 100644 --- a/backend/iptv/src/stalker/catalog.rs +++ b/backend/iptv/src/stalker/catalog.rs @@ -1,8 +1,11 @@ use crate::stalker::{ + action::StalkerAction, client::StalkerApiClient, error::{safe_stalker_url, StalkerError, StalkerResult}, + pagination::{BatchSink, CatalogPage, CatalogSink, CollectSink, PageMeta}, profile::StalkerHandshake, recipes::recipe_spec_for, + transport::StalkerTransport, url_factory::StalkerLoadUrl, }; use log::{info, warn}; @@ -12,6 +15,7 @@ use shared::{ model::stalker::StalkerStreamKind, utils::{deserialize_as_option_string, deserialize_number_from_string}, }; +use tuliprox_core::utils::Clock; /// A category returned by `get_*_categories`. Stalker portals wrap the list in `{"js": [...]}`. #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] @@ -239,12 +243,9 @@ pub struct StalkerRawSeriesDetails { pub seasons: Vec, } -#[derive(Debug)] -pub struct StalkerCatalogPage { - pub items: Vec, - pub next_page: Option, - pub total: Option, -} +/// Kept as the crate's public name for a catalog page; the type itself now lives in +/// [`crate::stalker::pagination`] so the page arithmetic has one home. +pub type StalkerCatalogPage = CatalogPage; #[derive(Debug, Clone, Default, Deserialize)] pub struct StalkerRawSeriesSeason { @@ -286,19 +287,19 @@ impl StalkerRawSeriesEpisode { // ----- Generic fetchers ------------------------------------------------------------------------- -async fn get_categories( - client: &StalkerApiClient, +async fn get_categories( + client: &StalkerApiClient, handshake: &StalkerHandshake, portal_type: &'static str, - action: &'static str, + action: StalkerAction, ) -> StalkerResult> { let spec = recipe_spec_for(handshake.profile.bootstrap_recipe); - let candidates = client.load_url_candidates().to_vec(); + let candidates = client.ordered_load_urls(); let mut last_err: Option = None; for load_url in candidates { - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ ("type", portal_type), - ("action", action), + ("action", action.as_str()), ("JsHttpRequest", "1-xml"), ("HttpRequest", "1-xml"), ]); @@ -358,30 +359,41 @@ fn collect_categories(value: &Value, out: &mut Vec) { } } -async fn get_paginated_items( - client: &StalkerApiClient, +/// Walk a paginated catalog endpoint, handing each page to `sink`. +/// +/// Endpoint candidates are tried in order; a mid-pagination failure abandons the candidate +/// and restarts on the next one, but only while the sink says that is still sound (see +/// [`CatalogSink::can_restart`]). Once a streaming sink has released a page, a later +/// failure is returned as-is rather than silently retried, so no caller can mistake a +/// truncated catalog for a complete one. +/// +/// Returns the number of rows delivered. +async fn drive_catalog_pages( + client: &StalkerApiClient, handshake: &StalkerHandshake, portal_type: &'static str, - action: &'static str, - parse_page: impl Fn(&Value) -> (Vec, Option, Option, Option), -) -> StalkerResult> + action: StalkerAction, + parse_page: impl Fn(&Value) -> (Vec, PageMeta), + sink: &mut S, +) -> StalkerResult where - T: for<'de> Deserialize<'de> + Clone, + T: for<'de> Deserialize<'de>, + S: CatalogSink, { let spec = recipe_spec_for(handshake.profile.bootstrap_recipe); let page_limit = client.catalog_max_pages(); - let candidates = client.load_url_candidates().to_vec(); + let candidates = client.ordered_load_urls(); let mut last_err: Option = None; 'candidates: for load_url in candidates { // Pagination state is per-candidate: a mid-pagination failure must not leak a // truncated catalog from one endpoint into the attempt against the next one. let mut page: u32 = 1; let mut pages_fetched: u32 = 0; - let mut all: Vec = Vec::new(); + let mut delivered: u64 = 0; loop { - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ ("type", portal_type), - ("action", action), + ("action", action.as_str()), ("JsHttpRequest", "1-xml"), ("HttpRequest", "1-xml"), ("p", page.to_string().as_str()), @@ -389,35 +401,30 @@ where builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(&handshake.session), spec.token_in_query); let value: Value = match client.send_json::(builder, action).await { - Ok(v) => v, + Ok(value) => value, Err(err) => { - // Never return a truncated catalog as Ok — retry from scratch on - // the next endpoint candidate instead. + if !sink.can_restart() { + return Err(err); + } + sink.restart(); last_err = Some(err); continue 'candidates; } }; - let (mut items, total_items, max_page_items, max_page) = parse_page(&value); + let (items, meta) = parse_page(&value); let page_len = items.len(); - if items.is_empty() { + if page_len == 0 { break; } pages_fetched = pages_fetched.saturating_add(1); - all.append(&mut items); - if total_items.is_some_and(|total| all.len() >= usize::try_from(total).unwrap_or(usize::MAX)) { - break; - } - // A short page (fewer rows than the advertised page size) is the last page. - if max_page_items.is_some_and(|max_items| page_len < usize::try_from(max_items).unwrap_or(usize::MAX)) { - break; - } - if max_page.is_some_and(|count| page >= count) { + delivered = delivered.saturating_add(page_len as u64); + sink.accept(items).await?; + if meta.is_terminal(page_len, page, usize::try_from(delivered).unwrap_or(usize::MAX)) { break; } if page >= page_limit { warn!( - "Stalker {portal_type}/{action} stopped at configured page limit {page_limit} with {} items fetched", - all.len() + "Stalker {portal_type}/{action} stopped at configured page limit {page_limit} with {delivered} items fetched" ); return Err(StalkerError::CatalogIncomplete { portal_type, @@ -427,10 +434,27 @@ where page += 1; } // An empty result from a healthy endpoint is a legitimate empty catalog. - info!("Stalker {portal_type}/{action} fetched {} items across {pages_fetched} pages", all.len()); - return Ok(all); + info!("Stalker {portal_type}/{action} fetched {delivered} items across {pages_fetched} pages"); + return Ok(delivered); } - Err(last_err.unwrap_or_else(|| StalkerError::EmptyBody { action: action.to_string() })) + Err(last_err.unwrap_or_else(|| StalkerError::EmptyBody { action })) +} + +/// Accumulating variant of [`drive_catalog_pages`], for callers that want the whole +/// catalog in one `Vec`. +async fn get_paginated_items( + client: &StalkerApiClient, + handshake: &StalkerHandshake, + portal_type: &'static str, + action: StalkerAction, + parse_page: impl Fn(&Value) -> (Vec, PageMeta), +) -> StalkerResult> +where + T: for<'de> Deserialize<'de> + Send, +{ + let mut sink = CollectSink::new(); + drive_catalog_pages(client, handshake, portal_type, action, parse_page, &mut sink).await?; + Ok(sink.into_rows()) } fn extract_items_array(value: &Value) -> &Value { @@ -440,29 +464,6 @@ fn extract_items_array(value: &Value) -> &Value { } } -fn catalog_js(value: &Value) -> &Value { value.as_object().and_then(|map| map.get("js")).unwrap_or(value) } - -fn extract_total_items(value: &Value) -> Option { - if let Some(obj) = catalog_js(value).as_object() { - return obj.get("total_items").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()); - } - None -} - -fn extract_max_page_items(value: &Value) -> Option { - catalog_js(value) - .as_object() - .and_then(|obj| obj.get("max_page_items").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok())) -} - -/// `max_page` is the total page count, not the page size — keep it strictly separate -/// from `max_page_items`. -fn extract_max_page(value: &Value) -> Option { - catalog_js(value) - .as_object() - .and_then(|obj| obj.get("max_page").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok())) -} - fn catalog_data(value: &Value) -> Option<&Value> { let js = value.as_object().and_then(|map| map.get("js")).unwrap_or(value); match js { @@ -479,7 +480,7 @@ fn parse_all_channels(value: &Value) -> StalkerResult> { let data = catalog_data(value).ok_or_else(|| StalkerError::BodyDecode { message: "get_all_channels response contained no channel collection".to_string(), })?; - let (items, _, _, _) = parse_items_page(data); + let (items, _) = parse_items_page(data); let source_len = match data { Value::Array(entries) => entries.len(), Value::Object(entries) => entries.len(), @@ -493,55 +494,40 @@ fn parse_all_channels(value: &Value) -> StalkerResult> { Ok(items) } -fn parse_item_catalog_page(value: &Value, current_page: u32) -> StalkerResult> { +/// Turn a catalog response into a page cursor. The `collection` guard rejects a body that +/// carried no row collection at all — distinct from a legitimately empty page, which is a +/// terminal page rather than an error. +fn parse_catalog_page(value: &Value, current_page: u32, collection: &'static str) -> StalkerResult> +where + T: for<'de> Deserialize<'de>, +{ catalog_data(value).ok_or_else(|| StalkerError::BodyDecode { - message: "get_ordered_list response contained no item collection".to_string(), + message: format!("get_ordered_list response contained no {collection} collection"), })?; - let (items, total, max_page_items, max_page) = parse_items_page(value); - let terminal = items.is_empty() - || max_page.is_some_and(|last| current_page >= last) - || max_page_items.is_some_and(|size| items.len() < usize::try_from(size).unwrap_or(usize::MAX)) - || total.is_some_and(|count| max_page_items.is_some_and(|size| current_page.saturating_mul(size) >= count)); - let next_page = (!terminal).then(|| current_page.checked_add(1)).flatten(); - Ok(StalkerCatalogPage { items, next_page, total }) + let (items, meta) = parse_rows_page::(value); + Ok(meta.into_page(items, current_page)) } -fn parse_series_catalog_page( - value: &Value, - current_page: u32, -) -> StalkerResult> { - catalog_data(value).ok_or_else(|| StalkerError::BodyDecode { - message: "get_ordered_list response contained no series collection".to_string(), - })?; - let (items, total, max_page_items, max_page) = parse_series_page(value); - let terminal = items.is_empty() - || max_page.is_some_and(|last| current_page >= last) - || max_page_items.is_some_and(|size| items.len() < usize::try_from(size).unwrap_or(usize::MAX)) - || total.is_some_and(|count| max_page_items.is_some_and(|size| current_page.saturating_mul(size) >= count)); - let next_page = (!terminal).then(|| current_page.checked_add(1)).flatten(); - Ok(StalkerCatalogPage { items, next_page, total }) -} - -async fn get_catalog_value( - client: &StalkerApiClient, +async fn get_catalog_value( + client: &StalkerApiClient, handshake: &StalkerHandshake, portal_type: &'static str, - action: &'static str, + action: StalkerAction, page: Option, ) -> StalkerResult { let spec = recipe_spec_for(handshake.profile.bootstrap_recipe); let mut last_err = None; - for load_url in client.load_url_candidates() { + for load_url in &client.ordered_load_urls() { let mut query = vec![ ("type", portal_type.to_string()), - ("action", action.to_string()), + ("action", action.as_str().to_string()), ("JsHttpRequest", "1-xml".to_string()), ("HttpRequest", "1-xml".to_string()), ]; if let Some(page) = page { query.push(("p", page.to_string())); } - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(load_url)).query(&query); + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(load_url)).query(&query); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(&handshake.session), spec.token_in_query); match client.send_json::(builder, action).await { @@ -552,43 +538,67 @@ async fn get_catalog_value( Err(last_err.unwrap_or_else(|| StalkerError::NoEndpoint { portal: safe_stalker_url(client.portal_url()) })) } -pub async fn get_all_channels( - client: &StalkerApiClient, +/// The bulk `get_all_channels` shortcut, which many portals simply do not implement. +/// +/// A portal that has already told us so is not asked again until the claim expires: the +/// caller falls back to paginated fetch, and re-probing an endpoint known to 404 on every +/// refresh is exactly the traffic pattern providers ban for. +pub async fn get_all_channels( + client: &StalkerApiClient, handshake: &StalkerHandshake, ) -> StalkerResult> { - let value = get_catalog_value(client, handshake, "itv", "get_all_channels", None).await?; - parse_all_channels(&value) + const ACTION: StalkerAction = StalkerAction::GetAllChannels; + if client.action_is_unsupported(ACTION) { + return Err(StalkerError::ActionUnsupported { action: ACTION }); + } + let result = async { + let value = get_catalog_value(client, handshake, "itv", ACTION, None).await?; + parse_all_channels(&value) + } + .await; + match &result { + Ok(_) => { + client.record_supported_action(ACTION); + } + Err(err) if err.is_unsupported_catalog_action() => { + if client.record_unsupported_action(ACTION) { + info!("Stalker portal does not implement {ACTION}; falling back to paginated fetch"); + } + } + Err(_) => {} + } + result } -pub async fn get_live_streams_page( - client: &StalkerApiClient, +pub async fn get_live_streams_page( + client: &StalkerApiClient, handshake: &StalkerHandshake, page: u32, ) -> StalkerResult> { - let value = get_catalog_value(client, handshake, "itv", "get_ordered_list", Some(page)).await?; - let response = parse_item_catalog_page(&value, page)?; + let value = get_catalog_value(client, handshake, "itv", StalkerAction::GetOrderedList, Some(page)).await?; + let response = parse_catalog_page::(&value, page, "item")?; apply_page_limit(&response, page, client.catalog_max_pages(), "itv")?; Ok(response) } -pub async fn get_vod_streams_page( - client: &StalkerApiClient, +pub async fn get_vod_streams_page( + client: &StalkerApiClient, handshake: &StalkerHandshake, page: u32, ) -> StalkerResult> { - let value = get_catalog_value(client, handshake, "vod", "get_ordered_list", Some(page)).await?; - let response = parse_item_catalog_page(&value, page)?; + let value = get_catalog_value(client, handshake, "vod", StalkerAction::GetOrderedList, Some(page)).await?; + let response = parse_catalog_page::(&value, page, "item")?; apply_page_limit(&response, page, client.catalog_max_pages(), "vod")?; Ok(response) } -pub async fn get_series_list_page( - client: &StalkerApiClient, +pub async fn get_series_list_page( + client: &StalkerApiClient, handshake: &StalkerHandshake, page: u32, ) -> StalkerResult> { - let value = get_catalog_value(client, handshake, "series", "get_ordered_list", Some(page)).await?; - let response = parse_series_catalog_page(&value, page)?; + let value = get_catalog_value(client, handshake, "series", StalkerAction::GetOrderedList, Some(page)).await?; + let response = parse_catalog_page::(&value, page, "series")?; apply_page_limit(&response, page, client.catalog_max_pages(), "series")?; Ok(response) } @@ -609,55 +619,98 @@ fn apply_page_limit( Ok(()) } -pub async fn get_live_categories( - client: &StalkerApiClient, +pub async fn get_live_categories( + client: &StalkerApiClient, handshake: &StalkerHandshake, ) -> StalkerResult> { - get_categories(client, handshake, "itv", "get_genres").await + get_categories(client, handshake, "itv", StalkerAction::GetGenres).await } -pub async fn get_vod_categories( - client: &StalkerApiClient, +pub async fn get_vod_categories( + client: &StalkerApiClient, handshake: &StalkerHandshake, ) -> StalkerResult> { - get_categories(client, handshake, "vod", "get_categories").await + get_categories(client, handshake, "vod", StalkerAction::GetCategories).await } -pub async fn get_series_categories( - client: &StalkerApiClient, +pub async fn get_series_categories( + client: &StalkerApiClient, handshake: &StalkerHandshake, ) -> StalkerResult> { - get_categories(client, handshake, "series", "get_categories").await + get_categories(client, handshake, "series", StalkerAction::GetCategories).await } -pub async fn get_live_streams_paginated( - client: &StalkerApiClient, +pub async fn get_live_streams_paginated( + client: &StalkerApiClient, handshake: &StalkerHandshake, ) -> StalkerResult> { - get_paginated_items(client, handshake, "itv", "get_ordered_list", parse_items_page).await + get_paginated_items(client, handshake, "itv", StalkerAction::GetOrderedList, parse_items_page).await } -pub async fn get_vod_streams_paginated( - client: &StalkerApiClient, +pub async fn get_vod_streams_paginated( + client: &StalkerApiClient, handshake: &StalkerHandshake, ) -> StalkerResult> { - get_paginated_items(client, handshake, "vod", "get_ordered_list", parse_items_page).await + get_paginated_items(client, handshake, "vod", StalkerAction::GetOrderedList, parse_items_page).await } -pub async fn get_series_list_paginated( - client: &StalkerApiClient, +pub async fn get_series_list_paginated( + client: &StalkerApiClient, handshake: &StalkerHandshake, ) -> StalkerResult> { - get_paginated_items(client, handshake, "series", "get_ordered_list", parse_series_page).await + get_paginated_items(client, handshake, "series", StalkerAction::GetOrderedList, parse_series_page).await } -pub async fn get_series_details( - client: &StalkerApiClient, +/// Stream the live catalog one page at a time. See [`drive_catalog_pages`] for the +/// endpoint-fallback caveat that comes with not buffering. +pub async fn stream_live_streams( + client: &StalkerApiClient, + handshake: &StalkerHandshake, + on_batch: F, +) -> StalkerResult +where + F: FnMut(Vec) -> Fut + Send, + Fut: std::future::Future> + Send, +{ + let mut sink = BatchSink::new(on_batch); + drive_catalog_pages(client, handshake, "itv", StalkerAction::GetOrderedList, parse_items_page, &mut sink).await +} + +/// Stream the VOD catalog one page at a time. +pub async fn stream_vod_streams( + client: &StalkerApiClient, + handshake: &StalkerHandshake, + on_batch: F, +) -> StalkerResult +where + F: FnMut(Vec) -> Fut + Send, + Fut: std::future::Future> + Send, +{ + let mut sink = BatchSink::new(on_batch); + drive_catalog_pages(client, handshake, "vod", StalkerAction::GetOrderedList, parse_items_page, &mut sink).await +} + +/// Stream the series catalog one page at a time. +pub async fn stream_series_list( + client: &StalkerApiClient, + handshake: &StalkerHandshake, + on_batch: F, +) -> StalkerResult +where + F: FnMut(Vec) -> Fut + Send, + Fut: std::future::Future> + Send, +{ + let mut sink = BatchSink::new(on_batch); + drive_catalog_pages(client, handshake, "series", StalkerAction::GetOrderedList, parse_series_page, &mut sink).await +} + +pub async fn get_series_details( + client: &StalkerApiClient, handshake: &StalkerHandshake, series_id: u32, ) -> StalkerResult { let spec = recipe_spec_for(handshake.profile.bootstrap_recipe); - let candidates = client.load_url_candidates().to_vec(); + let candidates = client.ordered_load_urls(); let mut last_err: Option = None; for load_url in candidates { match fetch_series_page(client, handshake, &load_url, spec.token_in_query, series_id, "0").await { @@ -676,8 +729,8 @@ pub async fn get_series_details( Err(last_err.unwrap_or_else(|| StalkerError::NoEndpoint { portal: safe_stalker_url(client.portal_url()) })) } -async fn fetch_series_page( - client: &StalkerApiClient, +async fn fetch_series_page( + client: &StalkerApiClient, handshake: &StalkerHandshake, load_url: &StalkerLoadUrl, token_in_query: bool, @@ -685,7 +738,7 @@ async fn fetch_series_page( season_id: &str, ) -> StalkerResult { let series_id = series_id.to_string(); - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(load_url)).query(&[ ("type", "series"), ("action", "get_ordered_list"), ("JsHttpRequest", "1-xml"), @@ -696,11 +749,11 @@ async fn fetch_series_page( ]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(&handshake.session), token_in_query); - client.send_json::(builder, "series_info").await + client.send_json::(builder, StalkerAction::SeriesInfo).await } -async fn hydrate_series_details( - client: &StalkerApiClient, +async fn hydrate_series_details( + client: &StalkerApiClient, handshake: &StalkerHandshake, load_url: &StalkerLoadUrl, token_in_query: bool, @@ -896,89 +949,48 @@ fn shell_episodes(value: &Value, season_number: u32) -> Vec (Vec, Option, Option, Option) { - let arr = extract_items_array(value); - let items: Vec = match arr { - Value::Array(a) => a.iter().filter_map(|v| serde_json::from_value::(v.clone()).ok()).collect(), - Value::Object(obj) => { - // Some portals use object-keyed data: { "data": { "100": {...}, "101": {...} } }. - let mut collected: Vec = Vec::new(); - if let Some(data) = obj.get("data") { - match data { - Value::Array(entries) => { - for v in entries { - if let Ok(item) = serde_json::from_value::(v.clone()) { - collected.push(item); - } - } - } - Value::Object(entries) => { - for v in entries.values() { - if let Ok(item) = serde_json::from_value::(v.clone()) { - collected.push(item); - } - } - } - _ => {} - } - } else { - for v in obj.values() { - if let Ok(item) = serde_json::from_value::(v.clone()) { - collected.push(item); - } - } - } - collected - } - _ => Vec::new(), - }; - let total = extract_total_items(value); - let max_page_items = extract_max_page_items(value); - (items, total, max_page_items, extract_max_page(value)) +/// Collect the rows out of a catalog page, together with its pagination hints. +/// +/// Portals emit the row collection in four shapes — a bare array, `data` as an array, +/// `data` as an id-keyed object, or the envelope object itself keyed by id — and this +/// walks all four. Rows that fail to deserialize are skipped rather than failing the +/// page, matching what every caller wanted before this was one function per row type. +fn parse_rows_page(value: &Value) -> (Vec, PageMeta) +where + T: for<'de> Deserialize<'de>, +{ + let items = collect_rows::(extract_items_array(value)); + (items, PageMeta::from_value(value)) } -fn parse_series_page(value: &Value) -> (Vec, Option, Option, Option) { - let arr = extract_items_array(value); - let items: Vec = match arr { - Value::Array(a) => { - a.iter().filter_map(|v| serde_json::from_value::(v.clone()).ok()).collect() - } - Value::Object(obj) => { - let mut collected: Vec = Vec::new(); - if let Some(data) = obj.get("data") { - match data { - Value::Array(entries) => { - for v in entries { - if let Ok(item) = serde_json::from_value::(v.clone()) { - collected.push(item); - } - } - } - Value::Object(entries) => { - for v in entries.values() { - if let Ok(item) = serde_json::from_value::(v.clone()) { - collected.push(item); - } - } - } - _ => {} - } - } else { - for v in obj.values() { - if let Ok(item) = serde_json::from_value::(v.clone()) { - collected.push(item); - } - } - } - collected - } +fn collect_rows(source: &Value) -> Vec +where + T: for<'de> Deserialize<'de>, +{ + match source { + Value::Array(entries) => entries.iter().filter_map(deserialize_row).collect(), + Value::Object(object) => match object.get("data") { + Some(Value::Array(entries)) => entries.iter().filter_map(deserialize_row).collect(), + Some(Value::Object(entries)) => entries.values().filter_map(deserialize_row).collect(), + // No `data` key at all: the envelope itself is the id-keyed row map. + None => object.values().filter_map(deserialize_row).collect(), + _ => Vec::new(), + }, _ => Vec::new(), - }; - let total = extract_total_items(value); - let max_page_items = extract_max_page_items(value); - (items, total, max_page_items, extract_max_page(value)) + } } +fn deserialize_row(value: &Value) -> Option +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_value::(value.clone()).ok() +} + +fn parse_items_page(value: &Value) -> (Vec, PageMeta) { parse_rows_page(value) } + +fn parse_series_page(value: &Value) -> (Vec, PageMeta) { parse_rows_page(value) } + #[cfg(test)] mod tests { use super::*; @@ -1006,7 +1018,7 @@ mod tests { "data": [{"id": "2", "name": "B"}] } }); - let page = parse_item_catalog_page(&value, 2)?; + let page = parse_catalog_page::(&value, 2, "item")?; assert_eq!(page.items.len(), 1); assert_eq!(page.total, Some(2)); assert_eq!(page.next_page, None); @@ -1031,7 +1043,7 @@ mod tests { "data": [{"id": "1", "name": "A"}] } }); - let page = parse_item_catalog_page(&value, 4)?; + let page = parse_catalog_page::(&value, 4, "item")?; assert_eq!(page.next_page, None); Ok(()) } @@ -1061,18 +1073,18 @@ mod tests { r#"{"js":{"total_items":2,"max_page":1,"data":{"100":{"id":"100","name":"Channel 100"},"101":{"id":"101","name":"Channel 101"}}}}"#, ) .unwrap(); - let (items, total, max_page_items, max_page) = parse_items_page(&v); + let (items, meta) = parse_items_page(&v); assert_eq!(items.len(), 2); assert_eq!(items[0].display_name(), "Channel 100"); - assert_eq!(total, Some(2)); - assert_eq!(max_page_items, None); - assert_eq!(max_page, Some(1)); + assert_eq!(meta.total_items, Some(2)); + assert_eq!(meta.max_page_items, None); + assert_eq!(meta.max_page, Some(1)); } #[test] fn parse_items_page_handles_array_form() { let v: Value = serde_json::from_str(r#"{"js":[{"id":"1","name":"A"}]}"#).unwrap(); - let (items, _, _, _) = parse_items_page(&v); + let (items, _) = parse_items_page(&v); assert_eq!(items.len(), 1); } @@ -1086,7 +1098,7 @@ mod tests { ] }); - let (items, _, _, _) = parse_items_page(&value); + let (items, _) = parse_items_page(&value); assert_eq!(items.len(), 3); assert_eq!(items[0].category_id(), Some("10")); @@ -1124,9 +1136,9 @@ mod tests { .unwrap(), ); assert!(single.is_ok(), "single item decode failed: {single:?}"); - let (items, total, max_page_items, _max_page) = parse_items_page(&v); - assert_eq!(total, Some(1)); - assert_eq!(max_page_items, Some(14)); + let (items, meta) = parse_items_page(&v); + assert_eq!(meta.total_items, Some(1)); + assert_eq!(meta.max_page_items, Some(14)); assert_eq!(items.len(), 1); assert_eq!(items[0].id.as_deref(), Some("686148")); assert_eq!(items[0].allow_local_timeshift, Some(true)); @@ -1154,14 +1166,14 @@ mod tests { #[test] fn max_page_is_not_misread_as_page_size() { - // `max_page` is a page COUNT, not a page size — it must surface in the fourth - // tuple slot only. + // `max_page` is a page COUNT, not a page size — it must land in `max_page` and + // leave `max_page_items` empty. let v: Value = serde_json::from_str(r#"{"js":{"max_page":50,"data":[{"id":"1","name":"A"}]}}"#).unwrap(); - let (items, total, max_page_items, max_page) = parse_items_page(&v); + let (items, meta) = parse_items_page(&v); assert_eq!(items.len(), 1); - assert_eq!(total, None); - assert_eq!(max_page_items, None); - assert_eq!(max_page, Some(50)); + assert_eq!(meta.total_items, None); + assert_eq!(meta.max_page_items, None); + assert_eq!(meta.max_page, Some(50)); } #[test] diff --git a/backend/iptv/src/stalker/client.rs b/backend/iptv/src/stalker/client.rs index 11944e9eb..7fbc0552f 100644 --- a/backend/iptv/src/stalker/client.rs +++ b/backend/iptv/src/stalker/client.rs @@ -1,14 +1,19 @@ -use crate::stalker::{ - auth, catalog, - cookie_jar::{apply_set_cookie_headers_unchecked, StalkerCookieJar}, - epg, - error::{StalkerError, StalkerResult}, - playback, - presets::stalker_mag_preset_spec, - profile::{StalkerHandshake, StalkerResolvedStream}, - recipes::apply_endpoint_preference, - session::StalkerSession, - url_factory::{load_url_candidates, StalkerLoadUrl}, +use crate::{ + capabilities::ProviderCapabilities, + stalker::{ + action::StalkerAction, + auth, catalog, + cookie_jar::{apply_set_cookie_headers_unchecked, StalkerCookieJar}, + epg, + error::{StalkerError, StalkerResult}, + playback, + presets::stalker_mag_preset_spec, + profile::{StalkerHandshake, StalkerResolvedStream}, + recipes::apply_endpoint_preference, + session::StalkerSession, + transport::{ReqwestTransport, StalkerTransport}, + url_factory::{load_url_candidates, StalkerLoadUrl}, + }, }; use bytes::{Bytes, BytesMut}; use log::{trace, warn}; @@ -25,7 +30,10 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; use tokio::sync::Mutex as AsyncMutex; -use tuliprox_core::model::{StalkerInputConfig, StalkerSizeCaps}; +use tuliprox_core::{ + model::{StalkerInputConfig, StalkerSizeCaps}, + utils::{Clock, SystemClock}, +}; /// Stalker portals expect a `X-User-Agent` header on every call. The `reqwest` re-export /// does not ship a constant for it (the upstream convention is to use a custom name), so @@ -73,10 +81,16 @@ impl From<&StalkerSizeCaps> for StalkerBodyCaps { } /// The Stalker/Ministra portal client. Built per-input so the cookie jar and session -/// never bleed between inputs. The client takes a `reqwest::Client` so the caller controls -/// connection pooling, timeouts and proxy settings. -pub struct StalkerApiClient { - http: Client, +/// never bleed between inputs. +/// +/// Its two ambient dependencies are type parameters rather than hard-wired globals: `T` +/// decides where a request goes and `C` decides what time it is. Both default to the +/// production implementation, so `StalkerApiClient::new` is unchanged and neither costs +/// anything at runtime — [`SystemClock`] is zero-sized and there is no vtable on either. +/// Tests name the other implementors; see [`crate::stalker::transport`]. +pub struct StalkerApiClient { + transport: Tr, + clock: C, portal_url: String, load_urls: Vec, config: StalkerInputConfig, @@ -87,15 +101,29 @@ pub struct StalkerApiClient { handshake: Mutex>, /// Serialises concurrent refresh attempts (e.g. 4xx-triggered re-handshake). refresh_lock: AsyncMutex<()>, + /// What this portal has already proven about itself. Seeded by the caller from a + /// [`crate::capability_store::CapabilityStore`] when one is available, and updated as + /// the portal answers. + capabilities: Mutex, } -impl StalkerApiClient { +impl StalkerApiClient { + /// The production client: a real `reqwest::Client`, so connection pooling, timeouts + /// and proxy settings stay under the caller's control, and the system clock. pub fn new(http: Client, portal_url: String, config: StalkerInputConfig) -> StalkerResult { + Self::with_parts(ReqwestTransport::new(http), SystemClock, portal_url, config) + } +} + +impl StalkerApiClient { + /// Build a client over a caller-supplied transport and clock. + pub fn with_parts(transport: Tr, clock: C, portal_url: String, config: StalkerInputConfig) -> StalkerResult { let load_urls = apply_endpoint_preference(config.endpoint_preference, load_url_candidates(&portal_url)?); let default_caps = StalkerSizeCaps::default(); let body_caps = StalkerBodyCaps::from(config.size_caps.as_ref().unwrap_or(&default_caps)); Ok(Self { - http, + transport, + clock, portal_url, load_urls, config, @@ -103,9 +131,68 @@ impl StalkerApiClient { cookies: StalkerCookieJar::new(), handshake: Mutex::new(None), refresh_lock: AsyncMutex::new(()), + capabilities: Mutex::new(ProviderCapabilities::default()), }) } + /// Seed the client with what a previous run learned about this portal. + #[must_use] + pub fn with_capabilities(self, capabilities: ProviderCapabilities) -> Self { + *self.capabilities.lock() = capabilities; + self + } + + /// The current snapshot, for persisting back to a store. + #[must_use] + pub fn capabilities(&self) -> ProviderCapabilities { self.capabilities.lock().clone() } + + /// Whether `action` is known not to work on this portal, according to a snapshot + /// still worth believing. + #[must_use] + pub fn action_is_unsupported(&self, action: StalkerAction) -> bool { + self.capabilities.lock().is_unsupported(action.as_str(), self.now_epoch_secs()) + } + + /// Note that the portal does not implement `action`. Returns whether this was new + /// information, so a caller can skip a write that would persist nothing. + pub fn record_unsupported_action(&self, action: StalkerAction) -> bool { + let now = self.now_epoch_secs(); + self.capabilities.lock().record_unsupported(action.as_str(), now) + } + + /// Note that `action` worked, taking back any earlier negative claim. + pub fn record_supported_action(&self, action: StalkerAction) -> bool { + let now = self.now_epoch_secs(); + self.capabilities.lock().record_supported(action.as_str(), now) + } + + /// Note the recipe and endpoint that completed a handshake, so the next one can start + /// there instead of walking the chain from the top. + pub fn record_successful_handshake(&self, recipe: &str, endpoint: &str) -> bool { + let now = self.now_epoch_secs(); + self.capabilities.lock().record_handshake(recipe, endpoint, now) + } + + /// The recipe that last completed a handshake here, when still worth believing. + #[must_use] + pub fn remembered_recipe(&self) -> Option { + let now = self.now_epoch_secs(); + self.capabilities.lock().remembered_recipe(now).map(ToString::to_string) + } + + /// The endpoint candidates in the order they should be tried, with the one that last + /// answered moved to the front. + #[must_use] + pub fn ordered_load_urls(&self) -> Vec { + let now = self.now_epoch_secs(); + self.capabilities.lock().prefer_remembered(self.load_urls.clone(), |url| url.load_url.clone(), now) + } + + /// Unix-epoch seconds according to this client's clock. Everything that expires - + /// sessions, cookies - is measured against this. + #[must_use] + pub fn now_epoch_secs(&self) -> u64 { crate::clock::epoch_secs(&self.clock) } + /// Returns the URL candidates this client will iterate through on errors. pub fn load_url_candidates(&self) -> &[StalkerLoadUrl] { &self.load_urls } @@ -134,7 +221,7 @@ impl StalkerApiClient { // Honour the soft TTL: a cached session older than `STALKER_SESSION_TTL` // is discarded and re-handshaken. The portal may invalidate tokens // earlier; that path is caught by the 4xx retry hook in `api_utils.rs`. - if !active.session.is_stale(crate::stalker::session::STALKER_SESSION_TTL) { + if !active.session.is_stale_at(self.now_epoch_secs(), crate::stalker::session::STALKER_SESSION_TTL) { return Ok(active); } // Stale: drop the cached handshake so the call below re-issues it. @@ -224,6 +311,35 @@ impl StalkerApiClient { catalog::get_series_details(self, handshake, series_id).await } + /// Stream the live catalog to `on_batch` one page at a time, instead of buffering the + /// whole thing like [`Self::get_live_streams`]. An `Err` means the batches already + /// delivered are an incomplete prefix and must be discarded. + pub async fn stream_live_streams(&self, handshake: &StalkerHandshake, on_batch: F) -> StalkerResult + where + F: FnMut(Vec) -> Fut + Send, + Fut: std::future::Future> + Send, + { + catalog::stream_live_streams(self, handshake, on_batch).await + } + + /// Stream the VOD catalog. See [`Self::stream_live_streams`] for the error contract. + pub async fn stream_vod_streams(&self, handshake: &StalkerHandshake, on_batch: F) -> StalkerResult + where + F: FnMut(Vec) -> Fut + Send, + Fut: std::future::Future> + Send, + { + catalog::stream_vod_streams(self, handshake, on_batch).await + } + + /// Stream the series catalog. See [`Self::stream_live_streams`] for the error contract. + pub async fn stream_series_list(&self, handshake: &StalkerHandshake, on_batch: F) -> StalkerResult + where + F: FnMut(Vec) -> Fut + Send, + Fut: std::future::Future> + Send, + { + catalog::stream_series_list(self, handshake, on_batch).await + } + pub async fn get_short_epg( &self, handshake: &StalkerHandshake, @@ -266,7 +382,8 @@ impl StalkerApiClient { // Internal helpers — used by auth/catalog/epg/playback submodules // ------------------------------------------------------------------------------------- - pub fn http(&self) -> &Client { &self.http } + /// Start a request against `url`. Stalker portals answer `GET` for every action. + pub fn get(&self, url: &str) -> RequestBuilder { self.transport.get(url) } pub fn portal_url(&self) -> &str { &self.portal_url } @@ -321,7 +438,7 @@ impl StalkerApiClient { } Err(err) => warn!("Stalker: dropping invalid Referer header value: {err}"), } - let now = crate::stalker::cookie_jar::now_epoch_secs(); + let now = self.now_epoch_secs(); let mut cookie_pairs = identity_cookie_pairs(&self.config); for (name, value) in self.cookies.active_cookies(now) { // Server-set cookies win over our synthesized identity values. @@ -379,14 +496,14 @@ impl StalkerApiClient { /// Persist Set-Cookie headers from a response into the jar. Called from every /// request helper after a successful response. pub fn ingest_response_cookies(&self, response: &Response) { - apply_set_cookie_headers_unchecked(&self.cookies, response.headers()).ok(); + apply_set_cookie_headers_unchecked(&self.cookies, response.headers(), self.now_epoch_secs()).ok(); } /// Send a request and decode the JSON body, applying the per-action body cap. When /// the body cap is exceeded the request is aborted and a `ResponseTooLarge` error is /// returned. The cap is enforced by inspecting the `Content-Length` header when /// present and otherwise by streaming the body in chunks of 64 KiB. - pub async fn send_json(&self, builder: RequestBuilder, cap_action: &'static str) -> StalkerResult + pub async fn send_json(&self, builder: RequestBuilder, cap_action: StalkerAction) -> StalkerResult where T: for<'de> Deserialize<'de>, { @@ -398,7 +515,7 @@ impl StalkerApiClient { if !status.is_success() { return Err(StalkerError::BadStatus { status: status.as_u16(), - action: cap_action.to_string(), + action: cap_action, body_snippet: String::from_utf8_lossy(&body[..body.len().min(256)]).into_owned(), }); } @@ -406,11 +523,11 @@ impl StalkerApiClient { // `code` field set to a 4xx value (e.g. `{"code": 44, "text": "Account is blocked"}`). // Those need to surface as `PortalBodyError` so `is_token_rejected()` flags them and // the proxy retry path can re-run `create_link` (or fall back gracefully). - if let Some(code) = Self::inspect_portal_code(&body) { + if let Some(code) = inspect_portal_code(&body) { if matches!(code, 44 | 440..=449) { return Err(StalkerError::PortalBodyError { code, - action: cap_action.to_string(), + action: cap_action, body_snippet: String::from_utf8_lossy(&body[..body.len().min(256)]).into_owned(), }); } @@ -421,7 +538,7 @@ impl StalkerApiClient { /// Decode the body bytes as JSON, optionally stripping a JSONP wrapper and a UTF-8 /// BOM. Returns the parsed value or a typed error. #[allow(clippy::unused_self)] - pub fn decode_body_bytes(&self, body: &[u8], action: &'static str) -> StalkerResult + pub fn decode_body_bytes(&self, body: &[u8], action: StalkerAction) -> StalkerResult where T: for<'de> Deserialize<'de>, { @@ -432,7 +549,7 @@ impl StalkerApiClient { return Err(StalkerError::HtmlResponse { snippet }); } if json.is_empty() { - return Err(StalkerError::EmptyBody { action: action.to_string() }); + return Err(StalkerError::EmptyBody { action }); } serde_json::from_str::(json).map_err(|err| { let snippet = json.chars().take(160).collect::(); @@ -440,43 +557,20 @@ impl StalkerApiClient { }) } - /// Inspect the body for a Stalker/Ministra portal-internal `code` field. The - /// middleware emits both `{"code": N, ...}` and `{"js": {"code": N, ...}}` shapes; - /// the wrapper can be a JSONP callback, so the same BOM/JSONP strip used for typed - /// decode applies. Returns the parsed `code` when present, otherwise `None`. - pub fn inspect_portal_code(body: &[u8]) -> Option { - let stripped = strip_bom(body); - let json = strip_jsonp(stripped); - if json.is_empty() { - return None; - } - let value: serde_json::Value = serde_json::from_str(json).ok()?; - // The Stalker/Ministra responses are either `{"code": N, ...}` directly, or wrapped - // in a `js` object as `{"js": {"code": N, ...}}`. Some endpoints return the data - // inside `js` as a stringified payload; we only handle the object form here. - let code = if let Some(n) = value.get("code").and_then(serde_json::Value::as_u64) { - n - } else { - let obj = value.get("js").and_then(serde_json::Value::as_object)?; - obj.get("code").and_then(serde_json::Value::as_u64)? - }; - u16::try_from(code).ok() - } - /// Send a request and reject an advertised body that exceeds the action cap. /// Body consumers must also use `read_body_with_cap` so chunked responses are /// bounded when no `Content-Length` header is present. pub async fn send_with_cap( &self, builder: RequestBuilder, - action: &'static str, + action: StalkerAction, cap: u64, ) -> StalkerResult { let request = builder.build().map_err(StalkerError::from)?; - let response = self.http.execute(request).await.map_err(StalkerError::from)?; + let response = self.transport.execute(request).await?; if let Some(content_length) = response.content_length() { if content_length > cap { - return Err(StalkerError::ResponseTooLarge { action: action.to_string(), cap_bytes: cap }); + return Err(StalkerError::ResponseTooLarge { action, cap_bytes: cap }); } } if response.status().is_success() { @@ -491,7 +585,7 @@ impl StalkerApiClient { pub async fn read_body_with_cap( &self, mut response: Response, - action: &'static str, + action: StalkerAction, cap: u64, ) -> StalkerResult { let initial_capacity = @@ -502,27 +596,41 @@ impl StalkerApiClient { while let Some(chunk) = response.chunk().await.map_err(StalkerError::from)? { received = received.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); if received > cap { - return Err(StalkerError::ResponseTooLarge { action: action.to_string(), cap_bytes: cap }); + return Err(StalkerError::ResponseTooLarge { action, cap_bytes: cap }); } body.extend_from_slice(&chunk); } let body = body.freeze(); - persist_stalker_debug_body(&self.portal_url, action, &body); + persist_stalker_debug_body(&self.portal_url, action.as_str(), &body); Ok(body) } - /// Resolve the body cap for a given action. The cap is taken from the user's - /// `StalkerSizeCaps`; defaults are inherited from `StalkerBodyCaps::default`. - pub fn cap_for_action(&self, action: &str) -> u64 { - match action { - "create_link" => self.body_caps.create_link_bytes, - "ordered_list" | "all_channels" | "vod" | "series_list" | "series_info" => { - self.body_caps.ordered_list_bytes - } - "get_epg" | "get_short_epg" => self.body_caps.get_epg_bytes, - _ => 8 * 1024 * 1024, - } + /// The body cap for `action`, from this client's configured [`StalkerBodyCaps`]. + #[must_use] + pub fn cap_for_action(&self, action: StalkerAction) -> u64 { action.cap_bytes(&self.body_caps) } +} + +/// Inspect the body for a Stalker/Ministra portal-internal `code` field. The +/// middleware emits both `{"code": N, ...}` and `{"js": {"code": N, ...}}` shapes; +/// the wrapper can be a JSONP callback, so the same BOM/JSONP strip used for typed +/// decode applies. Returns the parsed `code` when present, otherwise `None`. +pub fn inspect_portal_code(body: &[u8]) -> Option { + let stripped = strip_bom(body); + let json = strip_jsonp(stripped); + if json.is_empty() { + return None; } + let value: serde_json::Value = serde_json::from_str(json).ok()?; + // The Stalker/Ministra responses are either `{"code": N, ...}` directly, or wrapped + // in a `js` object as `{"js": {"code": N, ...}}`. Some endpoints return the data + // inside `js` as a stringified payload; we only handle the object form here. + let code = if let Some(n) = value.get("code").and_then(serde_json::Value::as_u64) { + n + } else { + let obj = value.get("js").and_then(serde_json::Value::as_object)?; + obj.get("code").and_then(serde_json::Value::as_u64)? + }; + u16::try_from(code).ok() } /// Strip a leading UTF-8 BOM (`EF BB BF`) from the body. @@ -699,45 +807,10 @@ fn sanitize_stalker_debug_body(body: &[u8]) -> Vec { let Ok(mut value) = serde_json::from_str::(strip_jsonp(strip_bom(body))) else { return b"[non-JSON Stalker response omitted]".to_vec(); }; - redact_stalker_debug_value(&mut value); + crate::redaction::redact_json(&mut value); serde_json::to_vec(&value).unwrap_or_else(|_| b"[Stalker response serialization failed]".to_vec()) } -fn redact_stalker_debug_value(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(fields) => { - for (key, value) in fields { - let key = key.to_ascii_lowercase(); - if [ - "token", - "password", - "passwd", - "credential", - "secret", - "auth", - "email", - "phone", - "account", - "login", - "username", - "mac", - "cmd", - "url", - ] - .iter() - .any(|sensitive| key.contains(sensitive)) - { - *value = serde_json::Value::String("[redacted]".to_string()); - } else { - redact_stalker_debug_value(value); - } - } - } - serde_json::Value::Array(values) => values.iter_mut().for_each(redact_stalker_debug_value), - _ => {} - } -} - fn rotate_stalker_debug_dumps(dump_dir: &Path) { let Ok(entries) = std::fs::read_dir(dump_dir) else { return; @@ -771,6 +844,293 @@ fn sanitize_dump_component(value: &str) -> String { .collect() } +#[cfg(test)] +mod transport_tests { + use super::*; + use crate::stalker::{ + action::StalkerAction, + catalog, + profile::{StalkerHandshake, StalkerProviderProfile, StalkerRawProviderProfile}, + recipes::fallback_recipes_for, + session::StalkerSession, + transport::testing::{FakeTransport, Reply}, + }; + use shared::model::stalker::{StalkerBootstrapRecipe, StalkerPortalCapabilitiesDto}; + use tuliprox_core::utils::ManualClock; + + const PORTAL: &str = "http://portal.example/stalker_portal/"; + + fn client_over( + replies: impl IntoIterator, + ) -> StalkerApiClient, ManualClock> { + client_with(std::sync::Arc::new(FakeTransport::new(replies)), StalkerInputConfig::default()) + } + + fn client_with( + transport: std::sync::Arc, + config: StalkerInputConfig, + ) -> StalkerApiClient, ManualClock> { + StalkerApiClient::with_parts(transport, ManualClock::new(10_000_000), PORTAL.to_string(), config) + .expect("portal url is well formed") + } + + fn handshake() -> StalkerHandshake { + let config = StalkerInputConfig::default(); + StalkerHandshake { + session: StalkerSession::new_at( + "token".to_string(), + format!("{PORTAL}c/"), + format!("{PORTAL}server/load.php"), + 10_000, + ), + profile: StalkerProviderProfile::from_config( + &config, + StalkerRawProviderProfile::default(), + StalkerBootstrapRecipe::GenericSafe, + fallback_recipes_for(config.auth_mode, config.mag_preset), + StalkerPortalCapabilitiesDto::default(), + StalkerSizeCaps::default(), + None, + None, + ), + } + } + + /// The portal reports account-level refusals inside a `200 OK` body. Nothing about + /// that path was reachable before the transport was a seam. + #[tokio::test] + async fn a_portal_refusal_hidden_in_a_200_body_surfaces_as_a_token_rejection() { + let client = client_over([Reply::ok(r#"{"code": 44, "text": "Account is blocked"}"#)]); + let err = client + .send_json::(client.get(PORTAL), StalkerAction::CreateLink) + .await + .expect_err("a portal code must not decode as success"); + assert!(matches!(err, StalkerError::PortalBodyError { code: 44, .. })); + assert!(err.is_token_rejected(), "the proxy retry path keys off this"); + } + + #[tokio::test] + async fn a_body_over_the_action_cap_is_refused_rather_than_buffered() { + let config = StalkerInputConfig { + size_caps: Some(StalkerSizeCaps { create_link_kb: 1, ..StalkerSizeCaps::default() }), + ..StalkerInputConfig::default() + }; + let oversized = "x".repeat(4096); + let client = client_with(std::sync::Arc::new(FakeTransport::new([Reply::ok(&oversized)])), config); + let err = client + .send_json::(client.get(PORTAL), StalkerAction::CreateLink) + .await + .expect_err("body above the cap must be refused"); + assert!(matches!(err, StalkerError::ResponseTooLarge { cap_bytes: 1024, .. })); + } + + #[tokio::test] + async fn an_html_error_page_is_not_mistaken_for_json() { + let client = client_over([Reply::ok("502 Bad Gateway")]); + let err = client + .send_json::(client.get(PORTAL), StalkerAction::GetOrderedList) + .await + .expect_err("HTML must not decode as JSON"); + assert!(matches!(err, StalkerError::HtmlResponse { .. })); + } + + /// The client walks `server/load.php`, then `portal.php`, then `c/`. Proving it moves + /// on after a 500 needed a portal that could fail on demand. + #[tokio::test] + async fn a_failing_endpoint_candidate_falls_through_to_the_next_one() { + let transport = std::sync::Arc::new(FakeTransport::new([ + Reply::Http(500, "upstream exploded".to_string()), + Reply::ok(r#"{"js": [{"id": "1", "title": "Sport"}]}"#), + ])); + let client = client_with(std::sync::Arc::clone(&transport), StalkerInputConfig::default()); + + let categories = catalog::get_live_categories(&client, &handshake()).await.expect("second candidate answers"); + + assert_eq!(categories.len(), 1); + assert_eq!(categories[0].title, "Sport"); + assert_eq!( + transport.requested_paths(), + vec!["/stalker_portal/server/load.php".to_string(), "/stalker_portal/portal.php".to_string()], + "both candidates should have been tried, in priority order" + ); + } + + /// Every candidate failing must surface the last real error, not a synthetic one. + #[tokio::test] + async fn exhausting_every_candidate_reports_the_last_failure() { + let client = client_over([ + Reply::Http(500, String::new()), + Reply::Http(500, String::new()), + Reply::Http(503, String::new()), + ]); + let err = catalog::get_live_categories(&client, &handshake()).await.expect_err("no candidate answers"); + assert!(matches!(err, StalkerError::BadStatus { status: 503, .. })); + } + + /// Pagination stops at the advertised last page instead of asking forever. + #[tokio::test] + async fn pagination_stops_when_the_portal_says_the_page_is_the_last_one() { + let transport = std::sync::Arc::new(FakeTransport::new([ + Reply::ok(r#"{"js":{"total_items":3,"max_page_items":2,"data":[{"id":"1"},{"id":"2"}]}}"#), + Reply::ok(r#"{"js":{"total_items":3,"max_page_items":2,"data":[{"id":"3"}]}}"#), + ])); + let client = client_with(std::sync::Arc::clone(&transport), StalkerInputConfig::default()); + + let items = client.get_live_streams(&handshake()).await.expect("two pages"); + + assert_eq!(items.len(), 3); + assert_eq!(transport.requested().len(), 2, "a third page must not be requested"); + } + + /// A mid-pagination failure must not hand back the rows collected so far. + #[tokio::test] + async fn a_truncated_catalog_is_never_returned_as_success() { + let client = client_over([ + Reply::ok(r#"{"js":{"total_items":10,"max_page_items":2,"data":[{"id":"1"},{"id":"2"}]}}"#), + Reply::Http(500, String::new()), + // Second candidate: restarts from page one, then dies too. + Reply::Http(500, String::new()), + Reply::Http(500, String::new()), + ]); + let result = client.get_live_streams(&handshake()).await; + assert!(result.is_err(), "a partial catalog must not be reported as complete"); + } + + /// The streaming variant makes the opposite trade, and says so: once a page is out, + /// the failure is returned rather than retried against another endpoint. + #[tokio::test] + async fn a_streaming_catalog_stops_instead_of_silently_restarting() { + let transport = std::sync::Arc::new(FakeTransport::new([ + Reply::ok(r#"{"js":{"total_items":10,"max_page_items":2,"data":[{"id":"1"},{"id":"2"}]}}"#), + Reply::Http(500, String::new()), + ])); + let client = client_with(std::sync::Arc::clone(&transport), StalkerInputConfig::default()); + + let seen = std::sync::Arc::new(parking_lot::Mutex::new(Vec::new())); + let sink = std::sync::Arc::clone(&seen); + let result = client + .stream_live_streams(&handshake(), move |batch: Vec| { + let sink = std::sync::Arc::clone(&sink); + async move { + sink.lock().extend(batch.into_iter().filter_map(|item| item.id)); + Ok(()) + } + }) + .await; + + assert!(result.is_err(), "the caller must be told the prefix is incomplete"); + assert_eq!(*seen.lock(), vec!["1".to_string(), "2".to_string()]); + assert_eq!(transport.requested().len(), 2, "no restart against the next candidate"); + } + + /// A portal that does not implement the bulk shortcut is asked exactly once. + #[tokio::test] + async fn an_unsupported_action_is_not_probed_again() { + let transport = std::sync::Arc::new(FakeTransport::new([ + // `get_all_channels` 404s on all three endpoint candidates. + Reply::Http(404, String::new()), + Reply::Http(404, String::new()), + Reply::Http(404, String::new()), + ])); + let client = client_with(std::sync::Arc::clone(&transport), StalkerInputConfig::default()); + + let first = catalog::get_all_channels(&client, &handshake()).await.expect_err("portal 404s"); + assert!(first.is_unsupported_catalog_action()); + let probes_after_first = transport.requested().len(); + + let second = catalog::get_all_channels(&client, &handshake()).await.expect_err("still unsupported"); + + assert!(matches!(second, StalkerError::ActionUnsupported { .. })); + assert!(second.is_unsupported_catalog_action(), "the caller's paginated fallback keys off this"); + assert_eq!(transport.requested().len(), probes_after_first, "the second call must not touch the network"); + } + + /// The negative claim is a hint with an expiry, not a permanent verdict. + #[tokio::test] + async fn an_expired_claim_lets_a_fixed_portal_be_retried() { + let clock = ManualClock::new(10_000_000); + let transport = std::sync::Arc::new(FakeTransport::new([ + Reply::Http(404, String::new()), + Reply::Http(404, String::new()), + Reply::Http(404, String::new()), + Reply::ok(r#"{"js": {"data": [{"id": "1", "name": "Now supported"}]}}"#), + ])); + let client = StalkerApiClient::with_parts( + std::sync::Arc::clone(&transport), + clock.clone(), + PORTAL.to_string(), + StalkerInputConfig::default(), + ) + .expect("portal url is well formed"); + + catalog::get_all_channels(&client, &handshake()).await.expect_err("portal 404s"); + clock.advance((crate::capabilities::CAPABILITY_TTL_SECS + 1) * 1_000); + + let channels = catalog::get_all_channels(&client, &handshake()).await.expect("re-probed after the TTL"); + assert_eq!(channels.len(), 1); + } + + /// A snapshot from a previous run moves the endpoint that answered to the front, so + /// the two that did not are never dialled again. + #[tokio::test] + async fn a_remembered_endpoint_is_tried_first() { + let transport = + std::sync::Arc::new(FakeTransport::new([Reply::ok(r#"{"js": [{"id": "1", "title": "Sport"}]}"#)])); + let mut capabilities = crate::capabilities::ProviderCapabilities::default(); + // 10_000_000 ms on the manual clock is 10_000 s. + capabilities.record_handshake("GenericSafe", &format!("{PORTAL}c/"), 10_000); + let client = client_with(std::sync::Arc::clone(&transport), StalkerInputConfig::default()) + .with_capabilities(capabilities); + + catalog::get_live_categories(&client, &handshake()).await.expect("remembered endpoint answers"); + + assert_eq!( + transport.requested_paths(), + vec!["/stalker_portal/c/".to_string()], + "the two endpoints that never answered must not be dialled" + ); + } + + /// A remembered endpoint that has since gone away must not strand the client. + #[tokio::test] + async fn a_remembered_endpoint_that_stops_answering_falls_back_to_the_others() { + let transport = std::sync::Arc::new(FakeTransport::new([ + Reply::Http(500, String::new()), + Reply::ok(r#"{"js": [{"id": "1", "title": "Sport"}]}"#), + ])); + let mut capabilities = crate::capabilities::ProviderCapabilities::default(); + capabilities.record_handshake("GenericSafe", &format!("{PORTAL}c/"), 10_000); + let client = client_with(std::sync::Arc::clone(&transport), StalkerInputConfig::default()) + .with_capabilities(capabilities); + + let categories = catalog::get_live_categories(&client, &handshake()).await.expect("another endpoint answers"); + + assert_eq!(categories.len(), 1); + assert_eq!(transport.requested_paths().len(), 2); + assert_eq!(transport.requested_paths()[0], "/stalker_portal/c/", "the remembered one is still tried first"); + } + + /// Session staleness is the client's own clock, so a cached handshake can be aged + /// past its TTL without waiting fifteen minutes. + #[test] + fn the_client_ages_a_session_against_its_own_clock() { + let clock = ManualClock::new(10_000_000); + let client = StalkerApiClient::with_parts( + std::sync::Arc::new(FakeTransport::new([])), + clock.clone(), + PORTAL.to_string(), + StalkerInputConfig::default(), + ) + .expect("portal url is well formed"); + + let session = StalkerSession::new_at("t".into(), "r".into(), "l".into(), client.now_epoch_secs()); + assert!(!session.is_stale_at(client.now_epoch_secs(), crate::stalker::session::STALKER_SESSION_TTL)); + + clock.advance(crate::stalker::session::STALKER_SESSION_TTL.as_secs() * 1_000); + assert!(session.is_stale_at(client.now_epoch_secs(), crate::stalker::session::STALKER_SESSION_TTL)); + } +} + #[cfg(test)] mod tests { use super::*; @@ -900,13 +1260,13 @@ mod tests { #[test] fn inspect_portal_code_detects_root_code_field() { let body = br#"{"code": 44, "text": "Account is blocked"}"#; - assert_eq!(StalkerApiClient::inspect_portal_code(body), Some(44)); + assert_eq!(inspect_portal_code(body), Some(44)); } #[test] fn inspect_portal_code_detects_js_wrapped_code() { let body = br#"{"js": {"code": 449, "text": "Token revoked"}}"#; - assert_eq!(StalkerApiClient::inspect_portal_code(body), Some(449)); + assert_eq!(inspect_portal_code(body), Some(449)); } #[test] @@ -914,31 +1274,31 @@ mod tests { // `code: 0` is the Stalker success indicator. `inspect_portal_code` returns the // raw number regardless; the 44xx filter is the caller's responsibility. let body = br#"{"js": {"code": 0}}"#; - assert_eq!(StalkerApiClient::inspect_portal_code(body), Some(0)); + assert_eq!(inspect_portal_code(body), Some(0)); } #[test] fn inspect_portal_code_handles_jsonp_wrapper() { let body = br#"callback({"code": 44, "text": "blocked"})"#; - assert_eq!(StalkerApiClient::inspect_portal_code(body), Some(44)); + assert_eq!(inspect_portal_code(body), Some(44)); } #[test] fn inspect_portal_code_handles_bom() { let mut body = vec![0xEF, 0xBB, 0xBF]; body.extend_from_slice(br#"{"code": 44}"#); - assert_eq!(StalkerApiClient::inspect_portal_code(&body), Some(44)); + assert_eq!(inspect_portal_code(&body), Some(44)); } #[test] fn inspect_portal_code_returns_none_when_absent() { let body = br#"{"js": {"data": []}}"#; - assert_eq!(StalkerApiClient::inspect_portal_code(body), None); + assert_eq!(inspect_portal_code(body), None); } #[test] fn inspect_portal_code_returns_none_for_empty_body() { - assert_eq!(StalkerApiClient::inspect_portal_code(b""), None); + assert_eq!(inspect_portal_code(b""), None); } #[test] @@ -956,16 +1316,28 @@ mod tests { #[test] fn is_token_rejected_recognises_portal_body_error() { // 44 and 440..=449 are recognised as token-rejected body errors. - assert!(StalkerError::PortalBodyError { code: 44, action: "create_link".into(), body_snippet: String::new() } - .is_token_rejected()); - assert!(StalkerError::PortalBodyError { code: 449, action: "create_link".into(), body_snippet: String::new() } - .is_token_rejected()); + assert!(StalkerError::PortalBodyError { + code: 44, + action: StalkerAction::CreateLink, + body_snippet: String::new() + } + .is_token_rejected()); + assert!(StalkerError::PortalBodyError { + code: 449, + action: StalkerAction::CreateLink, + body_snippet: String::new() + } + .is_token_rejected()); // Codes outside the 44xx band do not classify as token rejection. - assert!(!StalkerError::PortalBodyError { code: 11, action: "create_link".into(), body_snippet: String::new() } - .is_token_rejected()); + assert!(!StalkerError::PortalBodyError { + code: 11, + action: StalkerAction::CreateLink, + body_snippet: String::new() + } + .is_token_rejected()); assert!(!StalkerError::PortalBodyError { code: 500, - action: "create_link".into(), + action: StalkerAction::CreateLink, body_snippet: String::new(), } .is_token_rejected()); diff --git a/backend/iptv/src/stalker/cookie_jar.rs b/backend/iptv/src/stalker/cookie_jar.rs index e4ef164d5..aad820778 100644 --- a/backend/iptv/src/stalker/cookie_jar.rs +++ b/backend/iptv/src/stalker/cookie_jar.rs @@ -1,10 +1,6 @@ -use crate::stalker::error::StalkerResult; +use crate::{clock::system_epoch_secs, stalker::error::StalkerResult}; use parking_lot::RwLock; -use std::{ - collections::HashMap, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::{collections::HashMap, sync::Arc}; /// The cookie jar is intentionally cheaply cloneable (the inner map is wrapped in a /// `parking_lot::RwLock` so cloning a jar shares the storage; this is safe because all @@ -35,13 +31,16 @@ impl StalkerCookieJar { /// Merge a set of `Set-Cookie` header values into the jar. We accept a slice of raw /// header strings because `reqwest` exposes them as `HeaderValue` slices. - pub fn ingest_set_cookie(&self, cookies: I) + /// + /// `now_epoch_secs` is the instant a relative `Max-Age` is resolved against; the jar + /// never reads the clock itself. + pub fn ingest_set_cookie(&self, cookies: I, now_epoch_secs: u64) where I: IntoIterator, S: AsRef, { for raw in cookies { - if let Some(cookie) = parse_set_cookie(raw.as_ref()) { + if let Some(cookie) = parse_set_cookie(raw.as_ref(), now_epoch_secs) { self.insert(cookie); } } @@ -75,7 +74,7 @@ impl StalkerCookieJar { } } -fn parse_set_cookie(raw: &str) -> Option { +fn parse_set_cookie(raw: &str, now_epoch_secs: u64) -> Option { let mut parts = raw.split(';'); let head = parts.next()?.trim(); if head.is_empty() { @@ -97,7 +96,7 @@ fn parse_set_cookie(raw: &str) -> Option { if secs <= 0 { max_age = Some(0); } else if let Ok(delta) = u64::try_from(secs) { - max_age = Some(now_epoch_secs().saturating_add(delta)); + max_age = Some(now_epoch_secs.saturating_add(delta)); } } } else if key.eq_ignore_ascii_case("expires") { @@ -122,7 +121,9 @@ fn parse_cookie_expires(value: &str) -> Option { Some(u64::try_from(epoch).unwrap_or(0)) } -pub fn now_epoch_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) } +/// Unix-epoch seconds from the system clock, for the few call sites without one to hand. +#[must_use] +pub fn now_epoch_secs() -> u64 { system_epoch_secs() } /// Helper used by the client to ensure we never block longer than the configured timeout /// while parsing/merging cookies. The parsing helpers above are sync; the wrapper makes @@ -130,10 +131,11 @@ pub fn now_epoch_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).ma pub fn apply_set_cookie_headers_unchecked( jar: &StalkerCookieJar, headers: &reqwest::header::HeaderMap, + now_epoch_secs: u64, ) -> StalkerResult<()> { for value in headers.get_all(reqwest::header::SET_COOKIE) { if let Ok(raw) = value.to_str() { - jar.ingest_set_cookie([raw]); + jar.ingest_set_cookie([raw], now_epoch_secs); } } Ok(()) @@ -142,7 +144,7 @@ pub fn apply_set_cookie_headers_unchecked( #[cfg(test)] mod tests { use super::*; - use std::{thread::sleep, time::Duration}; + use std::time::Duration; pub fn cookie_age(jar: &StalkerCookieJar, name: &str) -> Option { let guard = jar.inner.read(); @@ -157,17 +159,16 @@ mod tests { #[test] fn parses_set_cookie_with_max_age() { - let cookie = parse_set_cookie("mac=00:1A:79:DE:AD:BE; Max-Age=3600; Path=/").expect("ok"); + let cookie = parse_set_cookie("mac=00:1A:79:DE:AD:BE; Max-Age=3600; Path=/", 1_000).expect("ok"); assert_eq!(cookie.name, "mac"); assert_eq!(cookie.value, "00:1A:79:DE:AD:BE"); - let exp = cookie.expires_at_epoch.expect("max-age should produce expiry"); - let now = now_epoch_secs(); - assert!(exp > now); + // Exactly `now + Max-Age`, which was unassertable while the jar read the clock. + assert_eq!(cookie.expires_at_epoch, Some(1_000 + 3_600)); } #[test] fn session_cookies_have_no_expiry() { - let cookie = parse_set_cookie("stb_lang=en; Path=/").expect("ok"); + let cookie = parse_set_cookie("stb_lang=en; Path=/", 1_000).expect("ok"); assert_eq!(cookie.name, "stb_lang"); assert!(cookie.expires_at_epoch.is_none()); } @@ -177,7 +178,7 @@ mod tests { let jar = StalkerCookieJar::new(); jar.insert(StalkerCookie { name: "dead".to_string(), value: "beef".to_string(), expires_at_epoch: Some(1) }); jar.insert(StalkerCookie { name: "alive".to_string(), value: "feed".to_string(), expires_at_epoch: None }); - let header = jar.active_cookie_header(now_epoch_secs()); + let header = jar.active_cookie_header(1_000); assert!(!header.contains("dead=beef")); assert!(header.contains("alive=feed")); } @@ -185,44 +186,43 @@ mod tests { #[test] fn jar_dedupes_by_name() { let jar = StalkerCookieJar::new(); - jar.ingest_set_cookie(["stb_lang=en"]); - jar.ingest_set_cookie(["stb_lang=de"]); - let header = jar.active_cookie_header(now_epoch_secs()); + jar.ingest_set_cookie(["stb_lang=en"], 1_000); + jar.ingest_set_cookie(["stb_lang=de"], 1_000); + let header = jar.active_cookie_header(1_000); assert_eq!(header.matches("stb_lang=").count(), 1); assert!(header.contains("stb_lang=de")); } #[test] fn max_age_zero_yields_immediate_expiry() { - let cookie = parse_set_cookie("kill=1; Max-Age=0").expect("ok"); + let cookie = parse_set_cookie("kill=1; Max-Age=0", 1_000).expect("ok"); assert_eq!(cookie.expires_at_epoch, Some(0)); } #[test] fn max_age_is_case_insensitive() { - let cookie = parse_set_cookie("mac=00:1A:79:DE:AD:BE; max-age=3600; path=/").expect("ok"); - let exp = cookie.expires_at_epoch.expect("lowercase max-age should produce expiry"); - assert!(exp > now_epoch_secs()); + let cookie = parse_set_cookie("mac=00:1A:79:DE:AD:BE; max-age=3600; path=/", 1_000).expect("ok"); + assert_eq!(cookie.expires_at_epoch, Some(1_000 + 3_600)); } #[test] fn parses_expires_attribute() { - let cookie = parse_set_cookie("sid=abc; Expires=Tue, 01 Jul 2042 10:00:00 GMT; Path=/").expect("ok"); + let cookie = parse_set_cookie("sid=abc; Expires=Tue, 01 Jul 2042 10:00:00 GMT; Path=/", 1_000).expect("ok"); let exp = cookie.expires_at_epoch.expect("expires should produce expiry"); - assert!(exp > now_epoch_secs()); + assert!(exp > 1_000); } #[test] fn past_expires_marks_cookie_expired() { - let cookie = parse_set_cookie("sid=abc; expires=Thu, 01 Jan 2004 00:00:00 GMT").expect("ok"); + let cookie = parse_set_cookie("sid=abc; expires=Thu, 01 Jan 2004 00:00:00 GMT", 1_700_000_000).expect("ok"); let exp = cookie.expires_at_epoch.expect("expires should parse"); - assert!(cookie.is_expired(now_epoch_secs())); + assert!(cookie.is_expired(1_700_000_000)); assert!(exp > 0); } #[test] fn max_age_wins_over_expires() { - let cookie = parse_set_cookie("sid=abc; Expires=Tue, 01 Jul 2042 10:00:00 GMT; Max-Age=0").expect("ok"); + let cookie = parse_set_cookie("sid=abc; Expires=Tue, 01 Jul 2042 10:00:00 GMT; Max-Age=0", 1_000).expect("ok"); assert_eq!(cookie.expires_at_epoch, Some(0)); } @@ -231,7 +231,7 @@ mod tests { let jar = StalkerCookieJar::new(); jar.insert(StalkerCookie { name: "dead".to_string(), value: "beef".to_string(), expires_at_epoch: Some(1) }); jar.insert(StalkerCookie { name: "alive".to_string(), value: "feed".to_string(), expires_at_epoch: None }); - let pairs = jar.active_cookies(now_epoch_secs()); + let pairs = jar.active_cookies(1_000); assert_eq!(pairs.len(), 1); assert_eq!(pairs[0], ("alive".to_string(), "feed".to_string())); } @@ -244,11 +244,12 @@ mod tests { } #[test] - fn sleep_short_does_not_break_jar() { - // Smoke test — ensures the jar remains usable after a thread sleep. + fn a_max_age_cookie_lives_exactly_as_long_as_advertised() { + // Was a sleep-based smoke test; the instant is a parameter now, so the boundary + // itself can be asserted. let jar = StalkerCookieJar::new(); - jar.ingest_set_cookie(["stb_lang=en; Max-Age=10"]); - sleep(Duration::from_millis(2)); - assert!(jar.active_cookie_header(now_epoch_secs()).contains("stb_lang=en")); + jar.ingest_set_cookie(["stb_lang=en; Max-Age=10"], 1_000); + assert!(jar.active_cookie_header(1_009).contains("stb_lang=en")); + assert!(jar.active_cookie_header(1_010).is_empty()); } } diff --git a/backend/iptv/src/stalker/epg.rs b/backend/iptv/src/stalker/epg.rs index df7ae037a..af58e33e9 100644 --- a/backend/iptv/src/stalker/epg.rs +++ b/backend/iptv/src/stalker/epg.rs @@ -1,8 +1,10 @@ use crate::stalker::{ + action::StalkerAction, client::StalkerApiClient, error::{safe_stalker_url, StalkerError, StalkerResult}, profile::StalkerHandshake, recipes::recipe_spec_for, + transport::StalkerTransport, }; use futures::StreamExt; use log::warn; @@ -27,6 +29,7 @@ use tokio_util::io::{StreamReader, SyncIoBridge}; // The persisted shape lives in `tuliprox_core::model`; re-exported here for // this module's own call sites. pub use tuliprox_core::model::StalkerProgramRecord; +use tuliprox_core::utils::Clock; #[derive(Debug, Default, Clone, Deserialize)] struct RawStalkerProgram { @@ -90,17 +93,17 @@ fn parse_stalker_timestamp(raw: &str) -> Option { } /// Short EPG (per channel, 2-24 hour window). Returns the parsed records on success. -pub async fn get_short_epg( - client: &StalkerApiClient, +pub async fn get_short_epg( + client: &StalkerApiClient, handshake: &StalkerHandshake, channel_id: u32, hours: u32, ) -> StalkerResult> { let spec = recipe_spec_for(handshake.profile.bootstrap_recipe); - let candidates = client.load_url_candidates().to_vec(); + let candidates = client.ordered_load_urls(); let mut last_err: Option = None; for load_url in candidates { - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ ("type", "itv"), ("action", "get_short_epg"), ("ch_id", &channel_id.to_string()), @@ -108,7 +111,7 @@ pub async fn get_short_epg( ]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(&handshake.session), spec.token_in_query); - match client.send_json::(builder, "get_short_epg").await { + match client.send_json::(builder, StalkerAction::GetShortEpg).await { Ok(value) => { return Ok(parse_epg_records(&value)); } @@ -121,17 +124,17 @@ pub async fn get_short_epg( } /// Per-channel full EPG. The portal returns the same payload shape as the short EPG. -pub async fn get_epg( - client: &StalkerApiClient, +pub async fn get_epg( + client: &StalkerApiClient, handshake: &StalkerHandshake, channel_id: u32, period_hours: u32, ) -> StalkerResult> { let spec = recipe_spec_for(handshake.profile.bootstrap_recipe); - let candidates = client.load_url_candidates().to_vec(); + let candidates = client.ordered_load_urls(); let mut last_err: Option = None; for load_url in candidates { - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ ("type", "itv"), ("action", "get_epg_info"), ("ch_id", &channel_id.to_string()), @@ -139,7 +142,7 @@ pub async fn get_epg( ]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(&handshake.session), spec.token_in_query); - match client.send_json::(builder, "get_epg").await { + match client.send_json::(builder, StalkerAction::GetEpg).await { Ok(value) => { return Ok(parse_epg_records(&value)); } @@ -154,8 +157,8 @@ pub async fn get_epg( /// Bulk-EPG stream. The portal can return hundreds of thousands of records in a single /// `get_epg_info?period=` call, so we stream the HTTP body through a reader-backed /// JSON deserializer and emit one programme record at a time. -pub async fn stream_bulk_epg( - client: &StalkerApiClient, +pub async fn stream_bulk_epg( + client: &StalkerApiClient, handshake: &StalkerHandshake, period_hours: u32, batch_size: usize, @@ -166,17 +169,20 @@ where Fut: std::future::Future>, { let spec = recipe_spec_for(handshake.profile.bootstrap_recipe); - let candidates = client.load_url_candidates().to_vec(); + let candidates = client.ordered_load_urls(); let mut last_err: Option = None; for load_url in candidates { - let mut builder = client.http().get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(&load_url)).query(&[ ("type", "itv"), ("action", "get_epg_info"), ("period", &period_hours.to_string()), ]); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(&handshake.session), spec.token_in_query); - let response = match client.send_with_cap(builder, "get_epg_bulk", client.body_caps().get_epg_bytes).await { + let response = match client + .send_with_cap(builder, StalkerAction::GetBulkEpg, client.cap_for_action(StalkerAction::GetBulkEpg)) + .await + { Ok(r) => r, Err(err) => { last_err = Some(err); @@ -186,7 +192,7 @@ where if !response.status().is_success() { last_err = Some(StalkerError::BadStatus { status: response.status().as_u16(), - action: "get_epg_bulk".to_string(), + action: StalkerAction::GetBulkEpg, body_snippet: String::new(), }); continue; @@ -244,7 +250,7 @@ where .await .map_err(|err| StalkerError::BodyDecode { message: format!("get_epg_bulk parser join error: {err}") })? .map_err(|err| match err.kind() { - ErrorKind::UnexpectedEof => StalkerError::EmptyBody { action: "get_epg_bulk".to_string() }, + ErrorKind::UnexpectedEof => StalkerError::EmptyBody { action: StalkerAction::GetBulkEpg }, _ => StalkerError::BodyDecode { message: format!("get_epg_bulk json decode: {err}") }, })?; cancellation_guard.disarm(); diff --git a/backend/iptv/src/stalker/error.rs b/backend/iptv/src/stalker/error.rs index ae796a0c1..4c5659a66 100644 --- a/backend/iptv/src/stalker/error.rs +++ b/backend/iptv/src/stalker/error.rs @@ -1,18 +1,10 @@ +use crate::{redaction, stalker::action::StalkerAction}; use std::io; use thiserror::Error; -use url::Url; -pub fn safe_stalker_url(value: &str) -> String { - let Ok(mut url) = Url::parse(value) else { - return "[redacted invalid URL]".to_string(); - }; - if !matches!(url.scheme(), "http" | "https") || url.set_username("").is_err() || url.set_password(None).is_err() { - return "[redacted invalid URL]".to_string(); - } - url.set_query(None); - url.set_fragment(None); - url.into() -} +/// Render a portal URL safely. Kept as this module's name for its existing callers; the +/// rule itself lives in [`crate::redaction`] alongside the rest. +pub fn safe_stalker_url(value: &str) -> String { redaction::safe_url(value) } #[derive(Clone, Debug, Eq, PartialEq)] pub struct StalkerErrorUrl(String); @@ -38,10 +30,10 @@ pub enum StalkerError { TokenRejected { status: u16, url: Option }, #[error("stalker portal body reported code {code} for {action}: {body_snippet}")] - PortalBodyError { code: u16, action: String, body_snippet: String }, + PortalBodyError { code: u16, action: StalkerAction, body_snippet: String }, #[error("stalker portal response exceeded {action} body cap of {cap_bytes} bytes")] - ResponseTooLarge { action: String, cap_bytes: u64 }, + ResponseTooLarge { action: StalkerAction, cap_bytes: u64 }, #[error("stalker portal refused the cmd: {reason}")] PortalRefusedCmd { reason: String }, @@ -56,13 +48,16 @@ pub enum StalkerError { HtmlResponse { snippet: String }, #[error("stalker portal returned an empty body for {action}")] - EmptyBody { action: String }, + EmptyBody { action: StalkerAction }, + + #[error("stalker portal does not implement {action}")] + ActionUnsupported { action: StalkerAction }, #[error("stalker {portal_type} catalog is incomplete: {reason}")] CatalogIncomplete { portal_type: &'static str, reason: String }, #[error("stalker portal response status {status} for {action}")] - BadStatus { status: u16, action: String, body_snippet: String }, + BadStatus { status: u16, action: StalkerAction, body_snippet: String }, #[error("stalker client exhausted all bootstrap recipes for portal {portal}")] RecipesExhausted { portal: String }, @@ -109,23 +104,130 @@ impl StalkerError { pub fn is_unsupported_catalog_action(&self) -> bool { match self { - Self::BodyDecode { .. } | Self::HtmlResponse { .. } => true, - Self::EmptyBody { action } => action == "get_all_channels", + Self::ActionUnsupported { .. } | Self::BodyDecode { .. } | Self::HtmlResponse { .. } => true, + Self::EmptyBody { action } => action.is_optional_catalog_shortcut(), Self::BadStatus { status, action, .. } => { - action == "get_all_channels" && matches!(*status, 400 | 404 | 405 | 501) + action.is_optional_catalog_shortcut() && matches!(*status, 400 | 404 | 405 | 501) } - Self::PortalBodyError { action, .. } => action == "get_all_channels" && !self.is_token_rejected(), + Self::PortalBodyError { action, .. } => action.is_optional_catalog_shortcut() && !self.is_token_rejected(), _ => false, } } } +/// What kind of failure this is, independent of which variant carried it. +/// +/// Callers were pattern-matching on variants to answer questions the variants were never +/// organised around — "should I retry this?", "is the provider down or is my token +/// stale?". The sibling `tuliprox-media-server` crate answers the same questions with a +/// `MediaServerErrorKind`; this is its counterpart, and it is what +/// [`StalkerError::is_retryable`] is built on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StalkerErrorKind { + /// The portal rejected our identity: a stale token, a blocked account. + /// Re-handshaking is the fix. + Auth, + /// The portal or the network failed in a way that may not repeat. + Transient, + /// The portal answered, but not in a shape we can use: HTML, bad JSON, a missing + /// field. Retrying the same call gets the same answer. + Protocol, + /// A limit we imposed was hit — a body cap, the configured page limit. + Capacity, + /// The input is misconfigured: no reachable endpoint, an invalid device profile. + Config, +} + +impl StalkerError { + /// Classify the failure. See [`StalkerErrorKind`]. + #[must_use] + pub fn kind(&self) -> StalkerErrorKind { + if self.is_token_rejected() { + return StalkerErrorKind::Auth; + } + match self { + Self::TokenRejected { .. } | Self::HandshakeFailed { .. } | Self::RecipesExhausted { .. } => { + StalkerErrorKind::Auth + } + Self::ResponseTooLarge { .. } | Self::CatalogIncomplete { .. } => StalkerErrorKind::Capacity, + Self::NoEndpoint { .. } | Self::InvalidProfile { .. } | Self::UnsupportedScheme { .. } => { + StalkerErrorKind::Config + } + Self::BodyDecode { .. } + | Self::HtmlResponse { .. } + | Self::EmptyBody { .. } + | Self::PortalRefusedCmd { .. } + | Self::ActionUnsupported { .. } + | Self::PortalBodyError { .. } => StalkerErrorKind::Protocol, + Self::BadStatus { .. } | Self::RequestBuild(_) | Self::Io(_) => StalkerErrorKind::Transient, + } + } + + /// Whether trying the same call again could plausibly succeed. + /// + /// [`StalkerErrorKind::Auth`] is retryable only after a re-handshake, which is a + /// different call; it is reported here as not retryable so a caller does not loop on + /// a rejected token. Use [`Self::is_token_rejected`] for that path. + #[must_use] + pub fn is_retryable(&self) -> bool { matches!(self.kind(), StalkerErrorKind::Transient) } +} + pub type StalkerResult = Result; #[cfg(test)] mod tests { use super::*; + #[test] + fn every_variant_classifies_and_only_transient_failures_are_retried() { + let cases: [(StalkerError, StalkerErrorKind); 8] = [ + (StalkerError::TokenRejected { status: 401, url: None }, StalkerErrorKind::Auth), + ( + StalkerError::PortalBodyError { + code: 44, + action: StalkerAction::GetOrderedList, + body_snippet: String::new(), + }, + StalkerErrorKind::Auth, + ), + (StalkerError::RecipesExhausted { portal: "p".into() }, StalkerErrorKind::Auth), + ( + StalkerError::ResponseTooLarge { action: StalkerAction::GetOrderedList, cap_bytes: 1 }, + StalkerErrorKind::Capacity, + ), + (StalkerError::NoEndpoint { portal: "p".into() }, StalkerErrorKind::Config), + (StalkerError::HtmlResponse { snippet: "".into() }, StalkerErrorKind::Protocol), + ( + StalkerError::BadStatus { + status: 502, + action: StalkerAction::GetOrderedList, + body_snippet: String::new(), + }, + StalkerErrorKind::Transient, + ), + (StalkerError::Io(std::io::Error::other("reset")), StalkerErrorKind::Transient), + ]; + + for (error, expected) in cases { + assert_eq!(error.kind(), expected, "{error}"); + assert_eq!( + error.is_retryable(), + expected == StalkerErrorKind::Transient, + "only a transient failure is worth repeating verbatim: {error}" + ); + } + } + + /// A 401 arriving as `BadStatus` rather than `TokenRejected` must still classify as + /// auth - the two variants describe the same portal answer. + #[test] + fn a_rejected_token_classifies_as_auth_whichever_variant_carries_it() { + let as_status = + StalkerError::BadStatus { status: 403, action: StalkerAction::GetOrderedList, body_snippet: String::new() }; + assert_eq!(as_status.kind(), StalkerErrorKind::Auth); + assert!(!as_status.is_retryable(), "looping on a rejected token is never right"); + } + #[test] fn no_content_token_rejection_is_recognized() { assert!(StalkerError::TokenRejected { status: 204, url: None }.is_token_rejected()); diff --git a/backend/iptv/src/stalker/mod.rs b/backend/iptv/src/stalker/mod.rs index 0252599a4..9746505e6 100644 --- a/backend/iptv/src/stalker/mod.rs +++ b/backend/iptv/src/stalker/mod.rs @@ -19,22 +19,26 @@ //! Tests in this module exercise the parsing and URL-derivation helpers directly with //! canned payloads; no HTTP requests are issued from unit tests. +pub mod action; pub mod auth; pub mod catalog; pub mod cmd_parser; pub mod cookie_jar; pub mod epg; pub mod error; +pub mod pagination; pub mod parser; pub mod playback; pub mod presets; pub mod profile; pub mod recipes; pub mod session; +pub mod transport; pub mod url_factory; pub mod client; +pub use action::StalkerAction; pub use client::StalkerApiClient; pub use error::{StalkerError, StalkerResult}; pub use profile::{StalkerHandshake, StalkerProviderProfile, StalkerRawProviderProfile, StalkerResolvedStream}; diff --git a/backend/iptv/src/stalker/pagination.rs b/backend/iptv/src/stalker/pagination.rs new file mode 100644 index 000000000..a5d8d8003 --- /dev/null +++ b/backend/iptv/src/stalker/pagination.rs @@ -0,0 +1,233 @@ +//! Page arithmetic shared by every Stalker catalog fetch. +//! +//! A Stalker portal advertises three independent, individually-optional hints next to a +//! page of rows — `total_items`, `max_page_items` (the page *size*) and `max_page` (the +//! page *count*) — and a page is the last one when any of them says so, or when it came +//! back short. That rule used to be recomputed in four places: the accumulating loop in +//! [`super::catalog`], the two `parse_*_catalog_page` helpers, and the `apply_page_limit` +//! guard. This module is the single copy. + +use crate::stalker::error::StalkerResult; +use serde_json::Value; + +/// The pagination hints a portal advertises alongside a page of rows. Every field is +/// optional because portals disagree about which ones they emit — the terminal test +/// simply ignores whichever are absent. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PageMeta { + /// Total row count across all pages. + pub total_items: Option, + /// Rows per page. Named for the portal's field; it is a page *size*, not a count. + pub max_page_items: Option, + /// Total page count. Strictly distinct from `max_page_items`. + pub max_page: Option, +} + +/// One page of catalog rows plus the cursor needed to ask for the next one. +#[derive(Debug)] +pub struct CatalogPage { + pub items: Vec, + /// `None` once [`PageMeta::is_terminal`] says the catalog is exhausted. + pub next_page: Option, + pub total: Option, +} + +impl PageMeta { + /// Extract the hints from a catalog response body, unwrapping the `js` envelope. + #[must_use] + pub fn from_value(value: &Value) -> Self { + let Some(object) = catalog_js(value).as_object() else { + return Self::default(); + }; + let field = |key: &str| object.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()); + Self { total_items: field("total_items"), max_page_items: field("max_page_items"), max_page: field("max_page") } + } + + /// True when `current_page` is the last page worth asking for. + /// + /// `page_len` is the number of rows this page yielded and `fetched_so_far` the running + /// total including them. Callers that hold only one page pass the best estimate they + /// have (`current_page * max_page_items`); callers that accumulate pass the real count. + #[must_use] + pub fn is_terminal(&self, page_len: usize, current_page: u32, fetched_so_far: usize) -> bool { + // An empty page is always the end: portals stop emitting rows rather than + // reporting the last page number reliably. + if page_len == 0 { + return true; + } + if self.total_items.is_some_and(|total| fetched_so_far >= as_usize(total)) { + return true; + } + // A short page — fewer rows than the advertised page size — is the last page. + if self.max_page_items.is_some_and(|size| page_len < as_usize(size)) { + return true; + } + self.max_page.is_some_and(|last| current_page >= last) + } + + /// The row count a caller holding only `current_page` can claim to have fetched. + /// Zero when the portal did not advertise a page size, which makes the `total_items` + /// arm of [`Self::is_terminal`] inert rather than guessing. + #[must_use] + pub fn fetched_estimate(&self, current_page: u32) -> usize { + self.max_page_items.map_or(0, |size| as_usize(current_page.saturating_mul(size))) + } + + /// Build the page cursor for a response, applying [`Self::is_terminal`]. + #[must_use] + pub fn into_page(self, items: Vec, current_page: u32) -> CatalogPage { + let terminal = self.is_terminal(items.len(), current_page, self.fetched_estimate(current_page)); + let next_page = (!terminal).then(|| current_page.checked_add(1)).flatten(); + CatalogPage { items, next_page, total: self.total_items } + } +} + +fn as_usize(value: u32) -> usize { usize::try_from(value).unwrap_or(usize::MAX) } + +/// Unwrap the `js` envelope Stalker portals wrap every response in, falling back to the +/// document itself for the portals that do not. +pub fn catalog_js(value: &Value) -> &Value { value.as_object().and_then(|map| map.get("js")).unwrap_or(value) } + +/// Where a catalog fetch delivers its rows. +/// +/// The Stalker client tries several endpoint candidates in turn, and a failure part-way +/// through pagination means abandoning that candidate and starting over on the next one. +/// That retry is only sound if nothing has left the client yet, which is exactly what +/// separates the two sinks: [`CollectSink`] holds everything until the catalog is complete +/// and can therefore restart freely, while a streaming sink that has already handed rows +/// to its caller cannot, and says so through [`CatalogSink::can_restart`]. +pub trait CatalogSink { + /// Take one page of rows. Returning `Err` aborts the whole fetch. + fn accept(&mut self, batch: Vec) -> impl std::future::Future> + Send; + + /// Whether the driver may abandon a half-fetched candidate and retry the next one. + fn can_restart(&self) -> bool; + + /// Discard whatever the abandoned candidate produced. Only called when + /// [`Self::can_restart`] returned `true`. + fn restart(&mut self); +} + +/// Accumulates the whole catalog in memory. This is the historical behaviour, and the one +/// that keeps the retry-from-scratch guarantee: a truncated catalog is never returned as +/// `Ok`, because nothing is visible to the caller until the fetch has completed. +#[derive(Debug, Default)] +pub struct CollectSink { + rows: Vec, +} + +impl CollectSink { + #[must_use] + pub fn new() -> Self { Self { rows: Vec::new() } } + + #[must_use] + pub fn into_rows(self) -> Vec { self.rows } +} + +impl CatalogSink for CollectSink { + fn accept(&mut self, mut batch: Vec) -> impl std::future::Future> + Send { + self.rows.append(&mut batch); + async { Ok(()) } + } + + fn can_restart(&self) -> bool { true } + + fn restart(&mut self) { self.rows.clear(); } +} + +/// Forwards each page to a caller-supplied callback as it arrives, so a large catalog +/// never has to be resident all at once. +/// +/// The trade is stated in [`CatalogSink`]: once a page has been handed over, the fetch can +/// no longer fall back to another endpoint candidate. An `Err` from a streaming fetch +/// therefore means the batches already delivered are an incomplete prefix and must be +/// discarded by the caller. +pub struct BatchSink { + on_batch: F, + emitted_any: bool, +} + +impl BatchSink { + pub fn new(on_batch: F) -> Self { Self { on_batch, emitted_any: false } } + + /// Whether any page reached the callback. Useful to a caller deciding whether an empty + /// catalog was genuinely empty. + #[must_use] + pub fn emitted_any(&self) -> bool { self.emitted_any } +} + +impl CatalogSink for BatchSink +where + T: Send, + F: FnMut(Vec) -> Fut + Send, + Fut: std::future::Future> + Send, +{ + fn accept(&mut self, batch: Vec) -> impl std::future::Future> + Send { + self.emitted_any = true; + (self.on_batch)(batch) + } + + fn can_restart(&self) -> bool { !self.emitted_any } + + fn restart(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::{catalog_js, PageMeta}; + + #[test] + fn empty_page_terminates_regardless_of_hints() { + let meta = PageMeta { total_items: Some(1000), max_page_items: Some(50), max_page: Some(20) }; + assert!(meta.is_terminal(0, 1, 0)); + } + + #[test] + fn short_page_terminates() { + let meta = PageMeta { total_items: None, max_page_items: Some(50), max_page: None }; + assert!(meta.is_terminal(17, 3, 117)); + assert!(!meta.is_terminal(50, 3, 150)); + } + + #[test] + fn accumulated_total_terminates() { + let meta = PageMeta { total_items: Some(100), max_page_items: Some(50), max_page: None }; + assert!(meta.is_terminal(50, 2, 100)); + assert!(!meta.is_terminal(50, 1, 50)); + } + + #[test] + fn max_page_is_a_page_count_not_a_page_size() { + let meta = PageMeta { total_items: None, max_page_items: Some(50), max_page: Some(3) }; + assert!(meta.is_terminal(50, 3, 150)); + assert!(!meta.is_terminal(50, 2, 100)); + } + + #[test] + fn missing_page_size_leaves_the_total_arm_inert() { + // Without `max_page_items` a single-page caller cannot estimate progress, so the + // `total_items` arm must not fire and truncate the catalog at page one. + let meta = PageMeta { total_items: Some(100), max_page_items: None, max_page: None }; + assert_eq!(meta.fetched_estimate(1), 0); + assert!(!meta.is_terminal(30, 1, meta.fetched_estimate(1))); + } + + #[test] + fn into_page_advances_until_terminal() { + let meta = PageMeta { total_items: Some(100), max_page_items: Some(50), max_page: None }; + assert_eq!(meta.into_page(vec![0_u8; 50], 1).next_page, Some(2)); + assert_eq!(meta.into_page(vec![0_u8; 50], 2).next_page, None); + } + + #[test] + fn meta_is_read_from_the_js_envelope_or_the_root() { + let wrapped = serde_json::json!({"js": {"total_items": 7, "max_page_items": 3, "max_page": 3}}); + assert_eq!( + PageMeta::from_value(&wrapped), + PageMeta { total_items: Some(7), max_page_items: Some(3), max_page: Some(3) } + ); + let bare = serde_json::json!({"total_items": 7}); + assert_eq!(PageMeta::from_value(&bare).total_items, Some(7)); + assert!(catalog_js(&bare).is_object()); + } +} diff --git a/backend/iptv/src/stalker/playback.rs b/backend/iptv/src/stalker/playback.rs index a404ba254..e03d05168 100644 --- a/backend/iptv/src/stalker/playback.rs +++ b/backend/iptv/src/stalker/playback.rs @@ -1,14 +1,17 @@ use crate::stalker::{ + action::StalkerAction, client::{validate_playable_scheme, StalkerApiClient}, cmd_parser::scheme_is_playable, error::{safe_stalker_url, StalkerError, StalkerResult}, profile::{StalkerHandshake, StalkerResolvedStream}, recipes::recipe_spec_for, + transport::StalkerTransport, }; use log::warn; use serde::{Deserialize, Serialize}; use serde_json::Value; use shared::model::stalker::{StalkerPlaybackMode, StalkerStreamKind}; +use tuliprox_core::utils::Clock; use url::Url; #[derive(Debug, Default, Clone, Deserialize, Serialize)] @@ -33,8 +36,8 @@ struct StalkerCreateLinkResponse { /// downstream layers (reverse-proxy, headers) can adapt to nginx-secure / flussonic / /// wowza without re-deriving it. #[allow(clippy::too_many_arguments)] -pub async fn create_link( - client: &StalkerApiClient, +pub async fn create_link( + client: &StalkerApiClient, handshake: &StalkerHandshake, kind: StalkerStreamKind, requested_mode: StalkerPlaybackMode, @@ -44,7 +47,7 @@ pub async fn create_link( archive_end: Option<&str>, ) -> StalkerResult { let spec = recipe_spec_for(handshake.profile.bootstrap_recipe); - let candidates = client.load_url_candidates().to_vec(); + let candidates = client.ordered_load_urls(); let mut last_err: Option = None; for load_url in candidates { let builder = build_create_link_builder( @@ -58,7 +61,7 @@ pub async fn create_link( archive_start, archive_end, ); - match client.send_json::(builder, "create_link").await { + match client.send_json::(builder, StalkerAction::CreateLink).await { Ok(resp) => { return resolve_response(resp, kind, requested_mode, cmd); } @@ -71,8 +74,8 @@ pub async fn create_link( } #[allow(clippy::too_many_arguments)] -fn build_create_link_builder( - client: &StalkerApiClient, +fn build_create_link_builder( + client: &StalkerApiClient, load_url: &crate::stalker::url_factory::StalkerLoadUrl, handshake: &StalkerHandshake, spec: &crate::stalker::recipes::StalkerRecipeSpec, @@ -111,8 +114,7 @@ fn build_create_link_builder( query_pairs.push(("end".to_string(), end.to_string())); } } - let mut builder = - client.http().get(&load_url.load_url).headers(client.common_headers(load_url)).query(&query_pairs); + let mut builder = client.get(&load_url.load_url).headers(client.common_headers(load_url)).query(&query_pairs); builder = client.apply_mac_query(builder); builder = client.apply_bearer(builder, Some(&handshake.session), spec.token_in_query); builder diff --git a/backend/iptv/src/stalker/session.rs b/backend/iptv/src/stalker/session.rs index 66eb81fa3..a3822f054 100644 --- a/backend/iptv/src/stalker/session.rs +++ b/backend/iptv/src/stalker/session.rs @@ -1,8 +1,6 @@ use super::error::safe_stalker_url; -use std::{ - fmt, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; +use crate::clock::system_epoch_secs; +use std::{fmt, time::Duration}; /// A successful Stalker handshake. The token is sent as `Authorization: Bearer ` on /// every subsequent API call; portal cookies live in the client's shared cookie jar; the @@ -35,8 +33,15 @@ impl fmt::Debug for StalkerSession { } impl StalkerSession { + /// Stamp the session with the system clock. Callers that already hold an instant + /// should use [`Self::new_at`] instead. pub fn new(token: String, referer: String, load_url: String) -> Self { - Self { token, referer, load_url, fingerprint_evidence: Vec::new(), created_at_epoch_secs: now_epoch_secs() } + Self::new_at(token, referer, load_url, system_epoch_secs()) + } + + /// Stamp the session with a caller-supplied instant. + pub fn new_at(token: String, referer: String, load_url: String, now_epoch_secs: u64) -> Self { + Self { token, referer, load_url, fingerprint_evidence: Vec::new(), created_at_epoch_secs: now_epoch_secs } } pub fn with_evidence(mut self, evidence: Vec) -> Self { @@ -47,14 +52,14 @@ impl StalkerSession { /// True when the cached session is older than `STALKER_SESSION_TTL`. The portal /// may invalidate tokens earlier; this is a soft hint to re-handshake, not a /// hard expiry. - pub fn is_stale(&self, ttl: Duration) -> bool { - let now = now_epoch_secs(); - now.saturating_sub(self.created_at_epoch_secs) >= ttl.as_secs() + pub fn is_stale(&self, ttl: Duration) -> bool { self.is_stale_at(system_epoch_secs(), ttl) } + + /// [`Self::is_stale`] against a caller-supplied instant. + pub fn is_stale_at(&self, now_epoch_secs: u64, ttl: Duration) -> bool { + now_epoch_secs.saturating_sub(self.created_at_epoch_secs) >= ttl.as_secs() } } -fn now_epoch_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs()) } - /// How long a `StalkerSession` should be considered fresh. The portal invalidates tokens /// aggressively (typically after 5–30 minutes of inactivity); the client treats a session /// older than this as a hint to re-handshake before issuing any new calls. @@ -109,16 +114,17 @@ mod tests { } #[test] - fn fresh_session_is_not_stale() { - let session = StalkerSession::new("t".into(), "r".into(), "l".into()); - assert!(!session.is_stale(STALKER_SESSION_TTL)); + fn staleness_is_measured_against_the_supplied_instant() { + // No back-dating of the struct and no sleeping: the instant is a parameter. + let session = StalkerSession::new_at("t".into(), "r".into(), "l".into(), 10_000); + assert!(!session.is_stale_at(10_000, STALKER_SESSION_TTL)); + assert!(!session.is_stale_at(10_000 + STALKER_SESSION_TTL.as_secs() - 1, STALKER_SESSION_TTL)); + assert!(session.is_stale_at(10_000 + STALKER_SESSION_TTL.as_secs(), STALKER_SESSION_TTL)); } #[test] - fn stale_session_is_detected() { - let mut session = StalkerSession::new("t".into(), "r".into(), "l".into()); - // Pretend the session is one hour old. - session.created_at_epoch_secs = now_epoch_secs().saturating_sub(3600); - assert!(session.is_stale(STALKER_SESSION_TTL)); + fn a_clock_that_runs_backwards_does_not_report_a_stale_session() { + let session = StalkerSession::new_at("t".into(), "r".into(), "l".into(), 10_000); + assert!(!session.is_stale_at(1, STALKER_SESSION_TTL)); } } diff --git a/backend/iptv/src/stalker/transport.rs b/backend/iptv/src/stalker/transport.rs new file mode 100644 index 000000000..164a83948 --- /dev/null +++ b/backend/iptv/src/stalker/transport.rs @@ -0,0 +1,147 @@ +//! Where a Stalker request actually goes. +//! +//! [`StalkerApiClient`](super::client::StalkerApiClient) used to own a `reqwest::Client` +//! outright, which put every interesting decision it makes — the recipe fallback chain, +//! pagination termination, the 401-triggered re-handshake, the portal's habit of reporting +//! `{"code": 44}` inside a `200 OK` — behind a live network connection. The module docs +//! conceded as much: *no HTTP requests are issued from unit tests*. +//! +//! Splitting *send this* out from *decide what to send* is all it takes to reach that +//! logic. Requests are still built with `reqwest`'s builder, because the fake needs to +//! build them too and there is nothing to gain from re-modelling a query string; only the +//! send is abstracted. +//! +//! The trait is held as a generic parameter defaulted to [`ReqwestTransport`], so +//! production construction sites are unchanged and there is no vtable on the hot path. + +use crate::stalker::error::{StalkerError, StalkerResult}; +use reqwest::{Client, Request, RequestBuilder, Response}; +use std::future::Future; + +pub trait StalkerTransport: Send + Sync + 'static { + /// Start a request. Stalker portals answer `GET` for every action, including the ones + /// that mutate session state. + fn get(&self, url: &str) -> RequestBuilder; + + /// Send a built request. + /// + /// Errors are already domain errors rather than `reqwest::Error`, both because callers + /// only ever converted them anyway and because `reqwest::Error` cannot be constructed + /// outside `reqwest` — a fake could not report a transport failure otherwise. + fn execute(&self, request: Request) -> impl Future> + Send; +} + +/// The production transport: a real `reqwest::Client`, so connection pooling, timeouts and +/// proxy settings stay under the caller's control exactly as before. +#[derive(Debug, Clone)] +pub struct ReqwestTransport { + client: Client, +} + +impl ReqwestTransport { + #[must_use] + pub fn new(client: Client) -> Self { Self { client } } + + #[must_use] + pub fn client(&self) -> &Client { &self.client } +} + +impl StalkerTransport for ReqwestTransport { + fn get(&self, url: &str) -> RequestBuilder { self.client.get(url) } + + fn execute(&self, request: Request) -> impl Future> + Send { + let client = self.client.clone(); + async move { client.execute(request).await.map_err(StalkerError::from) } + } +} + +/// Sharing one transport across several clients — several inputs against the same portal +/// host, say — is just `Arc`. +impl StalkerTransport for std::sync::Arc { + fn get(&self, url: &str) -> RequestBuilder { (**self).get(url) } + + fn execute(&self, request: Request) -> impl Future> + Send { + (**self).execute(request) + } +} + +/// Test doubles for the transport seam. +/// +/// Kept in the crate rather than a test file because the logic worth reaching from a fake +/// — recipe fallback, pagination, the 4xx re-handshake — lives in sibling modules. +#[cfg(test)] +pub mod testing { + use super::{StalkerResult, StalkerTransport}; + use crate::stalker::error::StalkerError; + use parking_lot::Mutex; + use reqwest::{Client, Request, RequestBuilder, Response}; + use std::{collections::VecDeque, future::Future}; + + /// What the fake portal answers with, one per request, in order. + pub enum Reply { + /// An HTTP response with this status and body. + Http(u16, String), + /// A transport-level failure — a connection reset, a timeout. + Transport(StalkerError), + } + + impl Reply { + /// `200 OK` carrying `body`. + pub fn ok(body: &str) -> Self { Self::Http(200, body.to_string()) } + } + + /// A portal that answers from a script instead of the network, and records what it + /// was asked for. + pub struct FakeTransport { + /// Only ever used to *build* requests; it never sends one. + client: Client, + replies: Mutex>, + requested: Mutex>, + } + + impl FakeTransport { + pub fn new(replies: impl IntoIterator) -> Self { + Self { + client: Client::new(), + replies: Mutex::new(replies.into_iter().collect()), + requested: Mutex::new(Vec::new()), + } + } + + /// Every URL the client asked for, in order, including query strings. + pub fn requested(&self) -> Vec { self.requested.lock().clone() } + + /// The paths asked for, without scheme, host or query — enough to assert which + /// endpoint candidate was tried without pinning the whole URL. + pub fn requested_paths(&self) -> Vec { + self.requested + .lock() + .iter() + .filter_map(|url| url::Url::parse(url).ok()) + .map(|url| url.path().to_string()) + .collect() + } + } + + impl StalkerTransport for FakeTransport { + fn get(&self, url: &str) -> RequestBuilder { self.client.get(url) } + + fn execute(&self, request: Request) -> impl Future> + Send { + self.requested.lock().push(request.url().to_string()); + let reply = self.replies.lock().pop_front(); + async move { + match reply { + Some(Reply::Http(status, body)) => { + let response = http::Response::builder() + .status(status) + .body(body) + .map_err(|err| StalkerError::BodyDecode { message: err.to_string() })?; + Ok(Response::from(response)) + } + Some(Reply::Transport(err)) => Err(err), + None => Err(StalkerError::Io(std::io::Error::other("fake transport ran out of replies"))), + } + } + } + } +} diff --git a/backend/iptv/src/xtream/mod.rs b/backend/iptv/src/xtream/mod.rs index 920d30d4f..f6f9efe60 100644 --- a/backend/iptv/src/xtream/mod.rs +++ b/backend/iptv/src/xtream/mod.rs @@ -4,9 +4,9 @@ use shared::{ concat_string, error::TuliproxError, model::{ - InputType, PlaylistEntry, PlaylistGroup, ProxyUserStatus, SeriesStreamProperties, StreamProperties, - VideoStreamProperties, XtreamCluster, XtreamLoginInfo, XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo, - XtreamVideoInfoDoc, + EventMessage, EventSink, InputType, PlaylistEntry, PlaylistGroup, ProviderAccountEvent, ProviderAccountState, + ProxyUserStatus, SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, + XtreamLoginInfo, XtreamPlaylistItem, XtreamSeriesInfo, XtreamVideoInfo, XtreamVideoInfoDoc, }, utils::{ extract_extension_from_url, get_i64_from_serde_value, get_string_from_serde_value, sanitize_sensitive_info, @@ -17,11 +17,10 @@ use std::{collections::HashMap, io::Error, str::FromStr, sync::Arc}; use tuliprox_core::{ model::{ is_input_expired, xtream_mapping_option_from_target_options, AppConfig, ConfigInput, ConfigInputFlags, - ConfigTarget, InputSource, MessageContent, ProxyUserCredentials, XtreamTargetOutput, + ConfigTarget, InputSource, ProxyUserCredentials, XtreamTargetOutput, }, utils::request, }; -use tuliprox_messaging::send_message; use tuliprox_parser::{xtream, xtream::parse_xtream_series_info}; use tuliprox_repository::{ get_input_storage_path, get_target_id_mapping, get_target_storage_path, persist_input_vod_info, @@ -231,7 +230,7 @@ pub async fn get_xtream_stream_info( provider_series.entry(pli.get_uuid().intern()).or_default().push( ProviderEpisodeKey { provider_id: episode_provider_id, - virtual_id: episode.header.virtual_id, + virtual_id: episode.header.virtual_id.get(), }, ); if target.use_memory_cache { @@ -374,9 +373,10 @@ const ACTIONS: [(XtreamCluster, &str, &str); 3] = [ ), ]; -pub async fn xtream_login( +pub async fn xtream_login( app_config: &Arc, client: &reqwest::Client, + events: &E, input: &InputSource, username: &str, ) -> Result, TuliproxError> { @@ -403,12 +403,15 @@ pub async fn xtream_login( login_info.status = Some(cur_status); if !matches!(cur_status, ProxyUserStatus::Active | ProxyUserStatus::Trial) { warn!("User status for user {username} is {cur_status:?}"); - send_message( - app_config, - client, - MessageContent::Error(format!("User status for user {username} is {cur_status:?}")), - ) - .await; + let text = format!("User status for user {username} is {cur_status:?}"); + events.emit(EventMessage::ProviderAccount(ProviderAccountEvent { + state: ProviderAccountState::StatusChanged, + username: username.to_string(), + provider: input.name.to_string(), + status: Some(format!("{cur_status:?}")), + expires_at: None, + message: text, + })); } } } @@ -417,7 +420,7 @@ pub async fn xtream_login( if let Some(exp_value) = user_info.get("exp_date") { if let Some(expiration_timestamp) = get_i64_from_serde_value(exp_value) { login_info.exp_date = Some(expiration_timestamp); - notify_account_expire(login_info.exp_date, app_config, client, username, &input.name).await; + notify_account_expire(login_info.exp_date, events, username, &input.name); } } } @@ -429,15 +432,27 @@ pub async fn xtream_login( } } -pub async fn notify_account_expire( +/// Publish the account-expiry state for `username` on `input_name`. +/// +/// Emitting is synchronous and non-blocking, so this no longer awaits: the +/// notification is delivered by whoever subscribes to the bus. +pub fn notify_account_expire(exp_date: Option, events: &E, username: &str, input_name: &str) { + notify_account_expire_at(Utc::now().timestamp(), exp_date, events, username, input_name); +} + +/// [`notify_account_expire`] against a caller-supplied instant. +/// +/// The three-day warning window and the expired/expiring split are pure functions of +/// `now_secs`; taking it as a parameter is what makes either branch reachable without +/// waiting for the calendar. +pub fn notify_account_expire_at( + now_secs: i64, exp_date: Option, - app_config: &Arc, - client: &reqwest::Client, + events: &E, username: &str, input_name: &str, ) { if let Some(expiration_timestamp) = exp_date { - let now_secs = Utc::now().timestamp(); // UTC-Time if expiration_timestamp > now_secs { let time_left = expiration_timestamp - now_secs; @@ -445,22 +460,32 @@ pub async fn notify_account_expire( if let Some(datetime) = DateTime::::from_timestamp(expiration_timestamp, 0) { let formatted = datetime.format("%Y-%m-%d %H:%M:%S").to_string(); warn!("User account for user {username} expires {formatted}"); - send_message( - app_config, - client, - MessageContent::Info(format!("User account for user {username} expires {formatted}")), - ) - .await; + let text = format!("User account for user {username} expires {formatted}"); + // The suppression key lives on `ProviderAccountEvent`; + // re-checked on every refresh, this would otherwise + // notify on each playlist update for the three days + // before expiry. + events.emit(EventMessage::ProviderAccount(ProviderAccountEvent { + state: ProviderAccountState::Expiring, + username: username.to_string(), + provider: input_name.to_string(), + status: None, + expires_at: Some(expiration_timestamp), + message: text, + })); } } } else { warn!("User account for user {username} is expired"); - send_message( - app_config, - client, - MessageContent::Info(format!("User account for user {username} for provider {input_name} is expired")), - ) - .await; + let text = format!("User account for user {username} for provider {input_name} is expired"); + events.emit(EventMessage::ProviderAccount(ProviderAccountEvent { + state: ProviderAccountState::Expired, + username: username.to_string(), + provider: input_name.to_string(), + status: None, + expires_at: Some(expiration_timestamp), + message: text, + })); } } } @@ -479,9 +504,10 @@ pub fn requested_clusters(requested: Option<&[XtreamCluster]>, skip_cluster: &[X } /// Downloads xtream clusters from a single source (either main input or staged input). -async fn download_xtream_from_source( +async fn download_xtream_from_source( app_config: &Arc, client: &reqwest::Client, + events: &E, input: &ConfigInput, input_source: &InputSource, source_input_type: InputType, @@ -503,7 +529,7 @@ async fn download_xtream_from_source( let base_url = get_xtream_stream_url_base(&base_input_url, username, password); let input_source_login = input_source.with_url(base_url.clone()); - if let Err(err) = xtream_login(app_config, client, &input_source_login, username).await { + if let Err(err) = xtream_login(app_config, client, events, &input_source_login, username).await { error!("Could not log in with xtream user {username} for provider {}. {err}", input.name); return (Vec::new(), vec![err], false); } @@ -577,9 +603,10 @@ async fn download_xtream_from_source( (playlist_groups, errors, use_disk_based_processing) } -pub async fn download_xtream_playlist( +pub async fn download_xtream_playlist( app_config: &Arc, client: &reqwest::Client, + events: &E, input: &ConfigInput, clusters: Option<&[XtreamCluster]>, ) -> (Vec, Vec, bool) { @@ -591,10 +618,11 @@ pub async fn download_xtream_playlist( let mut any_disk = false; if !main_clusters.is_empty() { - check_alias_user_state(app_config, client, input).await; + check_alias_user_state(events, input); let source: InputSource = input.into(); let (g, e, d) = - download_xtream_from_source(app_config, client, input, &source, input.input_type, &main_clusters).await; + download_xtream_from_source(app_config, client, events, input, &source, input.input_type, &main_clusters) + .await; all_groups.extend(g); all_errors.extend(e); any_disk |= d; @@ -607,18 +635,16 @@ pub async fn download_xtream_playlist( (all_groups, all_errors, any_disk) } -async fn check_alias_user_state(app_config: &Arc, client: &reqwest::Client, input: &ConfigInput) { +fn check_alias_user_state(events: &E, input: &ConfigInput) { if let Some(aliases) = input.aliases.as_ref() { for alias in aliases { if is_input_expired(alias.exp_date) { notify_account_expire( alias.exp_date, - app_config, - client, + events, alias.username.as_ref().map_or("", |s| s.as_str()), &alias.name, - ) - .await; + ); } } } @@ -671,12 +697,13 @@ pub fn create_vod_info_from_item(pli: &XtreamPlaylistItem) -> String { .get_container_extension() .filter(|ce| !ce.is_empty()) .map(|s| s.to_string()) - .or_else(|| extract_extension_from_url(&pli.url).map(ToString::to_string)) + // `extract_extension_from_url` keeps the leading dot; `container_extension` must not. + .or_else(|| extract_extension_from_url(&pli.url).map(|ext| ext.trim_start_matches('.').to_string())) .unwrap_or_default(); let mut doc = XtreamVideoInfoDoc::default(); doc.info.name.clone_from(name); - doc.movie_data.stream_id = pli.virtual_id; + doc.movie_data.stream_id = pli.virtual_id.get(); doc.movie_data.name.clone_from(name); doc.movie_data.added = added.intern(); doc.movie_data.category_id = category_id.intern(); @@ -699,6 +726,56 @@ mod tests { ConfigInput, ConfigInputFlags, ConfigInputFlagsSet, ConfigInputOptions, ProxyUserCredentials, }; + /// Records what reached the bus, so the expiry branches can be asserted rather than + /// inferred from a log line. + #[derive(Default)] + struct RecordingSink(std::sync::Mutex>); + + impl shared::model::EventSink for RecordingSink { + fn emit(&self, event: shared::model::EventMessage) { + if let shared::model::EventMessage::ProviderAccount(event) = event { + self.0.lock().expect("sink poisoned").push(event); + } + } + } + + impl RecordingSink { + fn states(&self) -> Vec { + self.0.lock().expect("sink poisoned").iter().map(|event| event.state).collect() + } + } + + const EXPIRY: i64 = 1_700_000_000; + + #[test] + fn an_account_past_its_expiry_is_reported_expired() { + let sink = RecordingSink::default(); + super::notify_account_expire_at(EXPIRY + 1, Some(EXPIRY), &sink, "user", "provider"); + assert_eq!(sink.states(), vec![shared::model::ProviderAccountState::Expired]); + } + + #[test] + fn an_account_inside_the_three_day_window_is_reported_expiring() { + let sink = RecordingSink::default(); + super::notify_account_expire_at(EXPIRY - 60, Some(EXPIRY), &sink, "user", "provider"); + assert_eq!(sink.states(), vec![shared::model::ProviderAccountState::Expiring]); + } + + #[test] + fn an_account_outside_the_three_day_window_is_quiet() { + let sink = RecordingSink::default(); + // One second before the window opens. + super::notify_account_expire_at(EXPIRY - super::THREE_DAYS_IN_SECS - 1, Some(EXPIRY), &sink, "u", "p"); + assert!(sink.states().is_empty()); + } + + #[test] + fn no_expiry_date_emits_nothing() { + let sink = RecordingSink::default(); + super::notify_account_expire_at(EXPIRY, None, &sink, "user", "provider"); + assert!(sink.states().is_empty()); + } + fn options_with_flags(flags: &[ConfigInputFlags]) -> ConfigInputOptions { let mut set = ConfigInputFlagsSet::new(); for flag in flags { @@ -738,7 +815,7 @@ mod tests { fn test_vod_item() -> XtreamPlaylistItem { XtreamPlaylistItem { - virtual_id: 176_141, + virtual_id: shared::model::VirtualId::new(176_141), provider_id: 813_563, name: "Movie".intern(), logo: "".intern(), diff --git a/backend/library/src/library/processor.rs b/backend/library/src/library/processor.rs index 41a71c276..2b7644456 100644 --- a/backend/library/src/library/processor.rs +++ b/backend/library/src/library/processor.rs @@ -757,7 +757,7 @@ impl LibraryProcessor { url: file_path, user_agent: None, analyze_duration: ffprobe.analyze_duration_micros, - probe_size: ffprobe.probe_size_bytes, + probe_size: ffprobe.probe_size_bytes.get(), timeout_secs: ffprobe.timeout.unwrap_or(60), }, // Local file probing does not traverse the network, so no proxy config is applied. diff --git a/backend/media-server/src/playlist_mapper.rs b/backend/media-server/src/playlist_mapper.rs index 50b1f107d..e0ec7caf6 100644 --- a/backend/media-server/src/playlist_mapper.rs +++ b/backend/media-server/src/playlist_mapper.rs @@ -658,7 +658,7 @@ mod tests { assert_eq!(groups.len(), 2); assert_eq!(groups[0].xtream_cluster, XtreamCluster::Video); assert_eq!(groups[0].channels[0].header.item_type, PlaylistItemType::Video); - assert_eq!(groups[0].channels[0].header.virtual_id, 0); + assert_eq!(groups[0].channels[0].header.virtual_id, shared::model::VirtualId::default()); assert!(groups[0].channels[0] .header .id diff --git a/backend/messaging/Cargo.toml b/backend/messaging/Cargo.toml index 5a7a82478..c32dd6fba 100644 --- a/backend/messaging/Cargo.toml +++ b/backend/messaging/Cargo.toml @@ -13,14 +13,18 @@ shared = { workspace = true } tuliprox-core = { workspace = true } arc-swap.workspace = true chrono.workspace = true +futures.workspace = true handlebars = "6.4.4" +hmac.workspace = true +sha2.workspace = true log.workspace = true mime = "0.3.17" reqwest = { version = "0.13.4", features = ["json", "stream", "rustls", "socks", "form", "query"] } serde.workspace = true url.workspace = true serde_json = { workspace = true, features = ["raw_value", "preserve_order"] } -tokio = { workspace = true, features = ["rt-multi-thread", "time", "macros"] } +tokio = { workspace = true, features = ["rt-multi-thread", "time", "macros", "process", "io-util"] } +tokio-util = { version = "0.7.19" } [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } diff --git a/backend/messaging/src/channel.rs b/backend/messaging/src/channel.rs new file mode 100644 index 000000000..5bb80cc5d --- /dev/null +++ b/backend/messaging/src/channel.rs @@ -0,0 +1,240 @@ +//! The outbound channel abstraction. +//! +//! Adding a channel used to mean editing ten sites across three crates: a +//! `MessagingChannel` variant, the `is_some()` chain in +//! `configured_channels`, the match in `send_message_to_channel`, a new +//! `send_*_message` function, the hardcoded `tokio::join!` in +//! `dispatch_send_message`, a config field, a DTO field, both `From` impls, +//! `MessagingConfig::prepare`, and the template-discovery prefix. +//! +//! Now it is one [`NotificationChannel`] impl plus one config field. The +//! dispatcher never learns the channel's name. +//! +//! Dispatch is static. [`NotificationChannel`] returns an opaque future +//! from `send` rather than a boxed one, which makes the trait deliberately +//! *not* object safe: there is no vtable and no allocation on the send +//! path. [`Channel`](crate::channels::Channel) enumerates the +//! implementations and every call monomorphizes. + +use shared::model::notification::{EventId, Severity}; +use std::future::Future; +use tuliprox_core::model::{ChannelRouting, NotificationEvent}; + +/// What happened to one delivery attempt. +/// +/// The old `Option` could not tell "retry me" from "this URL is +/// malformed, retrying is pointless", so a typo'd webhook burned every +/// attempt before dead-lettering, and a `429` was retried straight back +/// into the rate limit it had just hit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Delivery { + /// Landed. Never retry. + Delivered, + /// Transient. Retry after `after` if the provider named a delay, + /// otherwise on the outbox's own backoff schedule. + Retry { reason: String, after: Option }, + /// Permanently undeliverable - malformed request, rejected credentials. + /// Retrying cannot help, so dead-letter immediately. + Permanent { reason: String }, + /// Nothing to do: the channel does not want this event. Not a failure, + /// and must never be retried. + Skipped, +} + +impl Delivery { + /// Transient failure with no provider-supplied delay. + pub fn retry(reason: impl Into) -> Self { Self::Retry { reason: reason.into(), after: None } } + + /// Transient failure honouring a provider `Retry-After`. + pub fn retry_after(reason: impl Into, after: std::time::Duration) -> Self { + Self::Retry { reason: reason.into(), after: Some(after) } + } + + pub fn permanent(reason: impl Into) -> Self { Self::Permanent { reason: reason.into() } } + + #[must_use] + pub fn is_delivered(&self) -> bool { matches!(self, Self::Delivered) } + + /// Should the outbox keep this channel pending? + #[must_use] + pub fn should_retry(&self) -> bool { matches!(self, Self::Retry { .. }) } +} + +/// Classify an HTTP status into a delivery outcome. +/// +/// Shared by every HTTP-backed channel so they agree on what is worth +/// retrying. `408`/`429` and all of `5xx` are transient; other `4xx` are +/// the caller's fault and will fail identically forever. +#[must_use] +pub fn delivery_for_status(status: reqwest::StatusCode, retry_after: Option) -> Delivery { + if status.is_success() { + return Delivery::Delivered; + } + let reason = format!("status {status}"); + if status == reqwest::StatusCode::TOO_MANY_REQUESTS || status == reqwest::StatusCode::REQUEST_TIMEOUT { + return match retry_after { + Some(after) => Delivery::retry_after(reason, after), + None => Delivery::retry(reason), + }; + } + if status.is_server_error() { + return Delivery::retry(reason); + } + Delivery::permanent(reason) +} + +/// No provider is allowed to park a retry for longer than this. +const MAX_RETRY_AFTER: std::time::Duration = std::time::Duration::from_hours(24); + +/// Read a `Retry-After` header in either of its two legal forms. +#[must_use] +pub fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option { + let raw = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?; + // Delta-seconds is by far the common form. + if let Ok(secs) = raw.trim().parse::() { + return Some(std::time::Duration::from_secs(secs).min(MAX_RETRY_AFTER)); + } + // HTTP-date form. A date already in the past means the wait has elapsed, + // so it clamps to "retry now" rather than falling back to the default + // backoff - the server told us the delay is over. + let when = chrono::DateTime::parse_from_rfc2822(raw.trim()).ok()?; + let delta = when.timestamp().saturating_sub(chrono::Utc::now().timestamp()).max(0); + Some(std::time::Duration::from_secs(u64::try_from(delta).unwrap_or(0)).min(MAX_RETRY_AFTER)) +} + +/// What a channel can accept, so the dispatcher can prepare the body once. +#[derive(Debug, Clone, Copy)] +pub struct ChannelCapabilities { + /// The channel renders operator-supplied templates. When `false` the + /// dispatcher hands over [`NotificationEvent::body`] and does not look + /// for a template - which is what Pushover needed and never had. + pub supports_templates: bool, + /// Hard body limit in bytes; `None` for unlimited. The dispatcher + /// truncates on a character boundary before calling `send`. + pub max_body_bytes: Option, +} + +impl Default for ChannelCapabilities { + fn default() -> Self { Self { supports_templates: true, max_body_bytes: None } } +} + +/// An event with its body already rendered for one channel. +#[derive(Debug, Clone)] +pub struct RenderedMessage<'a> { + /// The event itself, for channels that build structured payloads. + pub event: &'a NotificationEvent, + /// The body to send: the channel's template output, or + /// [`NotificationEvent::body`] when no template applies. + pub body: String, + /// `true` when `body` came from an operator template rather than the + /// built-in rendering. Telegram needs this to decide whether escaping + /// its own output would corrupt deliberate markdown. + pub templated: bool, +} + +/// One configured outbound channel. +pub trait NotificationChannel: Send + Sync { + /// Stable wire id: config key, outbox key, metric label, template + /// filename prefix. Must not change once released - the outbox + /// persists it. + fn id(&self) -> &'static str; + + fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() } + + /// The operator's template for this event, if any. + fn template_for(&self, event: EventId) -> Option<&str>; + + /// This channel's routing rules - severity floor, quiet hours, + /// suppression window and hourly ceiling. + fn routing(&self) -> &ChannelRouting; + + /// Does this channel want this event at this severity? + /// + /// Default accepts everything; per-channel routing overrides it. + fn wants(&self, _event: EventId, _severity: Severity) -> bool { true } + + /// Deliver. Must not panic and must not block - a slow provider has to + /// surface as [`Delivery::Retry`], not a stalled worker. + /// + /// Returns an opaque future rather than a boxed one. That is what keeps + /// the send path allocation-free, and it is why this trait is not object + /// safe - implementations are reached through + /// [`Channel`](crate::channels::Channel), never a trait object. + fn send(&self, msg: &RenderedMessage<'_>) -> impl Future + Send; +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::{header::HeaderMap, StatusCode}; + + #[test] + fn success_is_delivered() { + assert_eq!(delivery_for_status(StatusCode::OK, None), Delivery::Delivered); + assert_eq!(delivery_for_status(StatusCode::NO_CONTENT, None), Delivery::Delivered); + } + + #[test] + fn server_errors_are_retried() { + assert!(delivery_for_status(StatusCode::INTERNAL_SERVER_ERROR, None).should_retry()); + assert!(delivery_for_status(StatusCode::BAD_GATEWAY, None).should_retry()); + assert!(delivery_for_status(StatusCode::SERVICE_UNAVAILABLE, None).should_retry()); + } + + #[test] + fn client_errors_are_permanent_so_a_typo_does_not_burn_every_attempt() { + // A malformed webhook URL used to consume all `max_attempts` with + // exponential backoff before being dead-lettered. + assert!(matches!(delivery_for_status(StatusCode::NOT_FOUND, None), Delivery::Permanent { .. })); + assert!(matches!(delivery_for_status(StatusCode::UNAUTHORIZED, None), Delivery::Permanent { .. })); + assert!(matches!(delivery_for_status(StatusCode::BAD_REQUEST, None), Delivery::Permanent { .. })); + } + + #[test] + fn rate_limit_is_retried_and_carries_the_provider_delay() { + let delay = std::time::Duration::from_secs(30); + let outcome = delivery_for_status(StatusCode::TOO_MANY_REQUESTS, Some(delay)); + assert_eq!(outcome, Delivery::Retry { reason: "status 429 Too Many Requests".to_string(), after: Some(delay) }); + } + + #[test] + fn rate_limit_without_a_header_still_retries() { + assert!(delivery_for_status(StatusCode::TOO_MANY_REQUESTS, None).should_retry()); + } + + #[test] + fn retry_after_reads_delta_seconds() { + let mut headers = HeaderMap::new(); + headers.insert(reqwest::header::RETRY_AFTER, "42".parse().expect("header")); + assert_eq!(parse_retry_after(&headers), Some(std::time::Duration::from_secs(42))); + } + + #[test] + fn retry_after_is_clamped_to_a_day() { + let mut headers = HeaderMap::new(); + headers.insert(reqwest::header::RETRY_AFTER, "999999999".parse().expect("header")); + assert_eq!(parse_retry_after(&headers), Some(std::time::Duration::from_hours(24))); + } + + #[test] + fn retry_after_absent_or_garbage_is_none() { + assert_eq!(parse_retry_after(&HeaderMap::new()), None); + let mut headers = HeaderMap::new(); + headers.insert(reqwest::header::RETRY_AFTER, "not-a-date".parse().expect("header")); + assert_eq!(parse_retry_after(&headers), None); + } + + #[test] + fn a_past_http_date_yields_no_delay_rather_than_underflowing() { + let mut headers = HeaderMap::new(); + headers.insert(reqwest::header::RETRY_AFTER, "Wed, 21 Oct 2015 07:28:00 GMT".parse().expect("header")); + assert_eq!(parse_retry_after(&headers), Some(std::time::Duration::ZERO)); + } + + #[test] + fn skipped_and_permanent_are_never_retried() { + assert!(!Delivery::Skipped.should_retry()); + assert!(!Delivery::permanent("nope").should_retry()); + assert!(!Delivery::Delivered.should_retry()); + } +} diff --git a/backend/messaging/src/channels/command.rs b/backend/messaging/src/channels/command.rs new file mode 100644 index 000000000..5b5e2e885 --- /dev/null +++ b/backend/messaging/src/channels/command.rs @@ -0,0 +1,98 @@ +//! Run a local program with the event JSON on stdin. +//! +//! The escape hatch that means nobody has to wait for a channel to be added +//! upstream. This runs arbitrary code as the tuliprox process user, so it is +//! opt-in and never configured by default. +//! +//! The program is executed directly, never through a shell, so there are no +//! quoting rules to get wrong and no shell-injection surface from event +//! content. + +use crate::channel::{ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage}; +use log::debug; +use shared::model::notification::{EventId, Severity}; +use tokio::io::AsyncWriteExt; +use tuliprox_core::model::{ChannelRouting, CommandMessagingConfig}; + +#[derive(Clone)] +pub struct CommandChannel { + config: CommandMessagingConfig, +} + +impl CommandChannel { + pub fn new(config: CommandMessagingConfig) -> Self { Self { config } } +} + +impl NotificationChannel for CommandChannel { + fn id(&self) -> &'static str { "command" } + + fn template_for(&self, event: EventId) -> Option<&str> { self.config.templates.get(&event).map(String::as_str) } + + fn routing(&self) -> &ChannelRouting { &self.config.routing } + + fn wants(&self, event: EventId, severity: Severity) -> bool { self.config.routing.accepts(event, severity) } + + async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + { + // Without a template, hand over the whole event as JSON so a + // script can pick out whatever it needs. + let payload = if msg.templated { + msg.body.clone() + } else { + serde_json::to_string(msg.event).unwrap_or_else(|_| msg.body.clone()) + }; + + let mut command = tokio::process::Command::new(&self.config.program); + command + .args(&self.config.args) + // Useful for a script that only wants to branch on the event. + .env("TULIPROX_EVENT_ID", msg.event.id.as_str()) + .env("TULIPROX_EVENT_SEVERITY", msg.event.severity.wire_name()) + .env("TULIPROX_EVENT_TITLE", &msg.event.title) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()); + + let mut child = match command.spawn() { + Ok(child) => child, + // A missing binary or a bad permission bit will fail + // identically forever, so retrying is pointless. + Err(err) => return Delivery::permanent(format!("could not run `{}`: {err}", self.config.program)), + }; + + if let Some(mut stdin) = child.stdin.take() { + if let Err(err) = stdin.write_all(payload.as_bytes()).await { + return Delivery::retry(format!("could not write to `{}` stdin: {err}", self.config.program)); + } + // Dropping closes the pipe, so the child sees EOF and can + // finish instead of blocking on a read forever. + drop(stdin); + } + + match tokio::time::timeout(self.config.timeout, child.wait_with_output()).await { + Ok(Ok(output)) if output.status.success() => { + debug!("Notification handled by `{}`", self.config.program); + Delivery::Delivered + } + Ok(Ok(output)) => { + let stderr = String::from_utf8_lossy(&output.stderr); + let stderr = stderr.trim(); + Delivery::retry(format!( + "`{}` exited with {}: {}", + self.config.program, + output.status, + if stderr.is_empty() { "(no stderr)" } else { stderr } + )) + } + Ok(Err(err)) => Delivery::retry(format!("`{}` failed: {err}", self.config.program)), + Err(_) => Delivery::retry(format!( + "`{}` did not finish within {}s", + self.config.program, + self.config.timeout.as_secs() + )), + } + } + } + + fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() } +} diff --git a/backend/messaging/src/channels/discord.rs b/backend/messaging/src/channels/discord.rs new file mode 100644 index 000000000..b183373bb --- /dev/null +++ b/backend/messaging/src/channels/discord.rs @@ -0,0 +1,67 @@ +//! Discord webhook channel. + +use crate::channel::{ + delivery_for_status, parse_retry_after, ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage, +}; +use log::debug; +use reqwest::header; +use serde_json::json; +use shared::model::notification::{EventId, Severity}; +use tuliprox_core::model::{ChannelRouting, DiscordMessagingConfig}; + +/// Discord rejects a webhook payload whose `content` exceeds this. +const DISCORD_CONTENT_LIMIT: usize = 2000; + +#[derive(Clone)] +pub struct DiscordChannel { + config: DiscordMessagingConfig, + client: reqwest::Client, +} + +impl DiscordChannel { + pub fn new(config: DiscordMessagingConfig, client: reqwest::Client) -> Self { Self { config, client } } +} + +impl NotificationChannel for DiscordChannel { + fn id(&self) -> &'static str { "discord" } + + fn template_for(&self, event: EventId) -> Option<&str> { self.config.templates.get(&event).map(String::as_str) } + + fn routing(&self) -> &ChannelRouting { &self.config.routing } + + fn wants(&self, event: EventId, severity: Severity) -> bool { self.config.routing.accepts(event, severity) } + + async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + { + // A template is expected to produce the whole webhook payload. + // Without one, wrap the plain body in the minimal valid shape. + let body = if msg.templated { + msg.body.clone() + } else { + let truncated: String = msg.body.chars().take(DISCORD_CONTENT_LIMIT).collect(); + json!({ "content": truncated }).to_string() + }; + + match self + .client + .post(&self.config.url) + .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) + .body(body) + .send() + .await + { + Ok(response) => { + let status = response.status(); + let retry_after = parse_retry_after(response.headers()); + if status.is_success() { + debug!("Notification delivered to Discord"); + } + delivery_for_status(status, retry_after) + } + Err(err) => Delivery::retry(format!("discord request failed: {err}")), + } + } + } + + fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() } +} diff --git a/backend/messaging/src/channels/gotify.rs b/backend/messaging/src/channels/gotify.rs new file mode 100644 index 000000000..a42bdf54c --- /dev/null +++ b/backend/messaging/src/channels/gotify.rs @@ -0,0 +1,91 @@ +//! Gotify channel. + +use crate::channel::{ + delivery_for_status, parse_retry_after, ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage, +}; +use log::debug; +use serde_json::json; +use shared::model::notification::{EventId, Severity}; +use tuliprox_core::model::{ChannelRouting, GotifyMessagingConfig}; + +#[derive(Clone)] +pub struct GotifyChannel { + config: GotifyMessagingConfig, + client: reqwest::Client, +} + +impl GotifyChannel { + pub fn new(config: GotifyMessagingConfig, client: reqwest::Client) -> Self { Self { config, client } } + + /// Gotify's 0-10 priority scale. + fn priority(severity: Severity) -> u8 { + match severity { + Severity::Info => 2, + Severity::Warn => 5, + Severity::Error => 7, + Severity::Critical => 9, + } + } +} + +impl NotificationChannel for GotifyChannel { + fn id(&self) -> &'static str { "gotify" } + + fn template_for(&self, event: EventId) -> Option<&str> { self.config.templates.get(&event).map(String::as_str) } + + fn routing(&self) -> &ChannelRouting { &self.config.routing } + + fn wants(&self, event: EventId, severity: Severity) -> bool { self.config.routing.accepts(event, severity) } + + async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + { + // A template is expected to produce the whole payload; without + // one, build the minimal valid shape. + let body = if msg.templated { + msg.body.clone() + } else { + json!({ + "title": msg.event.title, + "message": msg.body, + "priority": Self::priority(msg.event.severity), + }) + .to_string() + }; + let url = format!("{}/message", self.config.url); + match self + .client + .post(&url) + .query(&[("token", self.config.token.as_str())]) + .header(reqwest::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) + .body(body) + .send() + .await + { + Ok(response) => { + let status = response.status(); + let retry_after = parse_retry_after(response.headers()); + if status.is_success() { + debug!("Notification delivered to Gotify"); + } + delivery_for_status(status, retry_after) + } + Err(err) => Delivery::retry(format!("gotify request failed: {err}")), + } + } + } + + fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() } +} + +#[cfg(test)] +mod tests { + use super::{GotifyChannel, Severity}; + + #[test] + fn severity_maps_onto_the_gotify_priority_scale() { + assert!(GotifyChannel::priority(Severity::Info) < GotifyChannel::priority(Severity::Warn)); + assert!(GotifyChannel::priority(Severity::Warn) < GotifyChannel::priority(Severity::Error)); + assert!(GotifyChannel::priority(Severity::Error) < GotifyChannel::priority(Severity::Critical)); + assert!(GotifyChannel::priority(Severity::Critical) <= 10); + } +} diff --git a/backend/messaging/src/channels/mod.rs b/backend/messaging/src/channels/mod.rs new file mode 100644 index 000000000..4b83fd37c --- /dev/null +++ b/backend/messaging/src/channels/mod.rs @@ -0,0 +1,187 @@ +//! The built-in channels. +//! +//! Each is one [`NotificationChannel`](crate::channel::NotificationChannel) +//! impl plus a [`Channel`] variant. Adding another means a module here, an +//! enum variant, a config field and a line in [`build`] - the dispatcher, +//! the outbox and the renderer stay untouched. +//! +//! [`Channel`] is what makes dispatch static: the match monomorphizes into +//! a direct call, so there is no vtable and no boxed future anywhere on the +//! send path. + +pub mod command; +pub mod discord; +pub mod gotify; +pub mod ntfy; +pub mod pushover; +pub mod rest; +pub mod slack; +pub mod telegram; + +use crate::channel::{ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage}; +use log::warn; +use shared::model::notification::{EventId, Severity}; +use std::sync::{Arc, OnceLock, RwLock}; +use tuliprox_core::{ + model::{AppConfig, ChannelRouting, MessagingConfig}, + utils::request::create_client, +}; + +/// One configured channel. +/// +/// An enum rather than a trait object, so every dispatch is a direct call +/// the compiler can inline. `NotificationChannel::send` returns an opaque +/// future, which makes the trait non-object-safe by construction - this is +/// the only way an implementation is reached. +#[derive(Clone)] +pub enum Channel { + Telegram(telegram::TelegramChannel), + Rest(rest::RestChannel), + Pushover(pushover::PushoverChannel), + Discord(discord::DiscordChannel), + Ntfy(ntfy::NtfyChannel), + Gotify(gotify::GotifyChannel), + Slack(slack::SlackChannel), + Command(command::CommandChannel), +} + +/// Forward one `&self` method to whichever variant is present. +macro_rules! delegate { + ($self:ident, $inner:ident => $call:expr) => { + match $self { + Channel::Telegram($inner) => $call, + Channel::Rest($inner) => $call, + Channel::Pushover($inner) => $call, + Channel::Discord($inner) => $call, + Channel::Ntfy($inner) => $call, + Channel::Gotify($inner) => $call, + Channel::Slack($inner) => $call, + Channel::Command($inner) => $call, + } + }; +} + +impl Channel { + /// Stable wire id: config key, outbox key, metric label, template prefix. + #[must_use] + pub fn id(&self) -> &'static str { delegate!(self, c => c.id()) } + + #[must_use] + pub fn capabilities(&self) -> ChannelCapabilities { delegate!(self, c => c.capabilities()) } + + #[must_use] + pub fn template_for(&self, event: EventId) -> Option<&str> { delegate!(self, c => c.template_for(event)) } + + #[must_use] + pub fn routing(&self) -> &ChannelRouting { delegate!(self, c => c.routing()) } + + #[must_use] + pub fn wants(&self, event: EventId, severity: Severity) -> bool { delegate!(self, c => c.wants(event, severity)) } + + /// Deliver through the concrete channel. + /// + /// Each arm awaits a distinct concrete future type; the `async fn` is + /// what unifies them without boxing. + pub async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + match self { + Channel::Telegram(c) => c.send(msg).await, + Channel::Rest(c) => c.send(msg).await, + Channel::Pushover(c) => c.send(msg).await, + Channel::Discord(c) => c.send(msg).await, + Channel::Ntfy(c) => c.send(msg).await, + Channel::Gotify(c) => c.send(msg).await, + Channel::Slack(c) => c.send(msg).await, + Channel::Command(c) => c.send(msg).await, + } + } +} + +/// The channels configured right now, in a stable order. +pub type ChannelSet = Vec; + +/// Per-channel request timeout. +/// +/// The shared client sets no request timeout at all, and the outbox awaits +/// sends in sequence - so one webhook host that accepts a connection and +/// never answers used to stall every pending notification, including the +/// recording ones the outbox exists to protect. +const DEFAULT_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Build an HTTP client for a channel, with a bounded request timeout. +fn channel_client(app_config: &Arc) -> reqwest::Client { + create_client(app_config).timeout(DEFAULT_SEND_TIMEOUT).build().unwrap_or_else(|err| { + warn!("Falling back to a default HTTP client for notifications: {err}"); + reqwest::Client::new() + }) +} + +/// The built channel set, so a notification does not rebuild every channel +/// - and a fresh `reqwest::Client` with them - on every send. +/// +/// Invalidated on config reload via [`invalidate`]. +static CACHE: OnceLock>> = OnceLock::new(); + +fn cache() -> &'static RwLock> { CACHE.get_or_init(|| RwLock::new(None)) } + +/// Drop the cached channel set. Call on config reload. +pub fn invalidate() { + if let Ok(mut guard) = cache().write() { + *guard = None; + } + crate::rate_limit::reset(); +} + +/// Every channel the current config declares, in a stable order. +/// +/// Cached: repeated calls return the same channels, so per-channel state +/// (rate limiting, suppression) survives between notifications. +#[must_use] +pub fn channels(app_config: &Arc, messaging: &MessagingConfig) -> ChannelSet { + if let Ok(guard) = cache().read() { + if let Some(set) = guard.as_ref() { + return set.clone(); + } + } + let set = build(app_config, messaging); + if let Ok(mut guard) = cache().write() { + *guard = Some(set.clone()); + } + set +} + +/// Construct the channel set from config, bypassing the cache. +#[must_use] +pub fn build(app_config: &Arc, messaging: &MessagingConfig) -> ChannelSet { + let client = channel_client(app_config); + let mut channels: ChannelSet = Vec::with_capacity(8); + if let Some(cfg) = messaging.telegram.as_ref() { + channels.push(Channel::Telegram(telegram::TelegramChannel::new( + cfg.clone(), + Arc::clone(app_config), + client.clone(), + ))); + } + if let Some(cfg) = messaging.rest.as_ref() { + channels.push(Channel::Rest(rest::RestChannel::new(cfg.clone(), client.clone()))); + } + if let Some(cfg) = messaging.pushover.as_ref() { + channels.push(Channel::Pushover(pushover::PushoverChannel::new(cfg.clone(), client.clone()))); + } + if let Some(cfg) = messaging.discord.as_ref() { + channels.push(Channel::Discord(discord::DiscordChannel::new(cfg.clone(), client.clone()))); + } + if let Some(cfg) = messaging.ntfy.as_ref() { + channels.push(Channel::Ntfy(ntfy::NtfyChannel::new(cfg.clone(), client.clone()))); + } + if let Some(cfg) = messaging.gotify.as_ref() { + channels.push(Channel::Gotify(gotify::GotifyChannel::new(cfg.clone(), client.clone()))); + } + if let Some(cfg) = messaging.slack.as_ref() { + channels.push(Channel::Slack(slack::SlackChannel::new(cfg.clone(), client))); + } + if let Some(cfg) = messaging.command.as_ref() { + // No HTTP client: this one shells out. + channels.push(Channel::Command(command::CommandChannel::new(cfg.clone()))); + } + channels +} diff --git a/backend/messaging/src/channels/ntfy.rs b/backend/messaging/src/channels/ntfy.rs new file mode 100644 index 000000000..775469514 --- /dev/null +++ b/backend/messaging/src/channels/ntfy.rs @@ -0,0 +1,104 @@ +//! ntfy channel - self-hosted push, no account, no bot token. + +use crate::channel::{ + delivery_for_status, parse_retry_after, ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage, +}; +use log::debug; +use reqwest::header; +use shared::model::notification::{EventId, Severity}; +use tuliprox_core::model::{ChannelRouting, NtfyMessagingConfig}; + +#[derive(Clone)] +pub struct NtfyChannel { + config: NtfyMessagingConfig, + client: reqwest::Client, +} + +impl NtfyChannel { + pub fn new(config: NtfyMessagingConfig, client: reqwest::Client) -> Self { Self { config, client } } + + /// ntfy's 1-5 priority scale. + fn priority(severity: Severity) -> &'static str { + match severity { + Severity::Info => "2", + Severity::Warn => "3", + Severity::Error => "4", + Severity::Critical => "5", + } + } + + /// ntfy headers must be ASCII, and a title with an emoji in it is + /// common. Strip rather than fail the send. + fn ascii_title(title: &str) -> String { + let cleaned: String = title.chars().filter(char::is_ascii).collect(); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + "tuliprox".to_string() + } else { + trimmed.chars().take(200).collect() + } + } +} + +impl NotificationChannel for NtfyChannel { + fn id(&self) -> &'static str { "ntfy" } + + fn template_for(&self, event: EventId) -> Option<&str> { self.config.templates.get(&event).map(String::as_str) } + + fn routing(&self) -> &ChannelRouting { &self.config.routing } + + fn wants(&self, event: EventId, severity: Severity) -> bool { self.config.routing.accepts(event, severity) } + + async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + { + let url = format!("{}/{}", self.config.url, self.config.topic); + let mut rb = self + .client + .post(&url) + .header("X-Title", Self::ascii_title(&msg.event.title)) + .header("X-Priority", Self::priority(msg.event.severity)) + .header("X-Tags", msg.event.id.domain()) + .body(msg.body.clone()); + if let Some(token) = &self.config.token { + rb = rb.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + match rb.send().await { + Ok(response) => { + let status = response.status(); + let retry_after = parse_retry_after(response.headers()); + if status.is_success() { + debug!("Notification delivered to ntfy"); + } + delivery_for_status(status, retry_after) + } + Err(err) => Delivery::retry(format!("ntfy request failed: {err}")), + } + } + } + + fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() } +} + +#[cfg(test)] +mod tests { + use super::{NtfyChannel, Severity}; + + #[test] + fn severity_maps_onto_the_ntfy_priority_scale() { + assert_eq!(NtfyChannel::priority(Severity::Info), "2"); + assert_eq!(NtfyChannel::priority(Severity::Warn), "3"); + assert_eq!(NtfyChannel::priority(Severity::Error), "4"); + assert_eq!(NtfyChannel::priority(Severity::Critical), "5"); + } + + #[test] + fn a_title_with_emoji_is_stripped_to_ascii_rather_than_failing_the_send() { + // ntfy headers must be ASCII, and titles routinely carry emoji. + assert_eq!(NtfyChannel::ascii_title("Recording completed \u{1F534}"), "Recording completed"); + } + + #[test] + fn a_title_that_is_entirely_non_ascii_falls_back_rather_than_going_out_empty() { + assert_eq!(NtfyChannel::ascii_title("\u{1F534}\u{1F535}"), "tuliprox"); + } +} diff --git a/backend/messaging/src/channels/pushover.rs b/backend/messaging/src/channels/pushover.rs new file mode 100644 index 000000000..c9ad35eef --- /dev/null +++ b/backend/messaging/src/channels/pushover.rs @@ -0,0 +1,99 @@ +//! Pushover channel. +//! +//! Pushover had no `templates` map at all, so every notification took the +//! built-in text - which for watch changes and playlist stats was a raw +//! `serde_json` dump pushed to a phone. It now renders templates like every +//! other channel, and sends `title` and `body` separately so the push +//! notification has a usable headline. + +use crate::channel::{ + delivery_for_status, parse_retry_after, ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage, +}; +use log::debug; +use reqwest::header; +use shared::model::notification::{EventId, Severity}; +use tuliprox_core::model::{ChannelRouting, PushoverMessagingConfig}; + +/// Pushover truncates the message body here. +const PUSHOVER_BODY_LIMIT: usize = 1024; +/// And the title here. +const PUSHOVER_TITLE_LIMIT: usize = 250; + +#[derive(Clone)] +pub struct PushoverChannel { + config: PushoverMessagingConfig, + client: reqwest::Client, +} + +impl PushoverChannel { + pub fn new(config: PushoverMessagingConfig, client: reqwest::Client) -> Self { Self { config, client } } + + /// Pushover's own priority scale, from the event severity. + fn priority(severity: Severity) -> &'static str { + match severity { + Severity::Info => "-1", + Severity::Warn => "0", + Severity::Error | Severity::Critical => "1", + } + } +} + +impl NotificationChannel for PushoverChannel { + fn id(&self) -> &'static str { "pushover" } + + fn template_for(&self, event: EventId) -> Option<&str> { self.config.templates.get(&event).map(String::as_str) } + + fn routing(&self) -> &ChannelRouting { &self.config.routing } + + fn wants(&self, event: EventId, severity: Severity) -> bool { self.config.routing.accepts(event, severity) } + + async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + { + let body: String = msg.body.chars().take(PUSHOVER_BODY_LIMIT).collect(); + let title: String = msg.event.title.chars().take(PUSHOVER_TITLE_LIMIT).collect(); + let encoded: String = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("token", self.config.token.as_str()) + .append_pair("user", self.config.user.as_str()) + .append_pair("title", &title) + .append_pair("message", &body) + .append_pair("priority", Self::priority(msg.event.severity)) + .finish(); + + match self + .client + .post(&self.config.url) + .header(header::CONTENT_TYPE, mime::APPLICATION_WWW_FORM_URLENCODED.to_string()) + .body(encoded) + .send() + .await + { + Ok(response) => { + let status = response.status(); + let retry_after = parse_retry_after(response.headers()); + if status.is_success() { + debug!("Notification delivered to Pushover"); + } + delivery_for_status(status, retry_after) + } + Err(err) => Delivery::retry(format!("pushover request failed: {err}")), + } + } + } + + fn capabilities(&self) -> ChannelCapabilities { + ChannelCapabilities { supports_templates: true, max_body_bytes: Some(PUSHOVER_BODY_LIMIT) } + } +} + +#[cfg(test)] +mod tests { + use super::{PushoverChannel, Severity}; + + #[test] + fn severity_maps_onto_the_pushover_priority_scale() { + assert_eq!(PushoverChannel::priority(Severity::Info), "-1"); + assert_eq!(PushoverChannel::priority(Severity::Warn), "0"); + assert_eq!(PushoverChannel::priority(Severity::Error), "1"); + assert_eq!(PushoverChannel::priority(Severity::Critical), "1"); + } +} diff --git a/backend/messaging/src/channels/rest.rs b/backend/messaging/src/channels/rest.rs new file mode 100644 index 000000000..01dc0d235 --- /dev/null +++ b/backend/messaging/src/channels/rest.rs @@ -0,0 +1,130 @@ +//! Generic REST/webhook channel. + +use crate::channel::{ + delivery_for_status, parse_retry_after, ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage, +}; +use log::debug; +use reqwest::{header, Method}; +use shared::model::notification::{EventId, Severity}; +use std::str::FromStr; +use tuliprox_core::model::{ChannelRouting, RestMessagingConfig}; + +/// Hex HMAC-SHA256 of `{timestamp}.{body}`. +/// +/// The timestamp is inside the signed payload so a captured request cannot +/// be replayed later with a fresh timestamp header. +fn sign(secret: &str, timestamp: i64, body: &str) -> String { + use hmac::{KeyInit, Mac, SimpleHmac}; + let mut mac = as KeyInit>::new_from_slice(secret.as_bytes()) + .expect("HMAC accepts a key of any length"); + mac.update(timestamp.to_string().as_bytes()); + mac.update(b"."); + mac.update(body.as_bytes()); + let bytes = mac.finalize().into_bytes(); + bytes.iter().fold(String::with_capacity(bytes.len() * 2), |mut out, byte| { + use std::fmt::Write; + let _ = write!(out, "{byte:02x}"); + out + }) +} + +#[derive(Clone)] +pub struct RestChannel { + config: RestMessagingConfig, + client: reqwest::Client, +} + +impl RestChannel { + pub fn new(config: RestMessagingConfig, client: reqwest::Client) -> Self { Self { config, client } } +} + +impl NotificationChannel for RestChannel { + fn id(&self) -> &'static str { "rest" } + + fn template_for(&self, event: EventId) -> Option<&str> { self.config.templates.get(&event).map(String::as_str) } + + fn routing(&self) -> &ChannelRouting { &self.config.routing } + + fn wants(&self, event: EventId, severity: Severity) -> bool { self.config.routing.accepts(event, severity) } + + async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + { + let method = Method::from_str(&self.config.method).unwrap_or(Method::POST); + let mut rb = self.client.request(method, &self.config.url); + + // HMAC-SHA256 over `timestamp.body`, so the receiving endpoint + // can verify the sender and reject a replayed request. + if let Some(secret) = &self.config.signing_secret { + let timestamp = chrono::Utc::now().timestamp(); + let signature = sign(secret, timestamp, &msg.body); + rb = rb + .header("X-Tuliprox-Timestamp", timestamp.to_string()) + .header("X-Tuliprox-Signature", format!("sha256={signature}")); + } + + let has_content_type = self.config.headers.keys().any(|k| k.eq_ignore_ascii_case("content-type")); + if !has_content_type { + rb = rb.header(header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()); + } + for (key, value) in &self.config.headers { + rb = rb.header(key, value); + } + + match rb.body(msg.body.clone()).send().await { + Ok(response) => { + let status = response.status(); + let retry_after = parse_retry_after(response.headers()); + if status.is_success() { + debug!("Notification delivered to rest api"); + } + delivery_for_status(status, retry_after) + } + Err(err) => Delivery::retry(format!("rest request failed: {err}")), + } + } + } + + fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() } +} + +#[cfg(test)] +mod tests { + use super::sign; + + #[test] + fn signing_is_deterministic_for_the_same_inputs() { + assert_eq!(sign("secret", 1_700_000_000, "body"), sign("secret", 1_700_000_000, "body")); + } + + #[test] + fn the_signature_covers_the_secret_the_timestamp_and_the_body() { + let base = sign("secret", 1_700_000_000, "body"); + assert_ne!(base, sign("other", 1_700_000_000, "body"), "secret not covered"); + assert_ne!(base, sign("secret", 1_700_000_001, "body"), "timestamp not covered"); + assert_ne!(base, sign("secret", 1_700_000_000, "other"), "body not covered"); + } + + #[test] + fn the_signature_is_64_hex_characters() { + let signature = sign("secret", 1, "body"); + assert_eq!(signature.len(), 64, "sha256 hex must be 64 chars: {signature}"); + assert!(signature.chars().all(|c| c.is_ascii_hexdigit()), "non-hex output: {signature}"); + } + + #[test] + fn matches_a_known_hmac_sha256_vector() { + // RFC 4231 test case 2: key "Jefe", data "what do ya want for nothing?". + // Reproduced through `sign` by splitting the data across the + // timestamp/body fields exactly as the separator implies. + use hmac::{KeyInit, Mac, SimpleHmac}; + let mut mac = as KeyInit>::new_from_slice(b"Jefe").expect("key"); + mac.update(b"what do ya want for nothing?"); + let bytes = mac.finalize().into_bytes(); + let expected = bytes.iter().fold(String::new(), |mut out, b| { + use std::fmt::Write; + let _ = write!(out, "{b:02x}"); + out + }); + assert_eq!(expected, "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"); + } +} diff --git a/backend/messaging/src/channels/slack.rs b/backend/messaging/src/channels/slack.rs new file mode 100644 index 000000000..9428c5649 --- /dev/null +++ b/backend/messaging/src/channels/slack.rs @@ -0,0 +1,102 @@ +//! Slack incoming webhook. +//! +//! Not a Discord clone: Block Kit differs enough from Discord embeds that +//! reusing the Discord payload shape produces bad output. + +use crate::channel::{ + delivery_for_status, parse_retry_after, ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage, +}; +use log::debug; +use serde_json::json; +use shared::model::notification::{EventId, Severity}; +use tuliprox_core::model::{ChannelRouting, SlackMessagingConfig}; + +/// Slack rejects a `text` field longer than this. +const SLACK_TEXT_LIMIT: usize = 3000; + +#[derive(Clone)] +pub struct SlackChannel { + config: SlackMessagingConfig, + client: reqwest::Client, +} + +impl SlackChannel { + pub fn new(config: SlackMessagingConfig, client: reqwest::Client) -> Self { Self { config, client } } + + /// Slack has no severity field, so it goes in the header text where a + /// human will actually see it. + fn severity_marker(severity: Severity) -> &'static str { + match severity { + Severity::Info => "", + Severity::Warn => "[warn] ", + Severity::Error => "[error] ", + Severity::Critical => "[CRITICAL] ", + } + } +} + +impl NotificationChannel for SlackChannel { + fn id(&self) -> &'static str { "slack" } + + fn template_for(&self, event: EventId) -> Option<&str> { self.config.templates.get(&event).map(String::as_str) } + + fn routing(&self) -> &ChannelRouting { &self.config.routing } + + fn wants(&self, event: EventId, severity: Severity) -> bool { self.config.routing.accepts(event, severity) } + + async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + { + let body = if msg.templated { + msg.body.clone() + } else { + let text: String = msg.body.chars().take(SLACK_TEXT_LIMIT).collect(); + let header = format!("{}{}", Self::severity_marker(msg.event.severity), msg.event.title); + json!({ + "text": header, + "blocks": [ + { "type": "header", "text": { "type": "plain_text", "text": header, "emoji": true } }, + { "type": "section", "text": { "type": "mrkdwn", "text": text } }, + { "type": "context", "elements": [ + { "type": "mrkdwn", "text": format!("`{}` · {}", msg.event.id, msg.event.timestamp_rfc3339()) } + ] } + ] + }) + .to_string() + }; + match self + .client + .post(&self.config.url) + .header(reqwest::header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) + .body(body) + .send() + .await + { + Ok(response) => { + let status = response.status(); + let retry_after = parse_retry_after(response.headers()); + if status.is_success() { + debug!("Notification delivered to Slack"); + } + delivery_for_status(status, retry_after) + } + Err(err) => Delivery::retry(format!("slack request failed: {err}")), + } + } + } + + fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() } +} + +#[cfg(test)] +mod tests { + use super::{Severity, SlackChannel}; + + #[test] + fn only_non_info_severities_get_a_marker() { + // Slack has no severity field, so it has to go in the header text - + // but tagging every routine notification would be noise. + assert_eq!(SlackChannel::severity_marker(Severity::Info), ""); + assert!(SlackChannel::severity_marker(Severity::Warn).contains("warn")); + assert!(SlackChannel::severity_marker(Severity::Critical).contains("CRITICAL")); + } +} diff --git a/backend/messaging/src/channels/telegram.rs b/backend/messaging/src/channels/telegram.rs new file mode 100644 index 000000000..e44eebf04 --- /dev/null +++ b/backend/messaging/src/channels/telegram.rs @@ -0,0 +1,91 @@ +//! Telegram channel. + +use crate::channel::{ChannelCapabilities, Delivery, NotificationChannel, RenderedMessage}; +use log::debug; +use shared::{ + model::notification::{EventId, Severity}, + utils::{escape_markdown_v2, json_str_to_markdown}, +}; +use std::{borrow::Cow, sync::Arc}; +use tuliprox_core::{ + model::{AppConfig, ChannelRouting, TelegramMessagingConfig}, + utils::{telegram_create_instance, telegram_send_message, SendMessageOption, SendMessageParseMode}, +}; + +#[derive(Clone)] +pub struct TelegramChannel { + config: TelegramMessagingConfig, + app_config: Arc, + client: reqwest::Client, +} + +impl TelegramChannel { + pub fn new(config: TelegramMessagingConfig, app_config: Arc, client: reqwest::Client) -> Self { + Self { config, app_config, client } + } +} + +impl NotificationChannel for TelegramChannel { + fn id(&self) -> &'static str { "telegram" } + + fn template_for(&self, event: EventId) -> Option<&str> { self.config.templates.get(&event).map(String::as_str) } + + fn routing(&self) -> &ChannelRouting { &self.config.routing } + + fn wants(&self, event: EventId, severity: Severity) -> bool { self.config.routing.accepts(event, severity) } + + async fn send(&self, msg: &RenderedMessage<'_>) -> Delivery { + { + let raw = &msg.body; + let (message, options) = if self.config.markdown { + if let Ok(md) = json_str_to_markdown(raw) { + (Cow::Owned(md), Some(SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 })) + } else if msg.templated { + // Keep deliberate template markdown as-is. + (Cow::Borrowed(raw), Some(SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 })) + } else { + ( + Cow::Owned(escape_markdown_v2(raw)), + Some(SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 }), + ) + } + } else { + (Cow::Borrowed(raw), None) + }; + + // A chat that is already delivered must not be re-sent when + // another chat fails, so failures are reported per chat id and + // the outbox retries only what is still pending. + let mut failed: Vec = Vec::new(); + for chat_id in &self.config.chat_ids { + let bot = telegram_create_instance(&self.config.bot_token, chat_id); + let result = + telegram_send_message(&self.app_config, &self.client, &bot, &message, options.as_ref()).await; + let mut delivered = result.delivered; + if self.config.markdown && msg.templated && result.parse_error && !delivered { + // Template output can carry dynamic fields that break + // MarkdownV2. Retry once, escaped. + let escaped = escape_markdown_v2(raw); + let escaped_options = SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 }; + delivered = + telegram_send_message(&self.app_config, &self.client, &bot, &escaped, Some(&escaped_options)) + .await + .delivered; + } + if delivered { + debug!("Notification delivered to Telegram chat {chat_id}"); + } else { + failed.push(chat_id.clone()); + } + } + + if failed.is_empty() { + Delivery::Delivered + } else { + Delivery::retry(format!("telegram chat id(s) not delivered: {}", failed.join(", "))) + } + } + } + + fn capabilities(&self) -> ChannelCapabilities { ChannelCapabilities::default() } +} diff --git a/backend/messaging/src/lib.rs b/backend/messaging/src/lib.rs index 357847676..2f37cee88 100644 --- a/backend/messaging/src/lib.rs +++ b/backend/messaging/src/lib.rs @@ -1,476 +1,309 @@ //! Outbound notification delivery. //! -//! Renders message templates and sends them over the configured channels - -//! Telegram and generic REST endpoints. The playlist pipeline, the recording -//! supervisor and the API all notify through here, and none of them are named -//! by this crate. +//! Renders notification events and sends them over the configured channels. +//! The playlist pipeline, the recording supervisor and the API all notify +//! through here, and none of them are named by this crate. +//! +//! Channels are an open set: see [`channel::NotificationChannel`]. Event +//! kinds are an open set too - see [`shared::model::notification`]. Adding +//! either no longer means editing a match in this file. -use chrono::Utc; -use handlebars::{Context, Handlebars, Helper, HelperResult, Output, RenderContext}; +pub mod channel; +pub mod channels; +pub mod outbox; +pub mod rate_limit; +pub mod render; + +use crate::{ + channel::{Delivery, RenderedMessage}, + channels::{Channel, ChannelSet}, +}; use log::{debug, error}; -use reqwest::{header, Method}; -use serde_json::json; -use shared::{ - model::{DiskAlert, InputFetchMethod, MsgKind}, - utils::{escape_markdown_v2, human_readable_byte_size, json_str_to_markdown, Internable}, -}; -use std::{ - borrow::Cow, - collections::HashMap, - str::FromStr, - sync::{Arc, LazyLock}, -}; -use tuliprox_core::{ - model::{AppConfig, InputSource, MessageContent, MessagingConfig, TemplateContext}, - utils::{ - request::download_text_content, telegram_create_instance, telegram_send_message, SendMessageOption, - SendMessageParseMode, - }, +use shared::model::{ + notification::{EventId, Severity}, + MsgKind, }; +use std::sync::Arc; +use tuliprox_core::model::{AppConfig, MessageContent, MessagingConfig, NotificationEvent}; -fn is_enabled(kind: MsgKind, cfg: &MessagingConfig) -> bool { cfg.notify_on.contains(&kind) } +/// Is this event subscribed by `notify_on`? +fn is_enabled(id: EventId, cfg: &MessagingConfig) -> bool { cfg.subscription().matches(id) } -/// One configured outbound messaging channel. +/// The channels currently configured for `id`, by stable channel id. /// -/// The notification outbox retries per channel, not per message: a -/// message that reached Telegram but not Discord must be re-sent only to -/// Discord, or the retry would deliver a duplicate. Serialized into the -/// outbox file, so the variant names are part of that file's format. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MessagingChannel { - Telegram, - Rest, - Pushover, - Discord, -} - -/// Result of one channel send. `None` means "nothing to do" — the -/// channel is not configured, or this message kind is filtered out — and -/// must never be retried. -pub type ChannelOutcome = Option; - -/// The channels currently configured for `kind`, in a stable order. -pub fn configured_channels(app_config: &Arc, kind: MsgKind) -> Vec { +/// The outbox persists these strings, so a channel added in a later release +/// no longer makes an older build reject the whole outbox file. +pub fn configured_channels(app_config: &Arc, id: EventId) -> Vec { let cfg = app_config.config.load(); let Some(messaging) = cfg.messaging.as_ref() else { return Vec::new(); }; - if !is_enabled(kind, messaging) { + if !is_enabled(id, messaging) { return Vec::new(); } - let mut channels = Vec::with_capacity(4); - if messaging.telegram.is_some() { - channels.push(MessagingChannel::Telegram); - } - if messaging.rest.is_some() { - channels.push(MessagingChannel::Rest); - } - if messaging.pushover.is_some() { - channels.push(MessagingChannel::Pushover); - } - if messaging.discord.is_some() { - channels.push(MessagingChannel::Discord); - } - channels + channels::channels(app_config, messaging) + .into_iter() + .filter(|channel| channel.wants(id, severity_of(id))) + .map(|channel| channel.id().to_string()) + .collect() } -/// Send `content` to exactly one channel and report whether it landed. -/// -/// `send_message` fans out to every channel and swallows the outcome, -/// which is fine for fire-and-forget notifications but leaves a -/// recording-lifecycle event unrecoverable after a transient provider -/// error. This is the entry point the outbox worker drives. -pub async fn send_message_to_channel( +fn severity_of(id: EventId) -> Severity { shared::model::notification::registry::default_severity(id) } + +/// Render `event` for `channel`, falling back to the built-in body. +async fn render_for<'a>( app_config: &Arc, client: &reqwest::Client, - content: &MessageContent, - channel: MessagingChannel, -) -> ChannelOutcome { + channel: &Channel, + event: &'a NotificationEvent, +) -> RenderedMessage<'a> { + let capabilities = channel.capabilities(); + let mut templated = false; + let mut body = event.body.clone(); + + if capabilities.supports_templates { + if let Some(template) = channel.template_for(event.id) { + if let Some(rendered) = render::render(app_config, client, template, event).await { + body = rendered; + templated = true; + } + } + } + + if let Some(limit) = capabilities.max_body_bytes { + if body.len() > limit { + // Truncate on a character boundary, never mid-codepoint. + let mut end = limit; + while end > 0 && !body.is_char_boundary(end) { + end -= 1; + } + body.truncate(end); + } + } + + RenderedMessage { event, body, templated } +} + +/// Send `event` to exactly one channel, by its stable id. +/// +/// This is the entry point the outbox worker drives: it retries per +/// channel, so a message that reached Telegram but not Discord is re-sent +/// only to Discord. +pub async fn send_event_to_channel( + app_config: &Arc, + client: &reqwest::Client, + event: &NotificationEvent, + channel_id: &str, +) -> Delivery { let cfg = app_config.config.load(); - let messaging = cfg.messaging.as_ref()?; - if !is_enabled(content.kind(), messaging) { + let Some(messaging) = cfg.messaging.as_ref() else { + return Delivery::Skipped; + }; + if !is_enabled(event.id, messaging) { + return Delivery::Skipped; + } + let Some(channel) = channels::channels(app_config, messaging).into_iter().find(|c| c.id() == channel_id) else { + // The channel was removed from the config between enqueue and + // delivery. Nothing to do, and nothing to retry. + return Delivery::Skipped; + }; + if !channel.wants(event.id, event.severity) { + return Delivery::Skipped; + } + let routing = channel.routing(); + if let Some(reason) = + rate_limit::admit(channel.id(), event.dedup_key.as_deref(), routing.dedup_window, routing.max_per_hour) + { + return suppression_outcome(&channel, event, reason); + } + let msg = render_for(app_config, client, &channel, event).await; + channel.send(&msg).await +} + +/// Turn a suppression decision into a delivery outcome. +/// +/// A rate-limit ceiling reports itself once so the resulting silence is +/// distinguishable from a notifier that has died. +fn suppression_outcome(channel: &Channel, event: &NotificationEvent, reason: rate_limit::Suppression) -> Delivery { + match reason { + rate_limit::Suppression::Duplicate => { + debug!("Notification {} suppressed on {} as a duplicate", event.id, channel.id()); + Delivery::Skipped + } + rate_limit::Suppression::RateLimited => Delivery::Skipped, + rate_limit::Suppression::RateLimitReached => { + error!( + target: "notification::audit", + "notification_rate_limit_reached: channel={} event={} - further notifications suppressed this hour", + channel.id(), event.id + ); + Delivery::Skipped + } + } +} + +/// Minutes since local midnight, for quiet-hours evaluation. +fn local_minutes_now() -> u16 { + use chrono::Timelike; + let now = chrono::Local::now(); + u16::try_from(now.hour() * 60 + now.minute()).unwrap_or(0) +} + +/// How long delivery to `channel` should be deferred for quiet hours. +/// +/// Quiet hours defer rather than drop: an overnight outage nobody hears +/// about afterwards is worse than one that arrives late. +#[must_use] +pub fn quiet_hours_defer(channel: &Channel) -> Option { + let window = channel.routing().quiet_hours?; + let minutes = local_minutes_now(); + if !window.contains(minutes) { return None; } - match channel { - MessagingChannel::Telegram => send_telegram_message(app_config, client, content, messaging).await, - MessagingChannel::Rest => send_rest_message(app_config, client, content, messaging).await, - MessagingChannel::Pushover => send_pushover_message(app_config, client, content, messaging).await, - MessagingChannel::Discord => send_discord_message(app_config, client, content, messaging).await, - } + Some(std::time::Duration::from_secs(u64::from(window.minutes_until_end(minutes)) * 60)) } -/// Default fallback string for a disk alert when no template is configured. -fn default_disk_alert_text(alert: &DiskAlert) -> String { - format!( - "Disk usage {}: {:.1}% used ({} of {}), {} free.", - alert.level, - alert.percent, - human_readable_byte_size(alert.used_bytes), - human_readable_byte_size(alert.total_bytes), - human_readable_byte_size(alert.free_bytes), - ) -} - -static HANDLEBARS: LazyLock = LazyLock::new(|| { - let mut h = Handlebars::new(); - h.register_helper( - "json_escape", - Box::new( - |h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output| -> HelperResult { - let param = h.param(0).and_then(|v| v.value().as_str()).unwrap_or(""); - let escaped = serde_json::to_string(param).unwrap_or_else(|_| String::new()); - if escaped.len() >= 2 { - out.write(&escaped[1..escaped.len() - 1])?; - } - Ok(()) - }, - ), - ); - h -}); - -async fn render_template( - app_config: &Arc, - http_client: &reqwest::Client, - template: Option<&str>, - content: &MessageContent, -) -> String { - let timestamp = Utc::now().to_rfc3339(); - let kind = content.kind().to_string(); - - let mut template_context = TemplateContext { - kind, - timestamp, - message: None, - stats: None, - watch: None, - processing: None, - disk: None, - recording: None, - flat_stats: None, - }; - - match content { - MessageContent::Info(msg) | MessageContent::Error(msg) => { - template_context.message = Some(msg); - } - MessageContent::Watch(changes) => { - template_context.watch = Some(changes); - } - MessageContent::ProcessingStats(stats) => { - template_context.processing = Some(stats.clone()); - if let Some(stats) = &stats.stats { - template_context.stats = Some(stats); - if let Some(first_source) = stats.first() { - if let Some(first_input) = first_source.inputs.first() { - template_context.flat_stats = Some(first_input.clone()); - } - } - } - if let Some(errors) = &stats.errors { - template_context.message = Some(errors); - } - } - MessageContent::DiskAlert(alert) => { - template_context.disk = Some(alert); - } - MessageContent::RecordingLifecycle(recording) => { - template_context.recording = Some(recording); - template_context.message = Some(match recording.event { - MsgKind::RecordingStarted => "Recording started", - MsgKind::RecordingCompleted => "Recording completed", - MsgKind::RecordingFailed => "Recording failed", - _ => "Recording lifecycle event", - }); - } - } - - match template { - Some(template_content_or_uri) => { - let t = resolve_template(app_config, http_client, template_content_or_uri).await; - - match HANDLEBARS.render_template(&t, &template_context) { - Ok(rendered) => rendered, - Err(e) => { - error!("Failed to render template: {e}"); - default_text_for(content) - } - } - } - None => default_text_for(content), - } -} - -fn default_text_for(content: &MessageContent) -> String { - match content { - MessageContent::Info(s) | MessageContent::Error(s) => s.clone(), - MessageContent::Watch(w) => serde_json::to_string(w).unwrap_or_default(), - MessageContent::ProcessingStats(ps) => serde_json::to_string(ps).unwrap_or_default(), - MessageContent::DiskAlert(alert) => default_disk_alert_text(alert), - MessageContent::RecordingLifecycle(recording) => default_recording_lifecycle_text(recording), - } -} - -fn default_recording_lifecycle_text(recording: &tuliprox_core::model::RecordingLifecycleMessage) -> String { - let label = match recording.event { - MsgKind::RecordingStarted => "Recording started", - MsgKind::RecordingCompleted => "Recording completed", - MsgKind::RecordingFailed => "Recording failed", - _ => "Recording lifecycle event", - }; - let title = recording.programme_title.as_deref().unwrap_or("Untitled"); - let channel = recording.channel.as_deref().unwrap_or("unknown channel"); - match recording.failure_reason.as_deref() { - Some(reason) => format!("{label}: {title} on {channel} ({reason})"), - None => format!("{label}: {title} on {channel}"), - } -} - -async fn send_rest_message( - app_config: &Arc, - client: &reqwest::Client, - content: &MessageContent, - messaging: &MessagingConfig, -) -> ChannelOutcome { - if let Some(rest) = &messaging.rest { - let kind = content.kind(); - let template = rest.templates.get(&kind).map(String::as_str); - let body = render_template(app_config, client, template, content).await; - let method = Method::from_str(&rest.method).unwrap_or(Method::POST); - - let mut rb = client.request(method, &rest.url); - - let has_content_type = rest.headers.keys().any(|k| k.eq_ignore_ascii_case("content-type")); - if !has_content_type { - rb = rb.header(header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()); - } - - for (key, value) in &rest.headers { - rb = rb.header(key, value); - } - - match rb.body(body).send().await { - Ok(response) => { - if response.status().is_success() { - debug!("Message sent successfully to rest api"); - Some(true) - } else { - error!("Failed to send message to rest api, status code {}", response.status()); - Some(false) - } - } - Err(e) => { - error!("Message wasn't sent to rest api because of: {e}"); - Some(false) - } - } - } else { - None - } -} - -async fn send_discord_message( - app_config: &Arc, - client: &reqwest::Client, - content: &MessageContent, - messaging: &MessagingConfig, -) -> ChannelOutcome { - if let Some(discord) = &messaging.discord { - let kind = content.kind(); - let template = discord.templates.get(&kind).map(String::as_str); - - let body = if let Some(templ) = template { - render_template(app_config, client, Some(templ), content).await - } else { - // Default json formatting - let msg_str = default_text_for(content); - json!({ "content": msg_str }).to_string() - }; - - match client - .post(&discord.url) - .header(header::CONTENT_TYPE, mime::APPLICATION_JSON.to_string()) - .body(body) - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - debug!("Message sent successfully to Discord"); - Some(true) - } else { - error!("Failed to send message to Discord, status code {}", response.status()); - Some(false) - } - } - Err(e) => { - error!("Message wasn't sent to Discord because of: {e}"); - Some(false) - } - } - } else { - None - } -} - -async fn send_telegram_message( - app_config: &Arc, - client: &reqwest::Client, - content: &MessageContent, - messaging: &MessagingConfig, -) -> ChannelOutcome { - if let Some(telegram) = &messaging.telegram { - let kind = content.kind(); - let template = telegram.templates.get(&kind).map(String::as_str); - let has_template = template.is_some(); - - let msg = if let Some(templ) = template { - render_template(app_config, client, Some(templ), content).await - } else { - let serialized; - match content { - MessageContent::Info(s) | MessageContent::Error(s) => s.clone(), - MessageContent::Watch(s) => { - serialized = serde_json::to_string_pretty(s).unwrap_or_default(); - serialized - } - MessageContent::ProcessingStats(ps) => { - serialized = serde_json::to_string_pretty(ps).unwrap_or_default(); - serialized - } - MessageContent::DiskAlert(alert) => default_disk_alert_text(alert), - MessageContent::RecordingLifecycle(recording) => default_recording_lifecycle_text(recording), - } - }; - - let (message, options) = { - if telegram.markdown { - if let Ok(md) = json_str_to_markdown(&msg) { - (Cow::Owned(md), Some(SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 })) - } else { - // Keep template markdown as-is, but escape plain text to avoid MarkdownV2 parse errors. - if has_template { - (Cow::Borrowed(&msg), Some(SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 })) - } else { - ( - Cow::Owned(escape_markdown_v2(&msg)), - Some(SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 }), - ) - } - } - } else { - (Cow::Borrowed(&msg), None) - } - }; - - // A single failed chat id fails the channel: the outbox retries - // the whole channel, which is the coarsest granularity the - // Telegram config exposes. - let mut all_delivered = true; - for chat_id in &telegram.chat_ids { - let bot = telegram_create_instance(&telegram.bot_token, chat_id); - let send_result = telegram_send_message(app_config, client, &bot, &message, options.as_ref()).await; - let mut delivered = send_result.delivered; - if telegram.markdown && has_template && send_result.parse_error && !delivered { - // Template output can include dynamic fields that break MarkdownV2. Retry once escaped. - let escaped = escape_markdown_v2(&msg); - let escaped_options = SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2 }; - delivered = - telegram_send_message(app_config, client, &bot, &escaped, Some(&escaped_options)).await.delivered; - } - all_delivered &= delivered; - } - Some(all_delivered) - } else { - None - } -} - -async fn send_pushover_message( - _app_config: &Arc, - client: &reqwest::Client, - content: &MessageContent, - messaging: &MessagingConfig, -) -> ChannelOutcome { - if let Some(pushover) = &messaging.pushover { - let msg = default_text_for(content); - - let encoded_message: String = url::form_urlencoded::Serializer::new(String::new()) - .append_pair("token", pushover.token.as_str()) - .append_pair("user", pushover.user.as_str()) - .append_pair("message", &msg) - .finish(); - match client - .post(&pushover.url) - .header(header::CONTENT_TYPE, mime::APPLICATION_WWW_FORM_URLENCODED.to_string()) - .body(encoded_message) - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - debug!("Text message sent successfully to PUSHOVER, status code {}", response.status()); - Some(true) - } else { - error!("Failed to send text message to PUSHOVER, status code {}", response.status()); - Some(false) - } - } - Err(e) => { - error!("Text message wasn't sent to PUSHOVER api because of: {e}"); - Some(false) - } - } - } else { - None - } -} - -async fn dispatch_send_message(app_config: &Arc, client: &reqwest::Client, content: MessageContent) { +/// Notify. +/// +/// Hands the event to the durable outbox where one is installed, so a +/// transient provider error is retried instead of being logged and lost - +/// which is what happened to every non-recording notification before the +/// outbox was promoted out of the recording supervisor. Falls back to a +/// direct best-effort send when the outbox is absent (unit tests, early +/// startup) or full. +pub async fn send_event(app_config: &Arc, client: &reqwest::Client, event: NotificationEvent) { let cfg = app_config.config.load(); - let msg_cfg = cfg.messaging.as_ref(); - if let Some(messaging) = msg_cfg { - let kind = content.kind(); - if is_enabled(kind, messaging) { - let _ = tokio::join!( - send_telegram_message(app_config, client, &content, messaging), - send_rest_message(app_config, client, &content, messaging), - send_pushover_message(app_config, client, &content, messaging), - send_discord_message(app_config, client, &content, messaging) - ); + let Some(messaging) = cfg.messaging.as_ref() else { + return; + }; + if !is_enabled(event.id, messaging) { + return; + } + let event = match outbox::notification_outbox() { + Some(outbox) => match outbox.enqueue(event) { + None => return, + Some(rejected) => rejected, + }, + None => event, + }; + let set: ChannelSet = channels::channels(app_config, messaging); + dispatch(app_config, client, &event, &set).await; +} + +/// Deliver to every channel in `set` at once. +/// +/// Concurrent rather than sequential: one slow provider must not delay the +/// others. +async fn dispatch(app_config: &Arc, client: &reqwest::Client, event: &NotificationEvent, set: &ChannelSet) { + let sends = set.iter().filter(|channel| channel.wants(event.id, event.severity)).map(|channel| async move { + let routing = channel.routing(); + if let Some(reason) = + rate_limit::admit(channel.id(), event.dedup_key.as_deref(), routing.dedup_window, routing.max_per_hour) + { + return (channel.id(), suppression_outcome(channel, event, reason)); + } + let msg = render_for(app_config, client, channel, event).await; + let outcome = channel.send(&msg).await; + (channel.id(), outcome) + }); + for (id, outcome) in futures::future::join_all(sends).await { + match outcome { + Delivery::Delivered | Delivery::Skipped => debug!("Notification {} handled by {id}", event.id), + Delivery::Retry { reason, .. } => error!("Notification {} to {id} failed: {reason}", event.id), + Delivery::Permanent { reason } => { + error!("Notification {} to {id} permanently rejected: {reason}", event.id); + } } } } +/// Send a legacy [`MessageContent`], lifted into the envelope. +/// +/// Kept so the existing emitters need no change. pub async fn send_message(app_config: &Arc, client: &reqwest::Client, content: MessageContent) { - dispatch_send_message(app_config, client, content).await; + send_event(app_config, client, NotificationEvent::from_content(&content)).await; } -async fn resolve_template<'a>( - app_config: &'a Arc, - http_client: &'a reqwest::Client, - template: &'a str, -) -> Cow<'a, str> { - let url = template.to_string(); - - let input_source = InputSource { - name: "Template".intern(), - url, - provider: None, - username: None, - password: None, - method: InputFetchMethod::GET, - headers: HashMap::default(), - }; - if let Ok((content, _response_url)) = - download_text_content(app_config, http_client, &input_source, None, None, false).await - { - Cow::Owned(content) - } else { - Cow::Borrowed(template) +/// Legacy shim: the channels configured for a [`MsgKind`]. +pub fn configured_channels_for_kind(app_config: &Arc, kind: MsgKind) -> Vec { + match EventId::from_wire(kind.wire_name()) { + Some(id) => configured_channels(app_config, id), + None => Vec::new(), } } +/// One channel's result from a test send. +pub struct TestOutcome { + pub channel: String, + /// `delivered`, `skipped`, `retry`, `permanent`, or `preview`. + pub outcome: String, + pub reason: Option, + /// Exactly what the channel was asked to send. + pub rendered: String, + pub templated: bool, +} + +/// A representative event for `id`, for testing a channel or a template. +/// +/// Carries a plausible payload so a template that walks `event.fields` +/// renders something recognisable rather than blank. +#[must_use] +pub fn test_event(id: EventId) -> NotificationEvent { + let description = shared::model::notification::registry::describe(id) + .map_or("Test notification", |descriptor| descriptor.description); + NotificationEvent::new( + id, + format!("Test: {id}"), + format!("{description}\n\nThis is a test notification from tuliprox."), + ) + .with_fields(&serde_json::json!({ "test": true })) +} + +/// Render `event` for each requested channel, and send unless `preview`. +/// +/// Bypasses `notify_on` and the suppression window deliberately: the +/// operator asked for this one explicitly, and a test that silently does +/// nothing because of a dedup window would be worse than useless. +pub async fn render_and_send_test( + app_config: &Arc, + client: &reqwest::Client, + event: &NotificationEvent, + only_channel: Option<&str>, + preview: bool, +) -> Vec { + let cfg = app_config.config.load(); + let Some(messaging) = cfg.messaging.as_ref() else { + return Vec::new(); + }; + let set = channels::channels(app_config, messaging); + let mut results = Vec::with_capacity(set.len()); + for channel in set.iter().filter(|c| only_channel.is_none_or(|want| c.id() == want)) { + let msg = render_for(app_config, client, channel, event).await; + if preview { + results.push(TestOutcome { + channel: channel.id().to_string(), + outcome: "preview".to_string(), + reason: None, + rendered: msg.body.clone(), + templated: msg.templated, + }); + continue; + } + let (outcome, reason) = match channel.send(&msg).await { + Delivery::Delivered => ("delivered".to_string(), None), + Delivery::Skipped => ("skipped".to_string(), None), + Delivery::Retry { reason, .. } => ("retry".to_string(), Some(reason)), + Delivery::Permanent { reason } => ("permanent".to_string(), Some(reason)), + }; + results.push(TestOutcome { + channel: channel.id().to_string(), + outcome, + reason, + rendered: msg.body.clone(), + templated: msg.templated, + }); + } + results +} #[cfg(test)] mod tests { @@ -482,6 +315,25 @@ mod tests { utils::FileLockManager, }; + /// Adapter onto the new pipeline. + /// + /// These tests predate the channel/envelope refactor and assert that the + /// documented Discord and Telegram templates still render exactly as + /// before, so they are kept pointed at the real renderer rather than + /// rewritten. + async fn render_template( + app_cfg: &Arc, + client: &reqwest::Client, + template: Option<&str>, + content: &MessageContent, + ) -> String { + let event = NotificationEvent::from_content(content); + match template { + Some(t) => render::render(app_cfg, client, t, &event).await.unwrap_or_else(|| event.body.clone()), + None => event.body.clone(), + } + } + fn create_app_config() -> Arc { Arc::new(AppConfig { config: Arc::new(ArcSwap::default()), diff --git a/backend/messaging/src/outbox.rs b/backend/messaging/src/outbox.rs new file mode 100644 index 000000000..e14697c93 --- /dev/null +++ b/backend/messaging/src/outbox.rs @@ -0,0 +1,414 @@ +//! The notification outbox. +//! +//! This used to live in the recording supervisor and serve recordings only: +//! it was gated on the recording config and wrote +//! `recording_notification_outbox.json`. Everything else - playlist stats, +//! watch changes, disk alerts, provider account warnings - called +//! `send_message`, which fanned out and discarded every outcome with +//! `let _ =`. A transient 502 lost those permanently and logged one line. +//! +//! Now every notification goes through here: persisted before the first +//! attempt, retried per channel with capped exponential backoff, and +//! dead-lettered after `max_attempts`. +//! +//! Per-channel retry is what makes delivery at-most-once *per channel*: a +//! message that reached Telegram but not Discord is retried only against +//! Discord, so a retry can never duplicate a delivered message. + +use crate::{channel::Delivery, configured_channels, send_event_to_channel}; +use log::{debug, error, info}; +use shared::model::{EventMessage, EventSink, NotificationDeadLetter}; +use std::{ + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicI64, AtomicU64, Ordering}, + Arc, OnceLock, + }, + time::Duration, +}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tuliprox_core::model::{AppConfig, NotificationEvent, RecordingNotificationConfig}; + +/// Outbox file name under `storage_dir`. +const OUTBOX_FILE: &str = "notification_outbox.json"; + +/// The name this file had while the outbox was recording-only. Read once at +/// startup so pending recording notifications survive the upgrade instead +/// of being silently dropped. +const LEGACY_OUTBOX_FILE: &str = "recording_notification_outbox.json"; + +/// Delivery counters, surfaced on the status endpoint. +#[derive(Debug, Default)] +pub struct NotificationHealth { + pub outbox_depth: AtomicI64, + pub dead_lettered: AtomicU64, + pub delivered: AtomicU64, + pub failed: AtomicU64, + pub last_drain_at: AtomicI64, +} + +static HEALTH: OnceLock = OnceLock::new(); + +/// Process-wide delivery counters. +pub fn health() -> &'static NotificationHealth { HEALTH.get_or_init(NotificationHealth::default) } + +/// One queued notification. +/// +/// `pending` holds the channels that have *not* accepted it yet, which is +/// what keeps a retry from re-delivering to a channel that already took it. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct OutboxEntry { + id: u64, + event: NotificationEvent, + /// Stable channel ids. Strings rather than an enum so an outbox written + /// by a build that knows a newer channel still deserializes here. + #[serde(default)] + pending: Vec, + attempts: u32, + enqueued_at: i64, + next_attempt_at: i64, +} + +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +struct OutboxFile { + #[serde(default)] + next_id: u64, + #[serde(default)] + entries: Vec, +} + +/// Sender side of the outbox. Cloneable and cheap; `enqueue` never blocks. +#[derive(Debug, Clone)] +pub struct NotificationOutbox { + sender: mpsc::Sender, +} + +impl NotificationOutbox { + /// Hand a notification to the outbox worker. + /// + /// Never blocks and never awaits: a recording, or a playlist update, + /// must not stall because a messaging provider is slow. Returns the + /// event back when the outbox cannot take it so the caller can fall + /// back to a direct best-effort send. + pub fn enqueue(&self, event: NotificationEvent) -> Option { + match self.sender.try_send(event) { + Ok(()) => None, + Err(mpsc::error::TrySendError::Full(event)) => { + error!("Notification outbox is full; falling back to a direct send"); + Some(event) + } + Err(mpsc::error::TrySendError::Closed(event)) => Some(event), + } + } +} + +static OUTBOX: OnceLock = OnceLock::new(); + +/// The installed outbox, if the worker has started. Callers that find +/// `None` (unit tests, early startup) fall back to a direct send. +pub fn notification_outbox() -> Option<&'static NotificationOutbox> { OUTBOX.get() } + +/// Start the outbox worker. Idempotent: only the first call installs one. +pub fn spawn_notification_outbox( + app_config: &Arc, + http_client: reqwest::Client, + config: RecordingNotificationConfig, + cancel_token: &CancellationToken, + events: E, +) { + let (sender, receiver) = mpsc::channel(config.outbox_buffer.max(1)); + if OUTBOX.set(NotificationOutbox { sender }).is_err() { + debug!("Notification outbox already installed"); + return; + } + let app_config = Arc::clone(app_config); + let cancel_token = cancel_token.clone(); + tokio::spawn(async move { + run(app_config, http_client, config, receiver, cancel_token, events).await; + }); +} + +fn now_ts() -> i64 { chrono::Utc::now().timestamp() } + +fn outbox_path(app_config: &Arc) -> PathBuf { + PathBuf::from(app_config.config.load().storage_dir.as_str()).join(OUTBOX_FILE) +} + +fn legacy_outbox_path(app_config: &Arc) -> PathBuf { + PathBuf::from(app_config.config.load().storage_dir.as_str()).join(LEGACY_OUTBOX_FILE) +} + +async fn run( + app_config: Arc, + http_client: reqwest::Client, + config: RecordingNotificationConfig, + mut receiver: mpsc::Receiver, + cancel_token: CancellationToken, + events: E, +) { + let path = outbox_path(&app_config); + let mut file = load(&path, &legacy_outbox_path(&app_config)).await; + if !file.entries.is_empty() { + info!("Notification outbox recovered {} undelivered notification(s)", file.entries.len()); + } + // Once every sender is gone `recv()` completes instantly and forever, so + // the loop must stop polling it or it would spin. + let mut senders_gone = false; + loop { + let sleep_for = next_wakeup(&file, now_ts()); + let mut inbound = None; + if senders_gone { + if file.entries.is_empty() { + break; + } + tokio::select! { + () = cancel_token.cancelled() => break, + () = tokio::time::sleep(sleep_for) => {} + } + } else { + tokio::select! { + () = cancel_token.cancelled() => break, + message = receiver.recv() => { + match message { + Some(event) => inbound = Some(event), + None => senders_gone = true, + } + } + () = tokio::time::sleep(sleep_for) => {} + } + } + if let Some(event) = inbound { + admit(&mut file, event); + // Drain whatever else is queued so a burst costs one persist, + // not one per event. + while let Ok(event) = receiver.try_recv() { + admit(&mut file, event); + } + persist(&path, &file).await; + } + if drain_due(&app_config, &http_client, &config, &mut file, &events).await { + persist(&path, &file).await; + } + health().outbox_depth.store(i64::try_from(file.entries.len()).unwrap_or(i64::MAX), Ordering::Relaxed); + } + debug!("Notification outbox stopped"); +} + +/// How long to sleep before the next delivery attempt is due. +fn next_wakeup(file: &OutboxFile, now: i64) -> Duration { + file.entries + .iter() + .map(|entry| entry.next_attempt_at.saturating_sub(now).max(0)) + .min() + .map_or(Duration::from_hours(1), |secs| Duration::from_secs(u64::try_from(secs).unwrap_or(0))) +} + +/// Accept a new notification into the outbox. +fn admit(file: &mut OutboxFile, event: NotificationEvent) { + let now = now_ts(); + file.next_id = file.next_id.wrapping_add(1); + file.entries.push(OutboxEntry { + id: file.next_id, + event, + // Resolved at attempt time, not here, so a config reload between + // enqueue and delivery is honoured. + pending: Vec::new(), + attempts: 0, + enqueued_at: now, + next_attempt_at: now, + }); +} + +/// Attempt every due entry. Returns `true` when the outbox changed. +async fn drain_due( + app_config: &Arc, + client: &reqwest::Client, + config: &RecordingNotificationConfig, + file: &mut OutboxFile, + events: &E, +) -> bool { + let now = now_ts(); + if !file.entries.iter().any(|entry| entry.next_attempt_at <= now) { + return false; + } + health().last_drain_at.store(now, Ordering::Relaxed); + let mut changed = false; + let mut keep: Vec = Vec::with_capacity(file.entries.len()); + for mut entry in std::mem::take(&mut file.entries) { + if entry.next_attempt_at > now { + keep.push(entry); + continue; + } + changed = true; + if entry.pending.is_empty() && entry.attempts == 0 { + entry.pending = configured_channels(app_config, entry.event.id); + } + if entry.pending.is_empty() { + // No channel wants this event. Nothing to deliver, nothing to + // retry. + continue; + } + // Quiet hours defer, they do not drop: hold the whole entry until + // the window closes rather than losing an overnight outage. + if let Some(defer) = quiet_hours_defer_for(app_config, &entry.pending) { + entry.next_attempt_at = now.saturating_add(i64::try_from(defer.as_secs()).unwrap_or(0).max(60)); + debug!("Notification {} deferred {}s for quiet hours", entry.event.id, defer.as_secs()); + keep.push(entry); + continue; + } + entry.attempts = entry.attempts.saturating_add(1); + + // Concurrent, not sequential: one webhook host that accepts the + // connection and never answers must not stall every other channel + // or every other pending notification. + let attempts = std::mem::take(&mut entry.pending).into_iter().map(|channel_id| { + let event = &entry.event; + async move { + let outcome = send_event_to_channel(app_config, client, event, &channel_id).await; + (channel_id, outcome) + } + }); + let results = futures::future::join_all(attempts).await; + + let mut still_pending = Vec::new(); + let mut provider_delay: Option = None; + for (channel_id, outcome) in results { + match outcome { + Delivery::Delivered => { + health().delivered.fetch_add(1, Ordering::Relaxed); + } + // The channel is no longer configured or does not want this + // event: not a failure, and must not be retried. + Delivery::Skipped => {} + Delivery::Permanent { reason } => { + // Retrying cannot help. Dead-letter this channel now + // instead of burning every remaining attempt on it. + health().failed.fetch_add(1, Ordering::Relaxed); + health().dead_lettered.fetch_add(1, Ordering::Relaxed); + error!( + target: "notification::audit", + "notification_permanently_rejected: event={} channel={channel_id} reason={reason}", + entry.event.id + ); + } + Delivery::Retry { reason, after } => { + health().failed.fetch_add(1, Ordering::Relaxed); + debug!("Notification {} to {channel_id} will be retried: {reason}", entry.event.id); + if let Some(after) = after { + provider_delay = Some(provider_delay.map_or(after, |current: Duration| current.max(after))); + } + still_pending.push(channel_id); + } + } + } + + if still_pending.is_empty() { + debug!("Notification {} settled after {} attempt(s)", entry.id, entry.attempts); + continue; + } + entry.pending = still_pending; + if entry.attempts >= config.max_attempts { + health().dead_lettered.fetch_add(1, Ordering::Relaxed); + error!( + target: "notification::audit", + "notification_dead_lettered: event={} attempts={} channels={:?} enqueued_at={}", + entry.event.id, entry.attempts, entry.pending, entry.enqueued_at + ); + // The bus, not the outbox. Enqueueing a "delivery failed" notice + // against the channels that just failed to deliver is how this + // loops; the notification bridge drops this kind for that reason. + events.emit(EventMessage::NotificationDeadLettered(NotificationDeadLetter::new( + entry.event.id, + entry.attempts, + entry.pending.clone(), + entry.enqueued_at, + ))); + continue; + } + // A provider that named a `Retry-After` wins over our own backoff: + // retrying earlier just walks back into the same rate limit. + let backoff = backoff_secs(config, entry.attempts); + let delay = provider_delay + .map_or(backoff, |after| i64::try_from(after.as_secs()).unwrap_or(backoff).max(backoff.min(1))); + entry.next_attempt_at = now.saturating_add(delay); + keep.push(entry); + } + file.entries = keep; + changed +} + +/// Capped exponential backoff: `initial * 2^(attempts-1)`, clamped. +/// The shortest quiet-hours deferral across the still-pending channels. +/// +/// `None` when at least one pending channel is awake, so a notification is +/// never held back by a channel that is not the one blocking it. +fn quiet_hours_defer_for(app_config: &Arc, pending: &[String]) -> Option { + let cfg = app_config.config.load(); + let messaging = cfg.messaging.as_ref()?; + let set = crate::channels::channels(app_config, messaging); + let mut shortest: Option = None; + for channel_id in pending { + let channel = set.iter().find(|c| c.id() == channel_id)?; + let defer = crate::quiet_hours_defer(channel)?; + shortest = Some(shortest.map_or(defer, |current: Duration| current.min(defer))); + } + shortest +} + +fn backoff_secs(config: &RecordingNotificationConfig, attempts: u32) -> i64 { + let shift = attempts.saturating_sub(1).min(16); + let delay = config.backoff_initial_secs.saturating_mul(1u64 << shift).min(config.backoff_max_secs); + i64::try_from(delay).unwrap_or(i64::MAX) +} + +async fn read_outbox_file(path: &Path) -> Option { + match tokio::fs::read(path).await { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(file) => Some(file), + Err(err) => { + // A corrupt outbox must not stop the server. Losing + // undelivered notifications is the lesser failure. + error!("Notification outbox at {} is unreadable, ignoring it: {err}", path.display()); + None + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => { + error!("Could not read the notification outbox at {}: {err}", path.display()); + None + } + } +} + +/// Load the outbox, adopting anything left in the pre-rename file. +async fn load(path: &Path, legacy_path: &Path) -> OutboxFile { + let mut file = read_outbox_file(path).await.unwrap_or_default(); + if let Some(legacy) = read_outbox_file(legacy_path).await { + if !legacy.entries.is_empty() { + info!( + "Adopting {} notification(s) from the pre-rename outbox at {}", + legacy.entries.len(), + legacy_path.display() + ); + file.next_id = file.next_id.max(legacy.next_id); + file.entries.extend(legacy.entries); + } + // Remove it either way, so the adoption happens exactly once. + if let Err(err) = tokio::fs::remove_file(legacy_path).await { + error!("Could not remove the pre-rename outbox at {}: {err}", legacy_path.display()); + } + } + file +} + +async fn persist(path: &Path, file: &OutboxFile) { + match serde_json::to_vec_pretty(file) { + Ok(bytes) => { + if let Err(err) = tuliprox_core::utils::atomic_json_store::write_json_atomic(path, &bytes).await { + error!("Could not persist the notification outbox: {err}"); + } + } + Err(err) => error!("Could not serialize the notification outbox: {err}"), + } +} diff --git a/backend/messaging/src/rate_limit.rs b/backend/messaging/src/rate_limit.rs new file mode 100644 index 000000000..dcda28444 --- /dev/null +++ b/backend/messaging/src/rate_limit.rs @@ -0,0 +1,192 @@ +//! Per-channel suppression and rate limiting. +//! +//! Notification volume was low enough that none of this mattered: six +//! emitters, none of them chatty. Bridging the internal event bus and +//! adding provider/stream events raises that by an order of magnitude, and +//! the providers have hard limits - Discord webhooks allow about 5 requests +//! per 2s, Telegram 20 messages per chat per minute. Shaping traffic before +//! the `429` beats discovering it afterwards. +//! +//! State lives here rather than on the channel because it must survive +//! config reloads and channel rebuilds. + +use std::{ + collections::HashMap, + sync::{OnceLock, RwLock}, + time::{Duration, Instant}, +}; + +#[derive(Debug, Default)] +struct ChannelState { + /// Recent `dedup_key`s and when they were last sent. + seen: HashMap, + /// Send timestamps inside the current hour window, for `max_per_hour`. + sent_at: Vec, + /// Whether the hourly ceiling has already been reported. + ceiling_reported: bool, +} + +impl ChannelState { + /// Record a send at `now` and decide whether it is allowed. + /// + /// Pure in `now` so the windows are testable without sleeping. + fn admit( + &mut self, + now: Instant, + dedup_key: Option<&str>, + dedup_window: Option, + max_per_hour: Option, + ) -> Option { + // Suppression by dedup key. + if let (Some(key), Some(window)) = (dedup_key, dedup_window) { + self.seen.retain(|_, last| now.duration_since(*last) < window); + if self.seen.contains_key(key) { + return Some(Suppression::Duplicate); + } + } + + // Hourly ceiling. + if let Some(max) = max_per_hour { + let hour = Duration::from_hours(1); + self.sent_at.retain(|at| now.duration_since(*at) < hour); + if self.sent_at.len() >= max as usize { + // Report the ceiling exactly once per window, so the operator + // can tell suppression from a notifier that has died. + if self.ceiling_reported { + return Some(Suppression::RateLimited); + } + self.ceiling_reported = true; + return Some(Suppression::RateLimitReached); + } + self.ceiling_reported = false; + self.sent_at.push(now); + } + + if let (Some(key), Some(_)) = (dedup_key, dedup_window) { + self.seen.insert(key.to_string(), now); + } + None + } +} + +static STATE: OnceLock>> = OnceLock::new(); + +fn state() -> &'static RwLock> { STATE.get_or_init(|| RwLock::new(HashMap::new())) } + +/// Forget all suppression and rate-limit state. Called on config reload. +pub fn reset() { + if let Ok(mut guard) = state().write() { + guard.clear(); + } +} + +/// Why a notification was not sent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Suppression { + /// An identical `dedup_key` was sent inside the window. + Duplicate, + /// The channel's hourly ceiling is reached. + RateLimited, + /// The channel's hourly ceiling was reached *just now* - the caller + /// should emit one "suppressing further notifications" message so the + /// silence is distinguishable from a broken notifier. + RateLimitReached, +} + +/// Record a send and decide whether it is allowed. +/// +/// Returns `None` when the notification should go out. +pub fn admit( + channel_id: &str, + dedup_key: Option<&str>, + dedup_window: Option, + max_per_hour: Option, +) -> Option { + let mut guard = state().write().ok()?; + guard.entry(channel_id.to_string()).or_default().admit(Instant::now(), dedup_key, dedup_window, max_per_hour) +} + +#[cfg(test)] +#[allow(clippy::unnecessary_wraps)] +mod tests { + use super::{ChannelState, Suppression}; + use std::time::{Duration, Instant}; + + const WINDOW: Duration = Duration::from_mins(1); + + fn win() -> Option { Some(WINDOW) } + + #[test] + fn a_repeated_dedup_key_inside_the_window_is_suppressed() { + let mut state = ChannelState::default(); + let now = Instant::now(); + assert_eq!(state.admit(now, Some("disk:critical"), win(), None), None); + assert_eq!(state.admit(now, Some("disk:critical"), win(), None), Some(Suppression::Duplicate)); + } + + #[test] + fn the_same_key_is_allowed_again_once_the_window_has_passed() { + let mut state = ChannelState::default(); + let now = Instant::now(); + assert_eq!(state.admit(now, Some("k"), win(), None), None); + let later = now + Duration::from_secs(61); + assert_eq!(state.admit(later, Some("k"), win(), None), None, "window did not expire"); + } + + #[test] + fn different_dedup_keys_do_not_suppress_each_other() { + let mut state = ChannelState::default(); + let now = Instant::now(); + assert_eq!(state.admit(now, Some("a"), win(), None), None); + assert_eq!(state.admit(now, Some("b"), win(), None), None); + } + + #[test] + fn an_event_with_no_dedup_key_is_never_suppressed_as_a_duplicate() { + let mut state = ChannelState::default(); + let now = Instant::now(); + for _ in 0..5 { + assert_eq!(state.admit(now, None, win(), None), None); + } + } + + #[test] + fn no_window_means_no_suppression() { + let mut state = ChannelState::default(); + let now = Instant::now(); + assert_eq!(state.admit(now, Some("k"), None, None), None); + assert_eq!(state.admit(now, Some("k"), None, None), None); + } + + #[test] + fn the_hourly_ceiling_reports_once_then_stays_quiet() { + let mut state = ChannelState::default(); + let now = Instant::now(); + assert_eq!(state.admit(now, None, None, Some(2)), None); + assert_eq!(state.admit(now, None, None, Some(2)), None); + // The third trips it, and says so exactly once so the operator can + // tell suppression from a dead notifier. + assert_eq!(state.admit(now, None, None, Some(2)), Some(Suppression::RateLimitReached)); + assert_eq!(state.admit(now, None, None, Some(2)), Some(Suppression::RateLimited)); + assert_eq!(state.admit(now, None, None, Some(2)), Some(Suppression::RateLimited)); + } + + #[test] + fn the_ceiling_clears_once_the_hour_window_rolls_off() { + let mut state = ChannelState::default(); + let now = Instant::now(); + assert_eq!(state.admit(now, None, None, Some(1)), None); + assert_eq!(state.admit(now, None, None, Some(1)), Some(Suppression::RateLimitReached)); + let later = now + Duration::from_hours(1) + Duration::from_secs(1); + assert_eq!(state.admit(later, None, None, Some(1)), None, "hour window did not roll off"); + } + + #[test] + fn no_ceiling_configured_means_unlimited() { + let mut state = ChannelState::default(); + let now = Instant::now(); + for _ in 0..100 { + assert_eq!(state.admit(now, None, None, None), None); + } + } +} diff --git a/backend/messaging/src/render.rs b/backend/messaging/src/render.rs new file mode 100644 index 000000000..9c5c206d8 --- /dev/null +++ b/backend/messaging/src/render.rs @@ -0,0 +1,444 @@ +//! Template resolution, caching and rendering. +//! +//! `resolve_template` used to wrap every template string in an +//! `InputSource` and call `download_text_content` - once per message, per +//! channel. A `file://` template was re-read from disk and an `http://` +//! template re-fetched over the network for every notification, and an +//! *inline* Handlebars string was pushed through a download attempt too, +//! because nothing distinguished the three cases. +//! +//! This module classifies the source once, caches what it resolves, and +//! keeps the compiled Handlebars template so a render is not a re-parse. + +use handlebars::{Context, Handlebars, Helper, HelperResult, Output, RenderContext}; +use log::{debug, error, warn}; +use shared::{ + model::{notification::registry, InputFetchMethod}, + utils::Internable, +}; +use std::{ + collections::HashMap, + sync::{Arc, LazyLock, RwLock}, + time::{Duration, Instant}, +}; +use tuliprox_core::{ + model::{AppConfig, InputSource, NotificationEvent}, + utils::request::download_text_content, +}; + +/// How long a resolved remote template is trusted before revalidating. +const REMOTE_TTL: Duration = Duration::from_mins(5); + +/// Where a configured template actually lives. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateSource { + /// The config value *is* the template body. + Inline, + /// A local file, re-read when its mtime moves. + File(String), + /// A remote document, revalidated on a TTL. + Url, +} + +impl TemplateSource { + /// Classify without touching the network or the disk. + /// + /// The old code could not do this, so an inline template paid for a + /// download attempt on every single send. + #[must_use] + pub fn classify(template: &str) -> Self { + let trimmed = template.trim_start(); + if let Some(path) = trimmed.strip_prefix("file://") { + return Self::File(path.to_string()); + } + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + return Self::Url; + } + Self::Inline + } +} + +#[derive(Debug)] +struct CacheEntry { + body: Arc, + fetched_at: Instant, + /// File mtime at fetch time, so a local edit is picked up immediately + /// rather than after the TTL. + mtime: Option, +} + +/// Resolved template bodies, keyed by the raw config value. +static CACHE: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); + +/// Compiled templates, keyed by the raw config value. +static REGISTRY: LazyLock>> = LazyLock::new(|| RwLock::new(new_handlebars())); + +fn new_handlebars() -> Handlebars<'static> { + let mut h = Handlebars::new(); + h.register_helper( + "json_escape", + Box::new( + |h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output| -> HelperResult { + let param = h.param(0).and_then(|v| v.value().as_str()).unwrap_or(""); + let escaped = serde_json::to_string(param).unwrap_or_default(); + if escaped.len() >= 2 { + out.write(&escaped[1..escaped.len() - 1])?; + } + Ok(()) + }, + ), + ); + h +} + +/// Drop every cached body and compiled template. +/// +/// Called on config reload so an edited template takes effect without a +/// restart. +pub fn invalidate_cache() { + if let Ok(mut cache) = CACHE.write() { + cache.clear(); + } + if let Ok(mut registry) = REGISTRY.write() { + *registry = new_handlebars(); + } +} + +fn file_mtime(path: &str) -> Option { std::fs::metadata(path).ok()?.modified().ok() } + +/// Resolve a template value to its body, using the cache where possible. +async fn resolve(app_config: &Arc, client: &reqwest::Client, template: &str) -> Arc { + let source = TemplateSource::classify(template); + if source == TemplateSource::Inline { + // Nothing to fetch. This is the case the old code paid a download + // attempt for. + return Arc::from(template); + } + + let current_mtime = match &source { + TemplateSource::File(path) => file_mtime(path), + _ => None, + }; + + if let Ok(cache) = CACHE.read() { + if let Some(entry) = cache.get(template) { + let fresh = match &source { + // A local edit wins over the TTL. + TemplateSource::File(_) => entry.mtime == current_mtime, + _ => entry.fetched_at.elapsed() < REMOTE_TTL, + }; + if fresh { + return Arc::clone(&entry.body); + } + } + } + + let input_source = InputSource { + name: "Template".intern(), + url: template.to_string(), + provider: None, + username: None, + password: None, + method: InputFetchMethod::GET, + headers: HashMap::default(), + }; + match download_text_content(app_config, client, &input_source, None, None, false).await { + Ok((content, _)) => { + let body: Arc = Arc::from(content.as_str()); + if let Ok(mut cache) = CACHE.write() { + cache.insert( + template.to_string(), + CacheEntry { body: Arc::clone(&body), fetched_at: Instant::now(), mtime: current_mtime }, + ); + } + body + } + Err(err) => { + // Serve a stale body rather than silently degrading to the + // built-in text: the operator configured a template for a + // reason, and a template host blip should not change what the + // notification looks like. + if let Ok(cache) = CACHE.read() { + if let Some(entry) = cache.get(template) { + warn!("Template {template} could not be refreshed ({err}); serving the cached copy"); + return Arc::clone(&entry.body); + } + } + error!("Template {template} could not be resolved: {err}"); + Arc::from(template) + } + } +} + +/// Render `event` through `template`, or `None` when the template is +/// unusable. +/// +/// A `None` return means the caller falls back to `event.body`, which is +/// always populated. +pub async fn render( + app_config: &Arc, + client: &reqwest::Client, + template: &str, + event: &NotificationEvent, +) -> Option { + let body = resolve(app_config, client, template).await; + let data = context_for(event); + render_cached(template, &body, &data) +} + +/// Render against the compiled-template registry, compiling on first use. +fn render_cached(key: &str, body: &str, data: &serde_json::Value) -> Option { + // Fast path: already compiled. + if let Ok(registry) = REGISTRY.read() { + if registry.has_template(key) { + return match registry.render(key, data) { + Ok(rendered) => Some(rendered), + Err(err) => { + error!("Template {key} failed to render: {err}"); + None + } + }; + } + } + let mut registry = REGISTRY.write().ok()?; + if !registry.has_template(key) { + if let Err(err) = registry.register_template_string(key, body) { + error!("Template {key} failed to compile: {err}"); + return None; + } + debug!("Compiled notification template {key}"); + } + match registry.render(key, data) { + Ok(rendered) => Some(rendered), + Err(err) => { + error!("Template {key} failed to render: {err}"); + None + } + } +} + +/// Compile-check a template body without sending anything. +/// +/// Config validation calls this so a malformed template is an error the +/// operator sees at load, not a per-send `error!` with a silent fallback +/// that looks plausible. +pub fn validate(body: &str) -> Result<(), String> { + new_handlebars().render_template(body, &serde_json::json!({})).map(|_| ()).map_err(|err| err.to_string()) +} + +/// The data a template sees. +/// +/// Carries the new uniform `event.*` shape *and* every legacy top-level +/// key, so templates written against `{{kind}}`, `{{message}}`, +/// `{{stats}}`, `{{processing.errors}}`, `{{disk.percent}}` and the +/// flattened first-input fields keep rendering identically. +#[must_use] +pub fn context_for(event: &NotificationEvent) -> serde_json::Value { + let mut root = serde_json::Map::new(); + + root.insert("event".to_string(), serde_json::to_value(event).unwrap_or(serde_json::Value::Null)); + root.insert("timestamp".to_string(), event.timestamp_rfc3339().into()); + root.insert("kind".to_string(), legacy_kind_label(event).into()); + root.insert("severity".to_string(), event.severity.wire_name().into()); + root.insert("title".to_string(), event.title.clone().into()); + root.insert("body".to_string(), event.body.clone().into()); + + let fields = &event.fields; + match event.id { + id if id == registry::SYSTEM_INFO || id == registry::SYSTEM_ERROR => { + root.insert("message".to_string(), fields.clone()); + } + id if id == registry::PLAYLIST_WATCH_CHANGED => { + root.insert("watch".to_string(), fields.clone()); + } + id if id == registry::PLAYLIST_UPDATE_COMPLETED || id == registry::PLAYLIST_UPDATE_FAILED => { + root.insert("processing".to_string(), fields.clone()); + if let Some(stats) = fields.get("stats") { + root.insert("stats".to_string(), stats.clone()); + // The legacy context flattened the first input's stats to the + // root so short templates could say `{{name}}`/`{{took}}`. + if let Some(first_input) = stats.get(0).and_then(|s| s.get("inputs")).and_then(|i| i.get(0)) { + if let Some(map) = first_input.as_object() { + for (key, value) in map { + root.entry(key.clone()).or_insert_with(|| value.clone()); + } + } + } + } + if let Some(errors) = fields.get("errors") { + root.insert("message".to_string(), errors.clone()); + } + } + id if id == registry::SYSTEM_DISK_ALERT => { + root.insert("disk".to_string(), fields.clone()); + } + id if id == registry::RECORDING_STARTED + || id == registry::RECORDING_COMPLETED + || id == registry::RECORDING_FAILED => + { + root.insert("recording".to_string(), fields.clone()); + root.insert("message".to_string(), event.title.clone().into()); + } + _ => {} + } + serde_json::Value::Object(root) +} + +/// The legacy `{{kind}}` label. +/// +/// Templates in the wild print this, and the documented examples assert on +/// values like `Info` and `Stats`, so it stays the old CamelCase name for +/// registered legacy ids and the dotted id for everything new. +fn legacy_kind_label(event: &NotificationEvent) -> String { + let id = event.id; + if id == registry::SYSTEM_INFO { + "Info".to_string() + } else if id == registry::SYSTEM_ERROR { + "Error".to_string() + } else if id == registry::PLAYLIST_UPDATE_COMPLETED { + "Stats".to_string() + } else if id == registry::PLAYLIST_UPDATE_FAILED { + "Error".to_string() + } else if id == registry::PLAYLIST_WATCH_CHANGED { + "Watch".to_string() + } else if id == registry::SYSTEM_DISK_ALERT { + "DiskAlert".to_string() + } else if id == registry::RECORDING_STARTED { + "RecordingStarted".to_string() + } else if id == registry::RECORDING_COMPLETED { + "RecordingCompleted".to_string() + } else if id == registry::RECORDING_FAILED { + "RecordingFailed".to_string() + } else { + id.as_str().to_string() + } +} + +#[cfg(test)] +mod tests { + use super::{context_for, validate, TemplateSource}; + use shared::model::{DiskAlert, DiskAlertLevel, InputStats, InputType, PlaylistStats, SourceStats, TargetStats}; + use tuliprox_core::model::{MessageContent, NotificationEvent, ProcessingStats, WatchChanges}; + + fn stats_event() -> NotificationEvent { + let input = InputStats { + name: "Test Input".to_string(), + input_type: InputType::M3u, + error_count: 5, + raw_stats: PlaylistStats { group_count: 100, channel_count: 1000 }, + processed_stats: PlaylistStats { group_count: 50, channel_count: 500 }, + secs_took: 125, + }; + let content = MessageContent::ProcessingStats(ProcessingStats { + stats: Some(vec![SourceStats { inputs: vec![input], targets: vec![TargetStats::success("Target 1")] }]), + errors: Some("global error".to_string()), + }); + NotificationEvent::from_content(&content) + } + + #[test] + fn inline_templates_are_not_fetched() { + // The old code pushed every inline template through + // `download_text_content` on every send. + assert_eq!(TemplateSource::classify("Message: {{message}}"), TemplateSource::Inline); + assert_eq!(TemplateSource::classify(" {\"content\": \"x\"}"), TemplateSource::Inline); + } + + #[test] + fn file_and_url_templates_are_classified() { + assert_eq!(TemplateSource::classify("file:///tmp/a.templ"), TemplateSource::File("/tmp/a.templ".to_string())); + assert_eq!(TemplateSource::classify("https://example.test/a.templ"), TemplateSource::Url); + assert_eq!(TemplateSource::classify("http://example.test/a.templ"), TemplateSource::Url); + } + + #[test] + fn legacy_kind_labels_are_preserved() { + // Templates in the wild print `{{kind}}` and the documented examples + // assert on these exact strings. + let info = NotificationEvent::from_content(&MessageContent::Info("hi".to_string())); + assert_eq!(context_for(&info)["kind"], "Info"); + assert_eq!(context_for(&stats_event())["kind"], "Stats"); + let err = NotificationEvent::from_content(&MessageContent::ProcessingStats(ProcessingStats::new_error( + "boom".to_string(), + ))); + assert_eq!(context_for(&err)["kind"], "Error"); + } + + #[test] + fn legacy_message_key_still_resolves_for_info() { + let event = NotificationEvent::from_content(&MessageContent::Info("Hello World".to_string())); + assert_eq!(context_for(&event)["message"], "Hello World"); + } + + #[test] + fn legacy_processing_and_stats_keys_still_resolve() { + let ctx = context_for(&stats_event()); + assert_eq!(ctx["processing"]["errors"], "global error"); + assert!(ctx["stats"].is_array(), "legacy `stats` key missing: {ctx}"); + // `{{message}}` carried the error string for a stats message. + assert_eq!(ctx["message"], "global error"); + } + + #[test] + fn first_input_stats_are_flattened_to_the_root() { + // Legacy templates use bare `{{name}}` / `{{took}}` for the first + // input without walking into `stats`. + let ctx = context_for(&stats_event()); + assert_eq!(ctx["name"], "Test Input"); + assert!(ctx.get("took").is_some(), "flattened `took` missing: {ctx}"); + } + + #[test] + fn flattened_fields_never_shadow_a_real_context_key() { + // `stats` and `kind` must keep their meanings even though an input + // payload could carry a same-named field. + let ctx = context_for(&stats_event()); + assert!(ctx["stats"].is_array()); + assert_eq!(ctx["kind"], "Stats"); + } + + #[test] + fn legacy_disk_key_still_resolves() { + let event = NotificationEvent::from_content(&MessageContent::DiskAlert(DiskAlert { + level: DiskAlertLevel::Critical, + total_bytes: 1_000_000_000, + free_bytes: 50_000_000, + used_bytes: 950_000_000, + percent: 95.0, + })); + let ctx = context_for(&event); + assert_eq!(ctx["kind"], "DiskAlert"); + assert_eq!(ctx["disk"]["level"], "critical"); + assert_eq!(ctx["disk"]["percent"], 95.0); + } + + #[test] + fn legacy_watch_key_still_resolves() { + let event = NotificationEvent::from_content(&MessageContent::Watch(WatchChanges::new( + "T".to_string(), + "G".to_string(), + vec!["A".to_string()], + vec![], + ))); + let ctx = context_for(&event); + assert_eq!(ctx["kind"], "Watch"); + assert_eq!(ctx["watch"]["target"], "T"); + } + + #[test] + fn the_new_event_shape_is_available_alongside_the_legacy_keys() { + let ctx = context_for(&stats_event()); + assert_eq!(ctx["event"]["id"], "playlist.update.completed"); + assert_eq!(ctx["event"]["severity"], "info"); + assert!(ctx["event"]["title"].as_str().is_some_and(|t| !t.is_empty())); + assert!(ctx["severity"].as_str().is_some()); + } + + #[test] + fn validate_accepts_a_good_template_and_rejects_a_broken_one() { + assert!(validate("Hello {{message}}").is_ok()); + // Unclosed block - this is exactly what used to fail silently at + // send time and fall back to the built-in text. + assert!(validate("{{#each stats}}oops").is_err()); + } +} diff --git a/backend/metadata/src/ctx.rs b/backend/metadata/src/ctx.rs index 59cf90d39..fafcfcd99 100644 --- a/backend/metadata/src/ctx.rs +++ b/backend/metadata/src/ctx.rs @@ -8,6 +8,7 @@ use arc_swap::ArcSwap; use reqwest::Client; +use shared::model::EventSink; use std::sync::Arc; use tuliprox_core::model::{AppConfig, UpdateGuard}; use tuliprox_repository::PlaylistStorageState; @@ -15,7 +16,7 @@ use tuliprox_session::{ActiveProviderManager, ConnectionManager, EventManager}; /// Everything the metadata worker reads from the running server. #[derive(Clone)] -pub struct MetadataUpdateCtx { +pub struct MetadataUpdateCtx { /// Resolved configuration; re-read on each use because it is hot-swapped. pub app_config: Arc, /// Provider allocation, for the upstream a probe fetches from. @@ -23,7 +24,7 @@ pub struct MetadataUpdateCtx { /// Connection admission and teardown for probe requests. pub connection_manager: Arc, /// Where metadata progress is published. - pub event_manager: Arc, + pub events: E, /// The in-memory playlist cache the worker writes resolved detail into. pub playlists: Arc, /// Guards a playlist update against concurrent metadata writes. @@ -33,3 +34,12 @@ pub struct MetadataUpdateCtx { /// Client that does not follow redirects, for provider requests. pub http_client_no_redirect: Arc>, } + +/// The instantiation the long-lived worker stores. +/// +/// Functions that only read a context stay generic over the sink, so they +/// can be exercised against `NoopSink`. `MetadataUpdateManager` itself is +/// held by `AppState` as one concrete `Arc`, so it has to name one sink - +/// making it generic would make `AppState` generic, and then everything +/// that touches `AppState`. +pub type BoundMetadataUpdateCtx = MetadataUpdateCtx>; diff --git a/backend/metadata/src/manager.rs b/backend/metadata/src/manager.rs index ca3958aba..7c76dafa9 100644 --- a/backend/metadata/src/manager.rs +++ b/backend/metadata/src/manager.rs @@ -1,4 +1,4 @@ -use crate::ctx::MetadataUpdateCtx; +use crate::ctx::{BoundMetadataUpdateCtx, MetadataUpdateCtx}; use arc_swap::ArcSwap; use dashmap::{mapref::entry::Entry, DashMap}; use log::{debug, error, info, warn}; @@ -7,10 +7,11 @@ use shared::{ defaults::default_probe_user_priority, error::TuliproxError, model::{ - InputType, LiveStreamProperties, PlaylistItemType, SeriesStreamProperties, StreamProperties, UUIDType, - VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, + EventMessage, EventSink, InputType, LiveStreamProperties, MetadataUpdateFailure, PlaylistItemType, + SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamProperties, VirtualId, XtreamCluster, + XtreamPlaylistItem, }, - utils::generate_provider_playlist_uuid, + utils::{generate_provider_playlist_uuid, sanitize_sensitive_info}, }; use std::{ cmp::min, @@ -41,7 +42,6 @@ use tuliprox_repository::{ persist_input_vod_info_batch, write_playlist_batch_item_upsert, xtream_get_file_path, BPlusTree, BPlusTreeQuery, BPlusTreeUpdate, MetadataRetryDbKey, MetadataRetryDbValue, RetryStateDbValue, TargetIdMapping, }; -use tuliprox_session::EventMessage; const METADATA_RETRY_STATE_FILE: &str = "metadata_retry_state.db"; const TASK_ERR_NO_CONNECTION: &str = "No connection available"; @@ -110,7 +110,7 @@ impl Default for MetadataUpdateRuntimeSettings { } impl MetadataUpdateRuntimeSettings { - fn from_ctx(ctx: Option<&MetadataUpdateCtx>) -> Self { + fn from_ctx(ctx: Option<&MetadataUpdateCtx>) -> Self { let metadata_update = ctx.map_or_else(MetadataUpdateConfig::default, |ctx| { ctx.app_config .config @@ -447,7 +447,7 @@ pub struct MetadataUpdateManager { /// Terminal shutdown flag; once set, token rotation must not reactivate workers. is_shutdown_flag: AtomicBool, /// Global application state (weak reference to avoid cycles) - ctx: tokio::sync::Mutex>, + ctx: tokio::sync::Mutex>, /// Global gate: /// - Foreground playlist updates hold WRITE lock. /// - Background metadata/probe tasks hold READ lock per task. @@ -540,7 +540,7 @@ impl MetadataUpdateManager { /// Bind the handles the worker reads. Called once, after the server's root /// state is built - the worker itself is constructed before it. - pub async fn set_ctx(&self, ctx: MetadataUpdateCtx) { + pub async fn set_ctx(&self, ctx: BoundMetadataUpdateCtx) { let mut guard = self.ctx.lock().await; *guard = Some(ctx); } @@ -1053,7 +1053,7 @@ struct InputWorker { receiver: mpsc::Receiver, pending_tasks: Arc>, pending_task_count: Arc, - bound_ctx: Option, + bound_ctx: Option, update_pause_gate: Arc>, cancel_token: CancellationToken, batch_buffer: BatchResultCollector, @@ -1095,10 +1095,10 @@ macro_rules! collect_virtual_updates { mapping: &TargetIdMapping, input_name: &str, batch: &'a BatchResultCollector, - provider_virtual_ids: &mut HashMap>, - uuid_virtual_ids: &mut HashMap>, - ) -> HashMap { - let mut virtual_updates: HashMap = HashMap::new(); + provider_virtual_ids: &mut HashMap>, + uuid_virtual_ids: &mut HashMap>, + ) -> HashMap { + let mut virtual_updates: HashMap = HashMap::new(); if batch.$field.is_empty() { return virtual_updates; } @@ -1134,11 +1134,11 @@ macro_rules! collect_virtual_updates { /// used in error and log messages. macro_rules! apply_cascade_updates { ($fn_name:ident, $props_ty:ty, $cluster:expr, $variant:ident, $log_label:literal) => { - async fn $fn_name( - ctx: &MetadataUpdateCtx, + async fn $fn_name( + ctx: &MetadataUpdateCtx, target: &tuliprox_core::model::ConfigTarget, storage_path: &std::path::Path, - virtual_updates: HashMap, + virtual_updates: HashMap, ) { if virtual_updates.is_empty() { return; @@ -1147,7 +1147,7 @@ macro_rules! apply_cascade_updates { let target_name = target.name.as_str(); let xtream_path = xtream_get_file_path(storage_path, $cluster); let updates_input: Vec<(u32, $props_ty)> = - virtual_updates.into_iter().map(|(vid, props)| (vid, props.clone())).collect(); + virtual_updates.into_iter().map(|(vid, props)| (vid.get(), props.clone())).collect(); let updates = { // Scope read lock to read-only query phase so write phase can acquire lock. @@ -1211,6 +1211,13 @@ impl InputWorker { let mut last_progress_log_at = Instant::now(); let mut queue_cycle_active = false; let mut cycle_had_changes = false; + // Tasks that burned through their retries this cycle. A cycle that + // exhausts work is the only signal an operator gets that an input has + // stopped resolving - `InputMetadataUpdatesCompleted` fires only when + // something actually changed, so a permanently broken input would + // otherwise emit a start and then nothing at all. + let mut cycle_failed_tasks: usize = 0; + let mut cycle_last_error: Option = None; let mut consecutive_resolve_tasks = 0_usize; let input_name = self.input_name.clone(); @@ -1265,6 +1272,8 @@ impl InputWorker { // First entry of a new processing cycle. queue_cycle_active = true; cycle_had_changes = false; + cycle_failed_tasks = 0; + cycle_last_error = None; processed_vod_count = 0; processed_series_count = 0; last_progress_log_at = Instant::now(); @@ -1273,7 +1282,7 @@ impl InputWorker { .checked_sub(runtime_settings.queue_log_interval + Duration::from_secs(1)) .unwrap_or_else(Instant::now); if let Some(ctx) = bound_ctx.clone() { - ctx.event_manager.send_event(EventMessage::InputMetadataUpdatesStarted(input_name.clone())); + ctx.events.emit(EventMessage::InputMetadataUpdatesStarted(input_name.clone())); } if self.last_cycle_completed_at_ts.is_some_and(|last| { now_ts.saturating_sub(last) >= runtime_settings.resolve_exhaustion_reset_gap_secs @@ -1739,6 +1748,8 @@ impl InputWorker { let attempts = state_after_update.attempts; if attempts >= max_attempts { + cycle_failed_tasks = cycle_failed_tasks.saturating_add(1); + cycle_last_error = Some(sanitize_sensitive_info(e.message()).into_owned()); self.scheduled_requeues.remove(¤t_key); if retry_domain == RetryDomain::Probe { remove_current_task = true; @@ -1899,12 +1910,31 @@ impl InputWorker { if cycle_had_changes { info!("All pending metadata resolves completed for input {input_name} (with changes)"); if let Some(ctx) = bound_ctx.clone() { - ctx.event_manager.send_event(EventMessage::InputMetadataUpdatesCompleted(input_name.clone())); + ctx.events.emit(EventMessage::InputMetadataUpdatesCompleted(input_name.clone())); } } else { debug!("All pending metadata resolves completed for input {input_name} (no changes, skipping playlist update trigger)"); } + // Reported alongside the completion rather than instead of it: + // a cycle can both produce changes and exhaust tasks, and + // suppressing the completion would also suppress the playlist + // update it triggers. + if cycle_failed_tasks > 0 { + warn!( + "Metadata update cycle for input {input_name} exhausted {cycle_failed_tasks} task(s) without resolving them" + ); + if let Some(ctx) = bound_ctx.clone() { + ctx.events.emit(EventMessage::InputMetadataUpdatesFailed(MetadataUpdateFailure::new( + input_name.clone(), + cycle_failed_tasks, + cycle_had_changes, + cycle_last_error.clone(), + ))); + } + } cycle_had_changes = false; + cycle_failed_tasks = 0; + cycle_last_error = None; } } @@ -1920,10 +1950,10 @@ impl InputWorker { debug!("Metadata worker stopped for input {input_name}"); } - async fn ensure_metadata_retry_state_loaded( + async fn ensure_metadata_retry_state_loaded( &mut self, input_name: &str, - bound_ctx: Option<&MetadataUpdateCtx>, + bound_ctx: Option<&MetadataUpdateCtx>, runtime_settings: &MetadataUpdateRuntimeSettings, ) { if self.metadata_retry_loaded { @@ -2560,9 +2590,9 @@ impl InputWorker { } // Changed to static method - async fn flush_batch_static( + async fn flush_batch_static( input_name: &str, - bound_ctx: Option<&MetadataUpdateCtx>, + bound_ctx: Option<&MetadataUpdateCtx>, batch_buffer: &mut BatchResultCollector, ) { if batch_buffer.is_empty() { @@ -2657,8 +2687,8 @@ impl InputWorker { } #[allow(clippy::too_many_lines)] - async fn cascade_updates( - ctx: &MetadataUpdateCtx, + async fn cascade_updates( + ctx: &MetadataUpdateCtx, config: &tuliprox_core::model::Config, input_name: &str, batch: &BatchResultCollector, @@ -2713,8 +2743,8 @@ impl InputWorker { } }; - let mut provider_virtual_ids: HashMap> = HashMap::new(); - let mut uuid_virtual_ids: HashMap> = HashMap::new(); + let mut provider_virtual_ids: HashMap> = HashMap::new(); + let mut uuid_virtual_ids: HashMap> = HashMap::new(); let vod_virtual_updates = Self::collect_vod_virtual_updates( &mapping, @@ -2747,9 +2777,9 @@ impl InputWorker { fn get_cached_uuid_virtual_id( mapping: &TargetIdMapping, - cache: &mut HashMap>, + cache: &mut HashMap>, uuid: UUIDType, - ) -> Option { + ) -> Option { if let Some(cached) = cache.get(&uuid) { return *cached; } @@ -2780,8 +2810,8 @@ impl InputWorker { ); apply_cascade_updates!(apply_live_cascade_updates, LiveStreamProperties, XtreamCluster::Live, Live, "Live"); - async fn update_memory_cache( - ctx: &MetadataUpdateCtx, + async fn update_memory_cache( + ctx: &MetadataUpdateCtx, target_name: &str, cluster: XtreamCluster, updates: Vec, @@ -2795,14 +2825,14 @@ impl InputWorker { XtreamCluster::Series => &mut xtream_storage.series, }; for item in updates { - storage.insert(item.virtual_id, item); + storage.insert(item.virtual_id.get(), item); } } } } - async fn get_or_open_query( + async fn get_or_open_query( input_name: &str, - ctx: &MetadataUpdateCtx, + ctx: &MetadataUpdateCtx, cluster: XtreamCluster, db_handles: &mut HashMap, failed_clusters: &mut HashSet, @@ -2849,9 +2879,9 @@ impl InputWorker { } // Helper for get_item_name with caching - async fn get_item_name_static( + async fn get_item_name_static( input_name: &str, - ctx: &MetadataUpdateCtx, + ctx: &MetadataUpdateCtx, task: &UpdateTask, db_handles: &mut HashMap, failed_clusters: &mut HashSet, @@ -2889,9 +2919,9 @@ impl InputWorker { } #[allow(clippy::too_many_lines)] - async fn process_task_static( + async fn process_task_static( input_name: &Arc, - bound_ctx: Option<&MetadataUpdateCtx>, + bound_ctx: Option<&MetadataUpdateCtx>, task: &UpdateTask, collector: &mut BatchResultCollector, db_handles: &mut HashMap, @@ -3122,8 +3152,8 @@ impl InputWorker { } #[allow(clippy::too_many_arguments, clippy::too_many_lines)] - async fn execute_task_inner_static( - ctx: &MetadataUpdateCtx, + async fn execute_task_inner_static( + ctx: &MetadataUpdateCtx, client: &reqwest::Client, input: &Arc, task: &UpdateTask, @@ -3283,6 +3313,7 @@ impl InputWorker { &ctx.active_provider, active_handle, probe_priority, + &ctx.events, ) .await?; @@ -3328,7 +3359,7 @@ impl InputWorker { &mut item.additional_properties, *item_type, &item.name, - item.virtual_id, + item.virtual_id.get(), metadata.raw_video, metadata.raw_audio, metadata.stats, @@ -3359,6 +3390,7 @@ impl InputWorker { &ctx.active_provider, active_handle, probe_priority, + &ctx.events, ) .await?; @@ -3379,16 +3411,12 @@ impl InputWorker { /// lives there so the pipeline does not have to name this type. The impl lives /// here because the type does. impl tuliprox_processing::metadata_sink::MetadataUpdateSink for MetadataUpdateManager { - fn acquire_update_pause_guard( - &self, - ) -> tuliprox_processing::metadata_sink::SinkFuture<'_, tokio::sync::OwnedRwLockWriteGuard<()>> { - Box::pin(MetadataUpdateManager::acquire_update_pause_guard(self)) + async fn acquire_update_pause_guard(&self) -> tokio::sync::OwnedRwLockWriteGuard<()> { + MetadataUpdateManager::acquire_update_pause_guard(self).await } - fn prepare_enqueue_state(&self, input_name: Arc) -> tuliprox_processing::metadata_sink::SinkFuture<'_, ()> { - Box::pin(async move { - self.ensure_enqueue_state_loaded_for_input(&input_name).await; - }) + async fn prepare_enqueue_state(&self, input_name: Arc) { + self.ensure_enqueue_state_loaded_for_input(&input_name).await; } fn should_skip_enqueue(&self, input_name: &str, task: &UpdateTask) -> bool { @@ -4038,7 +4066,7 @@ mod tests { let mapping_path = dir.path().join("target_id_mapping.db"); let mut mapping = TargetIdMapping::new(&mapping_path, false).expect("mapping should be created"); let uuid = generate_provider_playlist_uuid($input_name, $text_id, $item_type); - let virtual_id = mapping.get_and_update_virtual_id(&uuid, 0, $item_type, 0); + let virtual_id = mapping.get_and_update_virtual_id(&uuid, 0, $item_type, VirtualId::default()); mapping.persist().expect("mapping should persist"); let mut batch = BatchResultCollector::new(); @@ -4097,7 +4125,7 @@ mod tests { batch.add_vod(ProviderIdType::Id(42), pending_props); let mut item = XtreamPlaylistItem { - virtual_id: 7, + virtual_id: VirtualId::new(7), provider_id: 42, name: Arc::from("Movie"), logo: Arc::from(""), diff --git a/backend/parser/src/m3u_format/catchup.rs b/backend/parser/src/m3u_format/catchup.rs index 9595763ac..acc98a922 100644 --- a/backend/parser/src/m3u_format/catchup.rs +++ b/backend/parser/src/m3u_format/catchup.rs @@ -1,5 +1,5 @@ use super::{encode_m3u_catchup_token, M3uCatchupToken}; -use chrono::{DateTime, NaiveDateTime}; +use chrono::{DateTime, NaiveDateTime, Utc}; use shared::{ error::TuliproxError, model::CatchupProperties, @@ -20,6 +20,7 @@ const M3U_CATCHUP_PARAM_LUTC: &str = "lutc"; const XC_START_TEMPLATE: &str = "{Y}-{m}-{d}:{H}-{M}-{S}"; const FLUSSONIC_UTC_SENTINEL: &str = "__TULIPROX_M3U_CATCHUP_UTC__"; const XTREAM_BRIDGE_START_FORMATS: [&str; 4] = ["%Y-%m-%d %H:%M", "%Y-%m-%d:%H-%M", "%Y-%m-%d:%H:%M", "%Y-%m-%d-%H-%M"]; +const MAX_CATCHUP_FUTURE_SKEW_SECS: i64 = 10 * 60; #[derive(Debug, Clone, PartialEq, Eq)] pub struct M3uCatchupRewrite { @@ -58,16 +59,6 @@ fn mode_alias(mode: &str) -> &str { } } -fn effective_catchup_mode(catchup: &CatchupProperties) -> &str { - catchup - .mode - .as_deref() - .map(str::trim) - .filter(|mode| !mode.is_empty()) - .or_else(|| catchup.catchup_type.as_deref().map(str::trim).filter(|mode| !mode.is_empty())) - .unwrap_or_default() -} - fn parse_template(template: &str) -> Vec { let mut segments = Vec::new(); let mut current = String::new(); @@ -224,30 +215,78 @@ fn is_append_like_query_source(mode: &str, source: &str) -> bool { /// Build `archive|{utc}|{duration}` when the resolved provider URL still carries append/shift /// query markers. Session tokens keep this discriminator so the panel can show Catchup/EPG /// after HLS rewrite drops `utc`/`utcstart` from segment URLs. -fn archive_discriminator_from_resolved_url(url: &str) -> Option { - let parsed = Url::parse(url).ok()?; +fn parse_catchup_timestamp(value: &str) -> Option { + if value.len() == 14 && value.bytes().all(|byte| byte.is_ascii_digit()) { + return NaiveDateTime::parse_from_str(value, "%Y%m%d%H%M%S").ok().map(|dt| dt.and_utc().timestamp()); + } + let timestamp = value.parse::().ok()?; + DateTime::from_timestamp(timestamp, 0).map(|_| timestamp) +} + +fn validate_catchup_start(start: i64) -> Result<(), TuliproxError> { + if start > Utc::now().timestamp().saturating_add(MAX_CATCHUP_FUTURE_SKEW_SECS) { + return Err(TuliproxError::RepositoryM3u("Catchup start timestamp is in the future".to_string())); + } + Ok(()) +} + +fn archive_discriminator_from_resolved_url(url: &str) -> Result, TuliproxError> { + let Ok(parsed) = Url::parse(url) else { + return Ok(None); + }; let mut start_ts = None; let mut end_ts = None; - let mut duration_secs = None; + let mut duration_value = None; for (key, value) in parsed.query_pairs() { if key.eq_ignore_ascii_case("utc") || key.eq_ignore_ascii_case("utcstart") || key.eq_ignore_ascii_case("timestamp") { - start_ts = value.parse::().ok().or(start_ts); + start_ts = + Some(parse_catchup_timestamp(&value).ok_or_else(|| { + TuliproxError::RepositoryM3u(format!("Invalid catchup start timestamp: {value}")) + })?); } else if key.eq_ignore_ascii_case("lutc") { - end_ts = value.parse::().ok().or(end_ts); + end_ts = Some( + parse_catchup_timestamp(&value) + .ok_or_else(|| TuliproxError::RepositoryM3u(format!("Invalid catchup end timestamp: {value}")))?, + ); } else if key.eq_ignore_ascii_case("offset") || key.eq_ignore_ascii_case("duration") { - duration_secs = value.parse::().ok().and_then(i64::checked_abs).or(duration_secs); + duration_value = Some(value.into_owned()); } } - let start = start_ts?; - let duration = end_ts.map(|end| end.saturating_sub(start).max(0)).or(duration_secs).unwrap_or(0); - Some(format!("archive|{start}|{duration}")) + let Some(start) = start_ts else { + if end_ts.is_some() { + return Err(TuliproxError::RepositoryM3u("Catchup window is missing a start timestamp".to_string())); + } + return Ok(None); + }; + validate_catchup_start(start)?; + let duration_secs = duration_value + .map(|value| { + let duration = value + .parse::() + .ok() + .and_then(i64::checked_abs) + .ok_or_else(|| TuliproxError::RepositoryM3u(format!("Invalid catchup duration: {value}")))?; + if duration == 0 { + return Err(TuliproxError::RepositoryM3u("Catchup duration must be greater than zero".to_string())); + } + Ok(duration) + }) + .transpose()?; + let duration = if let Some(end) = end_ts { + end.checked_sub(start) + .filter(|duration| *duration > 0) + .ok_or_else(|| TuliproxError::RepositoryM3u("Catchup end must be after its start".to_string()))? + } else { + duration_secs.unwrap_or(0) + }; + Ok(Some(format!("archive|{start}|{duration}"))) } fn derived_template_for_mode<'a>(source_url: &'a str, catchup: &'a CatchupProperties) -> Option> { - let mode = effective_catchup_mode(catchup); + let mode = catchup.effective_mode().unwrap_or_default(); if let Some(source) = catchup.source.as_deref().filter(|source| !source.is_empty()) { return Some(if is_append_like_query_source(mode, source) { append_query_template(source_url, source).map(Cow::Owned)? @@ -327,14 +366,15 @@ fn fill_collectors_from_utc_lutc( let utc = params.iter().find(|(key, _)| key.eq_ignore_ascii_case(M3U_CATCHUP_PARAM_UTC)).map(|(_, value)| value.as_str()); let duration = utc - .and_then(|start| start.parse::().ok()) + .and_then(parse_catchup_timestamp) .zip( params .iter() .find(|(key, _)| key.eq_ignore_ascii_case(M3U_CATCHUP_PARAM_LUTC)) - .and_then(|(_, value)| value.parse::().ok()), + .and_then(|(_, value)| parse_catchup_timestamp(value)), ) - .and_then(|(start, end)| (end >= start).then(|| (end - start).to_string())); + .and_then(|(start, end)| end.checked_sub(start).filter(|duration| *duration > 0)) + .map(|duration| duration.to_string()); for (collector, placeholder) in collectors.iter_mut().zip(placeholders) { if collector.is_some() { @@ -422,7 +462,7 @@ pub fn build_m3u_catchup_rewrite( .source .as_deref() .filter(|source| !source.is_empty()) - .is_some_and(|source| is_append_like_query_source(effective_catchup_mode(catchup), source)); + .is_some_and(|source| is_append_like_query_source(catchup.effective_mode().unwrap_or_default(), source)); let source = build_local_source(base_url, "", &token, &placeholders, append_mode); let mode = if append_mode { "append" } else { "default" }; @@ -491,9 +531,18 @@ pub fn resolve_xtream_m3u_catchup_url( let minutes: u64 = duration_minutes .parse() .map_err(|_| TuliproxError::RepositoryM3u(format!("Invalid Xtream duration minutes: {duration_minutes}")))?; + if minutes == 0 { + return Err(TuliproxError::RepositoryM3u("Xtream duration must be greater than zero".to_string())); + } let duration_secs = minutes .checked_mul(60) .ok_or_else(|| TuliproxError::RepositoryM3u("Xtream duration minutes overflow seconds".to_string()))?; + let duration_i64 = i64::try_from(duration_secs) + .map_err(|_| TuliproxError::RepositoryM3u("Xtream duration exceeds the supported range".to_string()))?; + validate_catchup_start(utc_start)?; + utc_start + .checked_add(duration_i64) + .ok_or_else(|| TuliproxError::RepositoryM3u("Xtream catchup end timestamp overflow".to_string()))?; let collectors: Vec<(usize, String)> = segments .iter() @@ -535,9 +584,9 @@ pub fn resolve_m3u_catchup_url( let url = render_template(&segments, &collectors)?; // Prefer archive|{utc}|{duration} so Streams/History can recover EPG from the session token // after append/shift query params are stripped from rewritten HLS segment URLs. - let discriminator = archive_discriminator_from_resolved_url(&url).unwrap_or_else(|| { + let discriminator = archive_discriminator_from_resolved_url(&url)?.unwrap_or_else(|| { let mut discriminator = url::form_urlencoded::Serializer::new(String::new()); - let mode = effective_catchup_mode(catchup); + let mode = catchup.effective_mode().unwrap_or_default(); discriminator.append_pair("mode", if mode.is_empty() { "default" } else { mode }); for (idx, value) in &collectors { discriminator.append_pair(&format!("{COLLECTOR_PREFIX}{idx}"), value); @@ -623,6 +672,7 @@ mod tests { super::archive_discriminator_from_resolved_url( "http://provider.example/live/42.m3u8?offset=-3600&utcstart=1717200000" ) + .expect("valid append window") .as_deref(), Some("archive|1717200000|3600") ); @@ -630,20 +680,45 @@ mod tests { super::archive_discriminator_from_resolved_url( "http://provider.example/live/42.ts?utc=1717200000&lutc=1717203600" ) + .expect("valid shift window") .as_deref(), Some("archive|1717200000|3600") ); } #[test] - fn append_discriminator_ignores_overflowing_i64_min_offset() { - assert_eq!( - super::archive_discriminator_from_resolved_url( - "http://provider.example/live/42.m3u8?offset=-9223372036854775808&utcstart=1717200000" - ) - .as_deref(), - Some("archive|1717200000|0") + fn append_discriminator_rejects_overflowing_i64_min_offset() { + let result = super::archive_discriminator_from_resolved_url( + "http://provider.example/live/42.m3u8?offset=-9223372036854775808&utcstart=1717200000", ); + assert!(result.is_err()); + } + + #[test] + fn shift_discriminator_normalizes_compact_calendar_window() { + let discriminator = super::archive_discriminator_from_resolved_url( + "http://provider.example/live/42.ts?utc=20240101120000&lutc=20240101130000", + ) + .expect("valid compact calendar window"); + + assert_eq!(discriminator.as_deref(), Some("archive|1704110400|3600")); + } + + #[test] + fn m3u_catchup_rejects_invalid_windows() { + let append = CatchupProperties { + mode: Some("append".intern()), + source: Some("?offset=-${offset}&utcstart=${timestamp}".intern()), + ..CatchupProperties::default() + }; + assert!(resolve_m3u_catchup_url("http://provider.example/live/42.m3u8", &append, Some("v0=0&v1=1717200000"),) + .is_err()); + + let shift = CatchupProperties { mode: Some("shift".intern()), ..CatchupProperties::default() }; + for query in ["utc=1717203600&lutc=1717200000", "utc=invalid&lutc=1717203600", "utc=1717200000&lutc=1717200000"] + { + assert!(resolve_m3u_catchup_url("http://provider.example/live/42.ts", &shift, Some(query)).is_err()); + } } #[test] @@ -970,6 +1045,45 @@ mod tests { assert!(!resolved.discriminator.is_empty()); } + #[test] + fn catchup_type_flussonic_wins_over_stale_append_mode() { + let catchup = CatchupProperties { + mode: Some("append".intern()), + catchup_type: Some("flussonic".intern()), + ..CatchupProperties::default() + }; + + assert!(is_xtream_m3u_catchup_supported("http://provider.example/channel/index.m3u8", &catchup)); + let resolved = resolve_xtream_m3u_catchup_url( + "http://provider.example/channel/index.m3u8", + &catchup, + "2024-01-01:00-00", + "60", + ) + .expect("catchup-type must select the Flussonic template"); + + assert_eq!(resolved.url, "http://provider.example/channel/timeshift_abs-1704067200.m3u8"); + } + + #[test] + fn catchup_type_shift_wins_over_stale_flussonic_mode() { + let catchup = CatchupProperties { + mode: Some("flussonic".intern()), + catchup_type: Some("shift".intern()), + ..CatchupProperties::default() + }; + + let resolved = resolve_m3u_catchup_url( + "http://provider.example/live/42.ts", + &catchup, + Some("utc=1704067200&lutc=1704070800"), + ) + .expect("shift catchup resolution must succeed") + .expect("catchup-type must select the shift template"); + + assert_eq!(resolved.url, "http://provider.example/live/42.ts?utc=1704067200&lutc=1704070800"); + } + #[test] fn xtream_bridge_rejects_unsupported_placeholder() { let catchup = CatchupProperties { @@ -985,7 +1099,7 @@ mod tests { "60", ) .unwrap_err(); - assert!(matches!(err, TuliproxError::RepositoryM3u(_))); + assert_eq!(err.kind(), shared::error::ErrorKind::RepositoryM3u); } #[test] @@ -1002,7 +1116,7 @@ mod tests { "60", ) .unwrap_err(); - assert!(matches!(err, TuliproxError::RepositoryM3u(_))); + assert_eq!(err.kind(), shared::error::ErrorKind::RepositoryM3u); } #[test] @@ -1019,7 +1133,27 @@ mod tests { &u64::MAX.to_string(), ) .unwrap_err(); - assert!(matches!(err, TuliproxError::RepositoryM3u(_))); + assert_eq!(err.kind(), shared::error::ErrorKind::RepositoryM3u); + } + + #[test] + fn xtream_bridge_rejects_zero_duration_and_future_start() { + let catchup = CatchupProperties { + mode: Some("flussonic".intern()), + source: Some("http://provider.example/channel/video-{utc}-{duration}.m3u8".intern()), + ..CatchupProperties::default() + }; + assert!(resolve_xtream_m3u_catchup_url( + "http://provider.example/channel/index.m3u8", + &catchup, + "1704067200", + "0", + ) + .is_err()); + + let future = chrono::Utc::now().timestamp().saturating_add(3600).to_string(); + assert!(resolve_xtream_m3u_catchup_url("http://provider.example/channel/index.m3u8", &catchup, &future, "60",) + .is_err()); } #[test] diff --git a/backend/parser/src/xtream.rs b/backend/parser/src/xtream.rs index 3781542bc..7a0aa4355 100644 --- a/backend/parser/src/xtream.rs +++ b/backend/parser/src/xtream.rs @@ -266,12 +266,12 @@ pub async fn parse_xtream( &stream.get_stream_id().to_string(), item_type, ), - name: Arc::clone(&stream.get_name()), - logo: Arc::clone(&stream.get_stream_icon()), + name: stream.get_name(), + logo: stream.get_stream_icon(), group: Arc::clone(category_name), - title: Arc::clone(&stream.get_name()), + title: stream.get_name(), url: stream_url.clone(), - epg_channel_id: stream.get_epg_channel_id().clone(), + epg_channel_id: stream.get_epg_channel_id(), item_type, xtream_cluster, category_id: stream.get_category_id(), diff --git a/backend/processing/Cargo.toml b/backend/processing/Cargo.toml index 0cadf16c9..7b2cd98b2 100644 --- a/backend/processing/Cargo.toml +++ b/backend/processing/Cargo.toml @@ -19,7 +19,6 @@ tuliprox-core = { workspace = true } tuliprox-iptv = { workspace = true } tuliprox-library = { workspace = true } tuliprox-media-server = { workspace = true } -tuliprox-messaging = { workspace = true } tuliprox-parser = { workspace = true } tuliprox-repository = { workspace = true } tuliprox-session = { workspace = true } diff --git a/backend/processing/src/epg.rs b/backend/processing/src/epg.rs index 66ca19f80..f2493082b 100644 --- a/backend/processing/src/epg.rs +++ b/backend/processing/src/epg.rs @@ -1,8 +1,9 @@ -use crate::{parser::xmltv::TVGuide, processor::PlaylistProcessingContext}; +use crate::{metadata_sink::MetadataUpdateSink, parser::xmltv::TVGuide, processor::PlaylistProcessingContext}; use log::{debug, warn}; use shared::{ concat_string, error::TuliproxError, + model::EventSink, utils::{sanitize_sensitive_info, short_hash}, }; use std::{ @@ -83,9 +84,9 @@ fn epg_source_cache_extension(source_type: EpgSourceType) -> &'static str { } } -async fn download_epg_file( +async fn download_epg_file( epg_source: &EpgSource, - ctx: &PlaylistProcessingContext, + ctx: &PlaylistProcessingContext, input: &ConfigInput, headers: Option<&reqwest::header::HeaderMap>, storage_dir: &str, @@ -182,8 +183,8 @@ async fn cleanup_unlisted_epg_files( Ok(()) } -pub async fn get_xmltv( - ctx: &PlaylistProcessingContext, +pub async fn get_xmltv( + ctx: &PlaylistProcessingContext, input: &ConfigInput, headers: Option<&reqwest::header::HeaderMap>, storage_dir: &str, diff --git a/backend/processing/src/fetched_playlist.rs b/backend/processing/src/fetched_playlist.rs index 2ecee31f1..790a18b0d 100644 --- a/backend/processing/src/fetched_playlist.rs +++ b/backend/processing/src/fetched_playlist.rs @@ -1,11 +1,11 @@ use crate::parser::xmltv::TVGuide; use shared::{ error::TuliproxError, - model::{PlaylistGroup, PlaylistItem, UUIDType}, + model::{PlaylistGroup, UUIDType}, }; use std::collections::HashSet; use tuliprox_core::model::ConfigInput; -use tuliprox_repository::PlaylistSource; +use tuliprox_repository::{ClusterFiltered, PlaylistSource, SourceCowItems, SourceItemsMut}; pub struct FetchedPlaylist<'a> { pub input: &'a ConfigInput, @@ -29,11 +29,9 @@ impl FetchedPlaylist<'_> { pub fn get_group_count(&mut self) -> usize { self.source.get_group_count() } - pub fn items_mut(&mut self) -> Box + Send + '_> { self.source.items_mut() } + pub fn items_mut(&mut self) -> ClusterFiltered> { self.source.items_mut() } - pub fn items<'a>(&'a mut self) -> Box> + Send + 'a> { - self.source.items() - } + pub fn items(&mut self) -> ClusterFiltered> { self.source.items() } pub fn deduplicate(&mut self, duplicates: &mut HashSet) { self.source.deduplicate(duplicates); } diff --git a/backend/processing/src/metadata_sink.rs b/backend/processing/src/metadata_sink.rs index 6e383081b..4f5595b16 100644 --- a/backend/processing/src/metadata_sink.rs +++ b/backend/processing/src/metadata_sink.rs @@ -10,17 +10,21 @@ //! states the narrow contract it needs rather than depending on all of that. //! The implementation lives beside the manager, in the binary. -use std::{future::Future, pin::Pin, sync::Arc}; -use tokio::sync::OwnedRwLockWriteGuard; +use std::{future::Future, sync::Arc}; +use tokio::sync::{OwnedRwLockWriteGuard, RwLock}; use tuliprox_core::model::UpdateTask; -/// A future returned across the trait boundary. -pub type SinkFuture<'a, T> = Pin + Send + 'a>>; - /// The background metadata worker, as the pipeline sees it. +/// +/// The two async methods used to return `Pin>` and the +/// pipeline held the whole thing as `Arc`: a heap +/// allocation per call and a vtable hop on `should_skip_enqueue`, which runs +/// once per playlist item. Nothing ever needed the erasure - there is one +/// implementor - so the futures are returned by value and the pipeline carries +/// the concrete type. pub trait MetadataUpdateSink: Send + Sync + 'static { /// Held for the lifetime of an update; background work waits on it. - fn acquire_update_pause_guard(&self) -> SinkFuture<'_, OwnedRwLockWriteGuard<()>>; + fn acquire_update_pause_guard(&self) -> impl Future> + Send; /// Load the per-input state that `should_skip_enqueue` reads. /// @@ -28,7 +32,7 @@ pub trait MetadataUpdateSink: Send + Sync + 'static { /// once, lazily, before a run of `should_skip_enqueue` checks for the same /// input - not eagerly, or inputs with nothing to enqueue pay for a load /// they never use. - fn prepare_enqueue_state(&self, input_name: Arc) -> SinkFuture<'_, ()>; + fn prepare_enqueue_state(&self, input_name: Arc) -> impl Future + Send; /// `true` when an equivalent task is already pending or ran recently enough /// that enqueuing again would be redundant. @@ -40,3 +44,26 @@ pub trait MetadataUpdateSink: Send + Sync + 'static { /// Enqueue a detail-resolution task without waiting for it to be accepted. fn queue_task_background(self: Arc, input_name: Arc, task: UpdateTask); } + +/// The sink type parameter of a run that has no metadata worker. +/// +/// A run without one holds `None`, so none of these methods is ever called - +/// the type exists only to name the parameter, the same role `NoopSink` plays +/// for events. `unreachable!` would be a landmine in a type whose whole purpose +/// is to be absent, so each method is a correct no-op instead. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopMetadataSink; + +impl MetadataUpdateSink for NoopMetadataSink { + async fn acquire_update_pause_guard(&self) -> OwnedRwLockWriteGuard<()> { + // A guard over a lock nobody else holds: acquired immediately, and + // pausing nothing is the correct behaviour with no worker to pause. + Arc::new(RwLock::new(())).write_owned().await + } + + async fn prepare_enqueue_state(&self, _input_name: Arc) {} + + fn should_skip_enqueue(&self, _input_name: &str, _task: &UpdateTask) -> bool { true } + + fn queue_task_background(self: Arc, _input_name: Arc, _task: UpdateTask) {} +} diff --git a/backend/processing/src/playlist_watch.rs b/backend/processing/src/playlist_watch.rs index 781e2fc17..f02441c48 100644 --- a/backend/processing/src/playlist_watch.rs +++ b/backend/processing/src/playlist_watch.rs @@ -1,19 +1,114 @@ use log::error; -use shared::model::PlaylistGroup; +use shared::model::{ + EventMessage, EventSink, PlaylistGroup, PlaylistGroupsChanged, WatchChanges, WatchDisabled, WatchDisabledReason, +}; use std::{collections::BTreeSet, path::Path, sync::Arc}; use tuliprox_core::{ - model::{AppConfig, MessageContent, WatchChanges}, + model::AppConfig, utils, utils::{binary_deserialize, binary_serialize, file_exists_async}, }; -use tuliprox_messaging::send_message; const WATCH_NOTIFICATION_LIST_LIMIT: usize = 120; const WATCH_NOTIFICATION_SUMMARY_THRESHOLD: usize = 500; -pub async fn process_group_watch( +/// Track which *groups* a target has, as opposed to which channels a group has. +/// +/// `process_group_watch` below is blind to the group set in both directions. +/// A group appearing is silent - it finds no baseline, writes one and emits +/// nothing, so the whole channel list reads as "not new" from then on. A +/// group vanishing is never even looked at: it is absent from the refreshed +/// playlist, so nothing iterates it. +/// +/// The index lives beside the per-group directory rather than inside it +/// (`.groups.bin`, not `/__groups.bin`) so it cannot collide +/// with a group whose sanitized title happens to match. +pub async fn process_target_groups_watch( app_config: &Arc, - client: &reqwest::Client, + events: &E, + target_name: &str, + playlist: &[PlaylistGroup], +) { + let new_tree: BTreeSet> = playlist.iter().map(|group| group.title.clone()).collect(); + + let index_filename = format!("{}.groups.bin", utils::sanitize_filename(target_name)); + let cfg = app_config.config.load(); + let Some(path) = utils::get_file_path(&cfg.storage_dir, Some(std::path::PathBuf::from(&index_filename))) else { + error!("failed to resolve a path for group index {index_filename}"); + emit_watch_disabled(events, target_name, "", format!("could not resolve a path for {index_filename}")); + return; + }; + + // First sight writes the baseline and says nothing. Announcing every + // group as new on the first refresh after an upgrade would be noise, not + // news. + let mut changed = true; + if file_exists_async(&path).await { + if let Some(loaded_tree) = load_watch_tree(&path).await { + let added: BTreeSet> = new_tree.difference(&loaded_tree).cloned().collect(); + let removed: BTreeSet> = loaded_tree.difference(&new_tree).cloned().collect(); + if added.is_empty() && removed.is_empty() { + changed = false; + } else { + emit_groups_changed(events, target_name, &added, &removed); + } + } else { + error!("failed to load group index {}", path.to_str().unwrap_or_default()); + emit_watch_disabled( + events, + target_name, + "", + format!("could not read the group index at {}", path.to_str().unwrap_or_default()), + ); + } + } + + if changed { + if let Err(err) = save_watch_tree(path.as_path(), &new_tree).await { + error!("failed to write group index {}: {err}", path.to_str().unwrap_or_default()); + emit_watch_disabled( + events, + target_name, + "", + format!("could not write the group index at {}: {err}", path.to_str().unwrap_or_default()), + ); + } + } +} + +/// Same sampling discipline as the per-group watch: titles only in the lists, +/// counts that stay true whatever the lists carry. +fn emit_groups_changed( + events: &E, + target_name: &str, + added: &BTreeSet>, + removed: &BTreeSet>, +) { + let added_total = added.len(); + let removed_total = removed.len(); + let mut added: Vec = added.iter().map(std::string::ToString::to_string).collect(); + let mut removed: Vec = removed.iter().map(std::string::ToString::to_string).collect(); + let mut truncated = false; + for list in [&mut added, &mut removed] { + if list.len() > WATCH_NOTIFICATION_LIST_LIMIT { + list.truncate(WATCH_NOTIFICATION_LIST_LIMIT); + truncated = true; + } + } + + events.emit(EventMessage::PlaylistGroupsChanged(PlaylistGroupsChanged { + target: target_name.to_string(), + added, + removed, + added_total, + removed_total, + truncated, + })); +} + +pub async fn process_group_watch( + app_config: &Arc, + events: &E, target_name: &str, pl: &PlaylistGroup, ) { @@ -27,96 +122,119 @@ pub async fn process_group_watch( let watch_filename = format!("{}/{}.bin", utils::sanitize_filename(target_name), utils::sanitize_filename(&pl.title)); let cfg = app_config.config.load(); - match utils::get_file_path(&cfg.storage_dir, Some(std::path::PathBuf::from(&watch_filename))) { - Some(path) => { - let save_path = path.as_path(); - let mut changed = false; - if file_exists_async(&path).await { - if let Some(loaded_tree) = load_watch_tree(&path).await { - // Find elements in set2 but not in set1 - let added_difference: BTreeSet> = new_tree.difference(&loaded_tree).cloned().collect(); - let removed_difference: BTreeSet> = loaded_tree.difference(&new_tree).cloned().collect(); - if !added_difference.is_empty() || !removed_difference.is_empty() { - changed = true; - handle_watch_notification( - app_config, - client, - &added_difference, - &removed_difference, - target_name, - &pl.title, - ) - .await; - } - } else { - error!("failed to load watch_file {}", path.to_str().unwrap_or_default()); - changed = true; - } - } else { - changed = true; - } + let Some(path) = utils::get_file_path(&cfg.storage_dir, Some(std::path::PathBuf::from(&watch_filename))) else { + error!("failed to write watch_file {watch_filename}"); + emit_watch_disabled( + events, + target_name, + &pl.title, + format!("could not resolve a storage path for {watch_filename}"), + ); + return; + }; + + let save_path = path.as_path(); + let mut changed = true; + if file_exists_async(&path).await { + if let Some(loaded_tree) = load_watch_tree(&path).await { + // Find elements in set2 but not in set1 + let added_difference: BTreeSet> = new_tree.difference(&loaded_tree).cloned().collect(); + let removed_difference: BTreeSet> = loaded_tree.difference(&new_tree).cloned().collect(); + changed = !added_difference.is_empty() || !removed_difference.is_empty(); if changed { - match save_watch_tree(save_path, &new_tree).await { - Ok(()) => {} - Err(err) => { - error!("failed to write watch_file {}: {err}", save_path.to_str().unwrap_or_default()); - } - } + handle_watch_notification(events, &added_difference, &removed_difference, target_name, &pl.title); } + } else { + error!("failed to load watch_file {}", path.to_str().unwrap_or_default()); + // The baseline is unreadable, so this refresh re-baselines and the + // diff it should have produced is gone. Silently doing that leaves + // the operator believing the group is being watched. + emit_watch_disabled( + events, + target_name, + &pl.title, + format!("could not read watch state at {}", path.to_str().unwrap_or_default()), + ); } - None => { - error!("failed to write watch_file {watch_filename}"); + } + + if changed { + if let Err(err) = save_watch_tree(save_path, &new_tree).await { + error!("failed to write watch_file {}: {err}", save_path.to_str().unwrap_or_default()); + emit_watch_disabled( + events, + target_name, + &pl.title, + format!("could not write watch state at {}: {err}", save_path.to_str().unwrap_or_default()), + ); } } } -async fn handle_watch_notification( - app_config: &Arc, - client: &reqwest::Client, +/// Report that a group's watch state could not be read or written. +/// +/// Each of these paths used to log once and carry on, which leaves the group +/// either silently re-baselining - losing the change it should have reported +/// - or not tracked at all. +fn emit_watch_disabled(events: &E, target_name: &str, group_name: &str, detail: String) { + let mut disabled = + WatchDisabled::new(target_name.to_string(), WatchDisabledReason::StorageFailure).with_detail(detail); + // The target-level group index has no group of its own; leaving the field + // unset is more honest than naming an empty one. + if !group_name.is_empty() { + disabled = disabled.with_group(group_name.to_string()); + } + events.emit(EventMessage::PlaylistWatchDisabled(disabled)); +} + +/// Turn a group's membership delta into an event. +/// +/// Large changes are sampled rather than listed in full, but the sampling is +/// reported in [`WatchChanges::added_total`] / [`WatchChanges::removed_total`] +/// and [`WatchChanges::truncated`] rather than written into the lists as prose. +fn handle_watch_notification( + events: &E, added: &BTreeSet>, removed: &BTreeSet>, target_name: &str, group_name: &str, ) { - let added_count = added.len(); - let removed_count = removed.len(); - let total_changed = added_count.saturating_add(removed_count); + let added_total = added.len(); + let removed_total = removed.len(); + let total_changed = added_total.saturating_add(removed_total); + if total_changed == 0 { + return; + } - let mut added = added.iter().map(std::string::ToString::to_string).collect::>(); - let mut removed = removed.iter().map(std::string::ToString::to_string).collect::>(); - if !added.is_empty() || !removed.is_empty() { - if total_changed > WATCH_NOTIFICATION_SUMMARY_THRESHOLD { - added = if added_count > 0 { - vec![format!( - "{added_count} entries added. Detailed list suppressed for large update ({total_changed} total changes)." - )] - } else { - vec![] - }; - removed = if removed_count > 0 { - vec![format!( - "{removed_count} entries removed. Detailed list suppressed for large update ({total_changed} total changes)." - )] - } else { - vec![] - }; - } else { - if added.len() > WATCH_NOTIFICATION_LIST_LIMIT { - let omitted = added.len() - WATCH_NOTIFICATION_LIST_LIMIT; - added.truncate(WATCH_NOTIFICATION_LIST_LIMIT); - added.push(format!("... {omitted} more added entries omitted")); - } - if removed.len() > WATCH_NOTIFICATION_LIST_LIMIT { - let omitted = removed.len() - WATCH_NOTIFICATION_LIST_LIMIT; - removed.truncate(WATCH_NOTIFICATION_LIST_LIMIT); - removed.push(format!("... {omitted} more removed entries omitted")); + let mut added: Vec = added.iter().map(std::string::ToString::to_string).collect(); + let mut removed: Vec = removed.iter().map(std::string::ToString::to_string).collect(); + let mut truncated = false; + + if total_changed > WATCH_NOTIFICATION_SUMMARY_THRESHOLD { + // A whole-provider reshuffle. Nobody reads ten thousand titles, and + // every channel routing this pays for the bytes, so send the counts + // alone. + added.clear(); + removed.clear(); + truncated = true; + } else { + for list in [&mut added, &mut removed] { + if list.len() > WATCH_NOTIFICATION_LIST_LIMIT { + list.truncate(WATCH_NOTIFICATION_LIST_LIMIT); + truncated = true; } } - - let changes = WatchChanges { target: target_name.to_string(), group: group_name.to_string(), added, removed }; - - send_message(app_config, client, MessageContent::Watch(changes)).await; } + + events.emit(EventMessage::PlaylistWatchChanged(WatchChanges { + target: target_name.to_string(), + group: group_name.to_string(), + added, + removed, + added_total, + removed_total, + truncated, + })); } async fn load_watch_tree(path: &Path) -> Option>> { @@ -132,3 +250,82 @@ async fn save_watch_tree(path: &Path, tree: &BTreeSet>) -> std::io::Res let encoded: Vec = binary_serialize(&tree)?; tokio::fs::write(path, encoded).await } + +#[cfg(test)] +mod tests { + use super::{handle_watch_notification, WATCH_NOTIFICATION_LIST_LIMIT, WATCH_NOTIFICATION_SUMMARY_THRESHOLD}; + use shared::model::{EventMessage, EventSink, WatchChanges}; + use std::{ + collections::BTreeSet, + sync::{Arc, Mutex}, + }; + + #[derive(Default)] + struct CollectSink(Mutex>); + + impl EventSink for CollectSink { + fn emit(&self, event: EventMessage) { + if let EventMessage::PlaylistWatchChanged(changes) = event { + self.0.lock().unwrap().push(changes); + } + } + } + + fn titles(count: usize) -> BTreeSet> { + // Zero-padded so the `BTreeSet` order matches the numeric order, which + // keeps the truncation assertions readable. + (0..count).map(|i| Arc::from(format!("Channel {i:05}").as_str())).collect() + } + + fn emit(added: usize, removed: usize) -> WatchChanges { + let sink = CollectSink::default(); + handle_watch_notification(&sink, &titles(added), &titles(removed), "target", "group"); + let mut captured = sink.0.lock().unwrap(); + assert_eq!(captured.len(), 1, "expected exactly one event"); + captured.pop().unwrap() + } + + #[test] + fn an_unchanged_group_emits_nothing() { + let sink = CollectSink::default(); + handle_watch_notification(&sink, &BTreeSet::new(), &BTreeSet::new(), "target", "group"); + assert!(sink.0.lock().unwrap().is_empty()); + } + + #[test] + fn a_small_change_lists_every_title() { + let changes = emit(3, 2); + assert_eq!(changes.added.len(), 3); + assert_eq!(changes.removed.len(), 2); + assert_eq!(changes.added_total, 3); + assert_eq!(changes.removed_total, 2); + assert!(!changes.truncated); + } + + /// The regression this whole field set exists for: the lists used to carry + /// a synthesised "... N more entries omitted" string beside real channel + /// titles, which a plugin reading the JSON payload could not distinguish + /// from a channel actually named that. + #[test] + fn a_truncated_list_carries_only_channel_titles() { + let added = WATCH_NOTIFICATION_LIST_LIMIT + 40; + let changes = emit(added, 0); + + assert_eq!(changes.added.len(), WATCH_NOTIFICATION_LIST_LIMIT); + assert_eq!(changes.added_total, added, "the total must survive the truncation"); + assert!(changes.truncated); + for title in &changes.added { + assert!(title.starts_with("Channel "), "list carries prose, not a channel title: {title}"); + } + } + + #[test] + fn a_very_large_change_reports_counts_only() { + let added = WATCH_NOTIFICATION_SUMMARY_THRESHOLD + 100; + let changes = emit(added, 0); + + assert!(changes.added.is_empty(), "a change this size should not list titles at all"); + assert_eq!(changes.added_total, added); + assert!(changes.truncated); + } +} diff --git a/backend/processing/src/processor/mod.rs b/backend/processing/src/processor/mod.rs index 7219bd593..0d97f33a5 100644 --- a/backend/processing/src/processor/mod.rs +++ b/backend/processing/src/processor/mod.rs @@ -7,6 +7,7 @@ mod deduplicate; mod epg; mod library; mod probe_handle_guard; +mod providers; mod resolve_options; mod sort; mod stream_probe; @@ -17,6 +18,7 @@ pub use self::{ epg::*, playlist::*, probe_handle_guard::*, + providers::*, resolve_options::*, stalker::{download_stalker_playlist, re_resolve_stalker_url, StalkerCluster}, stalker_refresh::StalkerRefreshMode, diff --git a/backend/processing/src/processor/playlist.rs b/backend/processing/src/processor/playlist.rs index de529731e..aea03bcff 100644 --- a/backend/processing/src/processor/playlist.rs +++ b/backend/processing/src/processor/playlist.rs @@ -1,13 +1,13 @@ +use super::providers::{LibraryProvider, PlexProvider, StalkerProvider, XmltvEpgProvider}; use crate::{ - epg, fetched_playlist::FetchedPlaylist, input_cache, input_cache::ClusterState, - metadata_sink::MetadataUpdateSink, + metadata_sink::{MetadataUpdateSink, NoopMetadataSink}, parser::xmltv::{flatten_tvguide, merge_epg_trees, EpgMergeAccumulator, TVGuide}, - playlist_watch::process_group_watch, + playlist_watch::{process_group_watch, process_target_groups_watch}, processor::{ - epg::process_playlist_epg, library, sort::sort_playlist, stalker, trakt::process_trakt_categories_for_target, + epg::process_playlist_epg, sort::sort_playlist, trakt::process_trakt_categories_for_target, xtream_series::playlist_resolve_series, xtream_vod::playlist_resolve_vod, StalkerRefreshMode, }, }; @@ -21,17 +21,17 @@ use shared::{ error::{get_errors_notify_message, TuliproxError}, foundation::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}, model::{ - ClusterFlags, CounterModifier, FieldGetAccessor, FieldSetAccessor, InputStats, InputType, ItemField, - MappingStage, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, PlaylistUpdateProgressEvent, - ProcessingOrder, SourceStats, StreamProperties, TargetStats, UUIDType, XtreamCluster, + ClusterFlags, CounterModifier, EventMessage, EventSink, FieldGet, FieldSet, InputStats, InputType, + MappingStage, PipelineStats, PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistStats, + PlaylistUpdateProgressEvent, PlaylistUpdateSummary, ProviderFetchFailure, SourceStats, StreamProperties, + TargetStats, UUIDType, WatchDisabled, WatchDisabledReason, WatchUnmatched, XtreamCluster, }, - utils::{create_alias_uuid, interner_gc, Internable}, + utils::{create_alias_uuid, interner_gc, sanitize_sensitive_info, Internable}, }; use std::{ collections::{HashMap, HashSet}, future::Future, path::PathBuf, - pin::Pin, sync::{Arc, Weak}, time::{Duration, Instant}, }; @@ -41,23 +41,26 @@ use tokio::{ }; use tuliprox_core::{ model::{ - is_valid, AppConfig, ConfigFavourites, ConfigInput, ConfigInputFlags, ConfigInputOptions, ConfigRename, - ConfigTarget, Epg, Mapping, MessageContent, ProcessTargets, ProviderIdType, ResolveReason, - ReverseProxyDisabledHeaderConfig, UpdateGuard, UpdateTask, + is_valid, AppConfig, CompiledMapping, ConfigFavourites, ConfigInput, ConfigInputFlags, ConfigInputOptions, + ConfigRename, ConfigTarget, Epg, MappingProgram, ProcessTargets, ProviderIdType, ResolveReason, + ReverseProxyDisabledHeaderConfig, TransformStage, UpdateGuard, UpdateTask, }, utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback}, }; -use tuliprox_iptv::{m3u, xtream}; -use tuliprox_media_server::{ - media_server_catalog_snapshot_to_playlist, refresh_media_server_catalog_complete_before_publish, - MediaServerCatalogRefreshPolicy, MediaServerHttpClient, +use tuliprox_iptv::{ + epg::{CountingEpgSink, EpgFetchRequest, EpgProvider}, + error::ProviderErrorKind, + provider::{ + BatchContainerProvider, M3uProvider, PlaylistFetch, PlaylistFetchRequest, PlaylistProvider, + UnsupportedProvider, XtreamProvider, + }, + xtream, }; -use tuliprox_messaging::send_message; use tuliprox_repository::{ load_input_playlist, persist_input_playlist, persist_playlist, CategoryKey, MemoryPlaylistSource, PlaylistSource, PlaylistStorageState, }; -use tuliprox_session::{ActiveProviderManager, EventManager, EventMessage}; +use tuliprox_session::ActiveProviderManager; const PLAYLIST_UPDATE_MAX_DURATION_SECS: u64 = 3600; const MAX_CONCURRENT_TARGET_FINALIZERS: usize = 2; @@ -109,10 +112,23 @@ fn stalker_checkpoint_message(input: &str) -> String { format!("Input '{input}': Stalker refresh checkpoint saved; active snapshot remains in service") } -pub fn apply_filter_to_source(source: &mut PlaylistSource, filter: &Filter) -> Option> { +#[derive(Debug, Default, PartialEq, Eq)] +pub struct FilterOutcome { + pub inspected: usize, + pub retained: usize, + pub removed: usize, +} + +fn retain_playlist_items( + source: &mut PlaylistSource, + mut keep: impl FnMut(&PlaylistItem) -> bool, +) -> (Option>, FilterOutcome) { let mut groups: IndexMap = IndexMap::new(); + let mut outcome = FilterOutcome::default(); for pli in source.into_items() { - if is_valid(&pli, filter, false) { + outcome.inspected += 1; + if keep(&pli) { + outcome.retained += 1; let group_title = pli.header.group.clone(); let cluster = pli.header.xtream_cluster; let cat_id = pli.header.category_id; @@ -128,18 +144,17 @@ pub fn apply_filter_to_source(source: &mut PlaylistSource, filter: &Filter) -> O }) .channels .push(pli); + } else { + outcome.removed += 1; } } - if groups.is_empty() { - None - } else { - Some(groups.into_values().collect()) - } + let groups = if groups.is_empty() { None } else { Some(groups.into_values().collect()) }; + (groups, outcome) } -fn filter_playlist(source: &mut PlaylistSource, target: &ConfigTarget) -> Option> { - apply_filter_to_source(source, &target.filter) +pub fn apply_filter_to_source(source: &mut PlaylistSource, filter: &Filter) -> Option> { + retain_playlist_items(source, |item| is_valid(item, filter, false)).0 } fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) { @@ -159,7 +174,61 @@ fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) { } } -fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>) { +#[derive(Debug, Default, PartialEq, Eq)] +pub struct RenameOutcome { + pub inspected: usize, + pub changed_items: usize, + pub changed_fields: usize, +} + +#[derive(Debug, Default)] +pub struct PipelineOutcome { + pub filter: Option, + pub rename: Option, + pub mapping: Option, +} + +impl PipelineOutcome { + fn merge(&mut self, other: Self) { + if let Some(value) = other.filter { + let outcome = self.filter.get_or_insert_with(FilterOutcome::default); + outcome.inspected += value.inspected; + outcome.retained += value.retained; + outcome.removed += value.removed; + } + if let Some(value) = other.rename { + let outcome = self.rename.get_or_insert_with(RenameOutcome::default); + outcome.inspected += value.inspected; + outcome.changed_items += value.changed_items; + outcome.changed_fields += value.changed_fields; + } + if let Some(value) = other.mapping { + let outcome = self.mapping.get_or_insert_with(MappingStageOutcome::default); + outcome.inspected += value.inspected; + outcome.matched_rules += value.matched_rules; + outcome.emitted_items += value.emitted_items; + outcome.changed_fields.extend(value.changed_fields); + outcome.diagnostics += value.diagnostics; + outcome.reported_diagnostics += value.reported_diagnostics; + } + } + + fn to_stats(&self) -> PipelineStats { + PipelineStats { + inspected: self.filter.as_ref().map_or(0, |outcome| outcome.inspected), + retained: self.filter.as_ref().map_or(0, |outcome| outcome.retained), + removed: self.filter.as_ref().map_or(0, |outcome| outcome.removed), + renamed_items: self.rename.as_ref().map_or(0, |outcome| outcome.changed_items), + renamed_fields: self.rename.as_ref().map_or(0, |outcome| outcome.changed_fields), + matched_mapping_rules: self.mapping.as_ref().map_or(0, |outcome| outcome.matched_rules), + emitted_items: self.mapping.as_ref().map_or(0, |outcome| outcome.emitted_items), + mapping_diagnostics: self.mapping.as_ref().map_or(0, |outcome| outcome.diagnostics), + } + } +} + +fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>) -> usize { + let mut changed_fields = 0; if let Some(renames) = rename { if !renames.is_empty() { let result = pli; @@ -169,88 +238,88 @@ fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>) { if log_enabled!(log::Level::Debug) && *value != *cap { trace_if_enabled!("Renamed {}={value} to {cap}", &r.field); } - set_field_value(result, r.field, cap.as_ref()); - } - } - } -} - -fn rename_playlist(source: &mut PlaylistSource, target: &ConfigTarget) -> Option> { - match &target.rename { - Some(renames) if !renames.is_empty() => { - let mut groups: IndexMap<(XtreamCluster, Arc), PlaylistGroup> = IndexMap::new(); - for mut pli in source.into_items() { - // Handle group rename first if it's in the renames - for r in renames { - if matches!(r.field, ItemField::Group) { - let value = &*pli.header.group; - let cap = r.pattern.replace_all(value, &r.new_name); - if *value != cap { - pli.header.group = cap.intern(); - } - } + if *value != *cap && set_field_value(result, r.field, cap.as_ref()) { + changed_fields += 1; } - exec_rename(&mut pli, Some(renames)); - let group_title = pli.header.group.clone(); - let cluster = pli.header.xtream_cluster; - let cat_id = pli.header.category_id; - groups - .entry((cluster, group_title.clone())) - .or_insert_with(|| PlaylistGroup { - id: cat_id, - title: group_title, - channels: vec![], - xtream_cluster: cluster, - }) - .channels - .push(pli); } - Some(groups.into_values().collect()) } - _ => None, + } + changed_fields +} + +struct ChannelMappingOutcome { + channel: PlaylistItem, + virtual_items: Vec, + matched_rules: usize, + changed_fields: HashSet, + diagnostics: Vec, +} + +const MAPPING_DIAGNOSTIC_LIMIT: usize = 10; + +#[derive(Debug, Default)] +pub struct MappingStageOutcome { + pub inspected: usize, + pub matched_rules: usize, + pub emitted_items: usize, + pub changed_fields: HashSet, + pub diagnostics: usize, + pub reported_diagnostics: usize, +} + +impl MappingStageOutcome { + fn record(&mut self, mapping_id: &str, outcome: &ChannelMappingOutcome) { + self.inspected += 1; + self.matched_rules += outcome.matched_rules; + self.emitted_items += outcome.virtual_items.len(); + self.changed_fields.extend(outcome.changed_fields.iter().cloned()); + self.diagnostics += outcome.diagnostics.len(); + for diagnostic in &outcome.diagnostics { + if self.reported_diagnostics >= MAPPING_DIAGNOSTIC_LIMIT { + break; + } + warn!("Mapping '{mapping_id}' {diagnostic}"); + self.reported_diagnostics += 1; + } } } -fn map_channel(mut channel: PlaylistItem, mapping: &Mapping) -> (PlaylistItem, Vec, bool) { - let mut matched = false; +fn map_channel(mut channel: PlaylistItem, mapping: &CompiledMapping) -> ChannelMappingOutcome { + let mut matched_rules = 0; let mut virtual_items = vec![]; - if let Some(mapper) = &mapping.mapper { - if !mapper.is_empty() { - let ref_chan = &mut channel; - let templates = mapping.templates.as_ref(); - for m in mapper { - if let Some(script) = m.t_script.as_ref() { - if let Some(filter) = &m.t_filter { - let provider = ValueProvider { pli: ref_chan, match_as_ascii: mapping.match_as_ascii }; - if filter.filter(&provider) { - matched = true; - let mut accessor = ValueAccessor { - pli: ref_chan, - virtual_items: vec![], - match_as_ascii: mapping.match_as_ascii, - }; - script.eval(&mut accessor, templates.map(Vec::as_slice)); - virtual_items.extend(accessor.virtual_items.into_iter().map(|(_, pli)| pli)); - } - } + let mut changed_fields = HashSet::new(); + let mut diagnostics = Vec::new(); + if !mapping.rules.is_empty() { + let ref_chan = &mut channel; + let templates = mapping.templates.as_deref(); + for (rule_index, rule) in mapping.rules.iter().enumerate() { + let provider = ValueProvider { pli: ref_chan, match_as_ascii: mapping.match_as_ascii }; + if rule.filter.filter(&provider) { + matched_rules += 1; + let mut accessor = ValueAccessor { + pli: ref_chan, + virtual_items: vec![], + match_as_ascii: mapping.match_as_ascii, + changed_fields: vec![], + }; + let outcome = match &rule.program { + MappingProgram::Script(script) => script.eval(&mut accessor, templates), + }; + changed_fields.extend(outcome.changed_fields.iter().cloned()); + for diagnostic in outcome.diagnostics { + let rule_label = rule.name.as_deref().map_or_else(|| (rule_index + 1).to_string(), str::to_string); + diagnostics.push(format!( + "rule '{rule_label}' failed for channel '{}' at statement {}: {}", + accessor.pli.header.name, + diagnostic.statement + 1, + diagnostic.message + )); } + virtual_items.extend(accessor.virtual_items.into_iter().map(|(_, pli)| pli)); } } } - (channel, virtual_items, matched) -} - -fn map_channel_and_flatten(channel: PlaylistItem, mapping: &Mapping) -> Vec { - let (mapped_channel, mut virtual_items, _matched) = map_channel(channel, mapping); - let mut result = Vec::with_capacity(1 + virtual_items.len()); - - result.push(mapped_channel); - result.append(&mut virtual_items); - result -} - -fn map_playlist(source: &mut PlaylistSource, target: &ConfigTarget) -> Option> { - map_playlist_at_stage(source, target, MappingStage::Processing, None) + ChannelMappingOutcome { channel, virtual_items, matched_rules, changed_fields, diagnostics } } fn map_playlist_at_stage( @@ -259,84 +328,117 @@ fn map_playlist_at_stage( stage: MappingStage, duplicates: Option<&mut HashSet>, ) -> Option> { + if !has_mapping_stage(target, stage) { + return None; + } + let items = source.into_items().collect::>(); + let (mapped_items, _outcome) = map_items_at_stage(items, target, stage, duplicates)?; + Some(group_mapped_items(mapped_items)) +} + +fn has_mapping_stage(target: &ConfigTarget, stage: MappingStage) -> bool { + target.mapping.load().as_ref().is_some_and(|mappings| !mappings.for_stage(stage).is_empty()) +} + +fn map_items_at_stage( + mut mapped_items: Vec, + target: &ConfigTarget, + stage: MappingStage, + duplicates: Option<&mut HashSet>, +) -> Option<(Vec, MappingStageOutcome)> { let mapping_binding = target.mapping.load(); let mappings = mapping_binding.as_ref()?; - let mut valid_mappings = mappings - .iter() - .filter(|m| m.stage == stage && m.mapper.as_ref().is_some_and(|items| !items.is_empty())) - .peekable(); - valid_mappings.peek()?; + let valid_mappings = mappings.for_stage(stage); + if valid_mappings.is_empty() { + return None; + } let original_ids = if duplicates.is_some() { - Some(source.items().map(|item| *item.header.get_uuid()).collect::>()) + Some(mapped_items.iter().map(|item| *item.header.get_uuid()).collect::>()) } else { None }; - let iter: Box> = Box::new(source.into_items()); - let mapped_iter = valid_mappings.fold(iter, |iter, mapping| { - Box::new(iter.flat_map(move |chan| map_channel_and_flatten(chan, mapping))) - as Box> - }); - let mut next_groups: IndexMap = IndexMap::new(); - let mut grp_id: u32 = 0; - for channel in mapped_iter { + let mut stage_outcome = MappingStageOutcome::default(); + for mapping in valid_mappings { + let mut next_items = Vec::with_capacity(mapped_items.len()); + for channel in mapped_items { + let outcome = map_channel(channel, mapping); + stage_outcome.record(&mapping.id, &outcome); + next_items.push(outcome.channel); + next_items.extend(outcome.virtual_items); + } + mapped_items = next_items; + } + debug!( + "Mapping stage {stage:?}: inspected={}, matched_rules={}, emitted={}, changed_fields={}, diagnostics={}, suppressed_diagnostics={}", + stage_outcome.inspected, + stage_outcome.matched_rules, + stage_outcome.emitted_items, + stage_outcome.changed_fields.len(), + stage_outcome.diagnostics, + stage_outcome.diagnostics.saturating_sub(stage_outcome.reported_diagnostics) + ); + let suppressed = stage_outcome.diagnostics.saturating_sub(stage_outcome.reported_diagnostics); + if suppressed > 0 { + warn!("Mapping stage {stage:?} suppressed {suppressed} additional diagnostics"); + } + if let (Some(original_ids), Some(duplicates)) = (original_ids, duplicates) { + mapped_items.retain(|item| { + let uuid = *item.header.get_uuid(); + original_ids.contains(&uuid) || duplicates.insert(uuid) + }); + } + Some((mapped_items, stage_outcome)) +} + +fn group_mapped_items(items: Vec) -> Vec { + let mut groups: IndexMap = IndexMap::new(); + let mut group_id = 0; + for channel in items { let group_title = channel.header.group.clone(); let cluster = channel.header.xtream_cluster; - next_groups + groups .entry((cluster, group_title.clone())) .or_insert_with(|| { - grp_id += 1; - PlaylistGroup { id: grp_id, title: group_title, channels: Vec::new(), xtream_cluster: cluster } + group_id += 1; + PlaylistGroup { id: group_id, title: group_title, channels: Vec::new(), xtream_cluster: cluster } }) .channels .push(channel); } - - let mut groups = next_groups.into_values().collect::>(); - if let (Some(original_ids), Some(duplicates)) = (original_ids, duplicates) { - for group in &mut groups { - group.channels.retain(|item| { - let uuid = *item.header.get_uuid(); - original_ids.contains(&uuid) || duplicates.insert(uuid) - }); - } - } - Some(groups) + groups.into_values().collect() } fn map_playlist_counter(target: &ConfigTarget, playlist: &mut [PlaylistGroup]) { if let Some(guard) = &*target.mapping.load() { - let mappings = guard.as_ref(); - for mapping in mappings { - if let Some(counter_list) = &mapping.t_counter { - for counter in counter_list { - // fresh per target/call. No shared atomic, no cross-refresh carry-over. - let mut current = counter.start; - for plg in &mut *playlist { - for channel in &mut plg.channels { - let provider = ValueProvider { pli: channel, match_as_ascii: mapping.match_as_ascii }; - if counter.filter.filter(&provider) { - let cntval = current; - current += 1; - let padded_cntval = if counter.padding > 0 { - format!("{:0width$}", cntval, width = counter.padding as usize) + for mapping in &guard.all { + for counter in &mapping.counters { + // fresh per target/call. No shared atomic, no cross-refresh carry-over. + let mut current = counter.start; + for plg in &mut *playlist { + for channel in &mut plg.channels { + let provider = ValueProvider { pli: channel, match_as_ascii: mapping.match_as_ascii }; + if counter.filter.filter(&provider) { + let cntval = current; + current += 1; + let padded_cntval = if counter.padding > 0 { + format!("{:0width$}", cntval, width = counter.padding as usize) + } else { + cntval.to_string() + }; + let new_value = if counter.modifier == CounterModifier::Assign { + padded_cntval + } else { + let value = channel + .header + .get(counter.field) + .map_or_else(String::new, |field_value| field_value.as_cow().into_owned()); + if counter.modifier == CounterModifier::Suffix { + format!("{value}{}{padded_cntval}", counter.concat) } else { - cntval.to_string() - }; - let new_value = if counter.modifier == CounterModifier::Assign { - padded_cntval - } else { - let value = channel - .header - .get_field(&counter.field) - .map_or_else(String::new, |field_value| field_value.to_string()); - if counter.modifier == CounterModifier::Suffix { - format!("{value}{}{padded_cntval}", counter.concat) - } else { - format!("{padded_cntval}{}{value}", counter.concat) - } - }; - channel.header.set_field(&counter.field, new_value.as_str()); - } + format!("{padded_cntval}{}{value}", counter.concat) + } + }; + channel.header.set(counter.field, new_value.as_str()); } } } @@ -404,35 +506,6 @@ fn collect_effective_skip_clusters(input: &ConfigInput) -> Vec { xtream::get_skip_cluster(input) } -async fn download_plex_media_server_playlist( - client: &reqwest::Client, - input: &ConfigInput, -) -> (Vec, Vec) { - let Some(media_server) = input.media_server.as_ref() else { - return ( - vec![], - vec![TuliproxError::Download(format!( - "media-server input '{}' is missing media_server configuration", - input.name - ))], - ); - }; - let http_client = MediaServerHttpClient::new(client.clone()); - let plex_client = match input.plex_catalog_client(http_client) { - Ok(client) => client, - Err(error) => return (vec![], vec![TuliproxError::Download(error.to_string())]), - }; - let policy = MediaServerCatalogRefreshPolicy { - page_size: usize::from(media_server.catalog.page_size), - request_delay_ms: media_server.catalog.request_delay_ms, - }; - - match refresh_media_server_catalog_complete_before_publish(&plex_client, policy).await { - Ok(snapshot) => (media_server_catalog_snapshot_to_playlist(&snapshot), vec![]), - Err(error) => (vec![], vec![TuliproxError::Download(error.to_string())]), - } -} - fn filter_skipped_clusters_from_source(source: PlaylistSource, input: &ConfigInput) -> PlaylistSource { let skip_clusters = collect_effective_skip_clusters(input); if skip_clusters.is_empty() { @@ -475,9 +548,10 @@ fn apply_staged_overlay_groups( fn should_apply_staged_overlay(download_result: &PlaylistDownloadResult) -> bool { !download_result.was_cached } #[allow(clippy::too_many_lines)] -async fn playlist_download_from_input( +async fn playlist_download_from_input( client: &reqwest::Client, app_config: &Arc, + events: &E, input: &ConfigInput, stalker_refresh_mode: StalkerRefreshMode, ) -> PlaylistDownloadResult { @@ -526,71 +600,69 @@ async fn playlist_download_from_input( return PlaylistDownloadResult::new(vec![], vec![], true, false); } - let (playlist, errors, persisted, _m3u_error_count, _xtream_error_count, partial) = { - match download_input_type { - InputType::M3u => { - let (p, e) = m3u::download_m3u_playlist(app_config, client, config, input).await; - (p, e, false, 0, 0, false) - } - InputType::Xtream => { - let (p, e, persisted) = xtream::download_xtream_playlist( - app_config, - client, - input, - Some(xtream_clusters_to_download.as_slice()), - ) - .await; - let xtream_error_count = e.len(); - (p, e, persisted, 0, xtream_error_count, false) - } - InputType::M3uBatch | InputType::XtreamBatch | InputType::StalkerBatch => { - (vec![], vec![], false, 0, 0, false) - } - InputType::Stalker => { - let (p, e, persisted, partial) = stalker::download_stalker_playlist( - app_config, - client, - input, - None, - stalker_refresh_mode, - !config.disk_based_processing, - ) - .await; - let stalker_error_count = e.len(); - (p, e, persisted, 0, stalker_error_count, partial) - } - InputType::Library => { - let (p, e) = library::download_library_playlist(client, app_config, input).await; - (p, e, false, 0, 0, false) - } - InputType::Plex => { - let (p, e) = download_plex_media_server_playlist(client, input).await; - (p, e, false, 0, 0, false) - } - InputType::Emby | InputType::Jellyfin => ( - vec![], - vec![TuliproxError::Download(format!( - "media-server input '{}' is configured but catalog import is not implemented yet", - input.name - ))], - false, - 0, - 0, - false, - ), - InputType::Staged => ( - vec![], - vec![TuliproxError::Download(format!( - "staged input '{}' was not resolved against a parent input", - input.name - ))], - false, - 0, - 0, - false, - ), + let request = PlaylistFetchRequest { + app_config, + config: &app_config.config.load(), + client, + input, + xtream_clusters: Some(xtream_clusters_to_download.as_slice()), + }; + + // Each arm builds the provider its input type needs and awaits it in place: the + // provider types share no supertype, and building one is free, so this stays a match + // and stays statically dispatched. What changed is the result - one named + // `PlaylistFetch` instead of a six-element tuple assembled by position. + let fetch = match download_input_type { + InputType::M3u => M3uProvider.fetch(&request).await, + InputType::Xtream => XtreamProvider::new(events).fetch(&request).await, + InputType::M3uBatch | InputType::XtreamBatch | InputType::StalkerBatch => { + BatchContainerProvider.fetch(&request).await + } + InputType::Stalker => { + StalkerProvider::new(stalker_refresh_mode, !config.disk_based_processing).fetch(&request).await + } + InputType::Library => LibraryProvider.fetch(&request).await, + InputType::Plex => PlexProvider.fetch(&request).await, + InputType::Emby | InputType::Jellyfin => { + UnsupportedProvider::new( + "media-server", + format!("media-server input '{}' is configured but catalog import is not implemented yet", input.name), + ) + .fetch(&request) + .await + } + InputType::Staged => { + UnsupportedProvider::new( + "staged", + format!("staged input '{}' was not resolved against a parent input", input.name), + ) + .fetch(&request) + .await } }; + // `ProviderErrorKind` has always been able to answer "is this worth + // retrying, and does it need a human" - `needs_operator()` is exactly that + // question - and nothing consumed the answer. Every fetch failure was + // counted, logged and treated identically. + if let Some(kind) = fetch.error_kind() { + let worst = fetch + .errors + .iter() + .max_by_key(|error| ProviderErrorKind::of_tuliprox(error)) + .map(|error| sanitize_sensitive_info(&error.to_string()).into_owned()); + events.emit(EventMessage::ProviderFetchFailed(ProviderFetchFailure { + input: sanitize_sensitive_info(&input.name).into_owned().into(), + provider: download_input_type.to_string().into(), + kind: kind.into(), + error_count: fetch.errors.len(), + message: worst, + retryable: kind.is_retryable(), + needs_operator: kind.needs_operator(), + partial: fetch.partial, + })); + } + + let PlaylistFetch { groups: playlist, errors, persisted, partial } = fetch; // Update Status let save_status; @@ -641,9 +713,9 @@ struct InputJobResult { errors: Vec, } -async fn process_input_job( +async fn process_input_job( index: usize, - ctx: &PlaylistProcessingContext, + ctx: &PlaylistProcessingContext, input: &Arc, process_parallel: bool, ) -> InputJobResult { @@ -656,14 +728,14 @@ async fn process_input_job( .await } -async fn process_input_job_inner( +async fn process_input_job_inner( index: usize, - ctx: &PlaylistProcessingContext, + ctx: &PlaylistProcessingContext, input: &Arc, ) -> InputJobResult { let start_time = Instant::now(); let input_type = input.get_download_input_type(); - let broadcast_step = create_broadcast_callback(ctx.event_manager.as_ref()); + let broadcast_step = create_broadcast_callback(&ctx.events); broadcast_step("Playlist download", &format!("Downloading input '{}'", input.name)); let (mut errors, mut source, storage_error, partial) = download_input(ctx, input, false).await; @@ -729,6 +801,7 @@ struct TargetJobResult { name: String, result: Result<(), Vec>, errors: Vec, + processing: PipelineStats, } fn collect_target_task_result( @@ -755,8 +828,8 @@ async fn wait_for_target_finalizer_slot( } #[allow(clippy::too_many_lines)] -async fn process_targets( - ctx: &Arc, +async fn process_targets( + ctx: &Arc>, playlists: &mut [FetchedPlaylist<'_>], targets: &[&Arc], input_stats: &mut HashMap, InputStats>, @@ -771,12 +844,13 @@ async fn process_targets( prepare_playlist_for_target(ctx, playlists, target, input_stats, errors, consume_input_source).await; match result { Ok(prepared) => { + let processing = prepared.processing.clone(); let (result, mut finalization_errors) = finalize_prepared_target(Arc::clone(ctx), prepared).await; errors.append(&mut finalization_errors); match result { - Ok(()) => target_stats.push(TargetStats::success(&target.name)), + Ok(()) => target_stats.push(TargetStats::success_with_processing(&target.name, processing)), Err(mut target_errors) => { - target_stats.push(TargetStats::failure(&target.name)); + target_stats.push(TargetStats::failure_with_processing(&target.name, processing)); errors.append(&mut target_errors); } } @@ -811,6 +885,7 @@ async fn process_targets( match prepare_playlist_for_target(ctx, playlists, target, input_stats, errors, false).await { Ok(prepared) => { + let processing = prepared.processing.clone(); let task_ctx = Arc::clone(ctx); let target_name = target.name.clone(); tasks.spawn(async move { @@ -823,7 +898,9 @@ async fn process_targets( std::panic::AssertUnwindSafe(finalize_prepared_target(task_ctx, prepared)).catch_unwind().await; completion.send_replace(true); match finalized { - Ok((result, errors)) => TargetJobResult { index, name: target_name, result, errors }, + Ok((result, errors)) => { + TargetJobResult { index, name: target_name, result, errors, processing } + } Err(_) => TargetJobResult { index, name: target_name.clone(), @@ -831,6 +908,7 @@ async fn process_targets( "Target '{target_name}' finalization panicked" ))]), errors: Vec::new(), + processing, }, } }); @@ -842,6 +920,7 @@ async fn process_targets( name: target.name.clone(), result: Err(target_errors), errors: Vec::new(), + processing: PipelineStats::default(), }); } } @@ -856,9 +935,11 @@ async fn process_targets( for mut target_result in results { errors.append(&mut target_result.errors); match target_result.result { - Ok(()) => target_stats.push(TargetStats::success(&target_result.name)), + Ok(()) => { + target_stats.push(TargetStats::success_with_processing(&target_result.name, target_result.processing)); + } Err(mut target_errors) => { - target_stats.push(TargetStats::failure(&target_result.name)); + target_stats.push(TargetStats::failure_with_processing(&target_result.name, target_result.processing)); errors.append(&mut target_errors); } } @@ -867,9 +948,9 @@ async fn process_targets( } #[allow(clippy::too_many_lines)] -async fn process_source( +async fn process_source( source_idx: usize, - ctx: Arc, + ctx: Arc>, ) -> (Vec, Vec, Vec) { log_memory_snapshot(format!("source[{source_idx}] start").as_str()); let sources = ctx.config.sources.load(); @@ -878,7 +959,7 @@ async fn process_source( let mut target_stats = Vec::::new(); if let Some(source) = sources.get_source_at(source_idx) { let mut source_playlists = Vec::with_capacity(source.inputs.len()); - let broadcast_step = create_broadcast_callback(ctx.event_manager.as_ref()); + let broadcast_step = create_broadcast_callback(&ctx.events); let process_parallel = ctx.config.config.load().process_parallel; let mut disabled_inputs: Vec> = vec![]; let mut enabled_inputs = Vec::with_capacity(source.inputs.len()); @@ -977,27 +1058,39 @@ async fn process_source( (ordered_input_stats, target_stats, errors) } -async fn download_input_epg( - ctx: &PlaylistProcessingContext, +async fn download_input_epg( + ctx: &PlaylistProcessingContext, input: &Arc, error_list: &mut Vec, ) -> Option { - // Download epg for input - let (tvguide, mut tvguide_errors) = if error_list.is_empty() { - let storage_dir = &ctx.config.config.load().storage_dir; - epg::get_xmltv(ctx, input, None, storage_dir).await - } else { - (None, vec![]) - }; - error_list.append(&mut tvguide_errors); - tvguide + // A failed playlist download makes the EPG moot: the channels it would annotate are + // not there. + if !error_list.is_empty() { + return None; + } + let provider = XmltvEpgProvider::new(ctx); + // The XMLTV path produces documents, not programme records, so nothing reaches the + // sink. It is here because the same call answers for a record-streaming provider. + let mut discarded = CountingEpgSink::new(); + let outcome = provider.fetch(&EpgFetchRequest::new(input), &mut discarded).await; + error_list.extend(provider.take_errors()); + match outcome { + Ok(outcome) => outcome.into_guide(), + Err(err) => { + error_list.push(err); + None + } + } } /// `invalidate_input_cache_status` performs a non-atomic file I/O sequence /// (`input_cache::load_input_status` + `input_cache::save_input_status`). /// Call this only while holding the per-input lock from /// `PlaylistProcessingContext::get_input_lock` (as done in `download_input`). -async fn invalidate_input_cache_status(ctx: &PlaylistProcessingContext, input: &ConfigInput) { +async fn invalidate_input_cache_status( + ctx: &PlaylistProcessingContext, + input: &ConfigInput, +) { let storage_dir = { ctx.config.config.load().storage_dir.clone() }; let storage_path = input_cache::resolve_input_storage_path(&storage_dir, &input.name).await; let mut status = input_cache::load_input_status(&storage_path); @@ -1007,8 +1100,8 @@ async fn invalidate_input_cache_status(ctx: &PlaylistProcessingContext, input: & } } -async fn load_cached_input_playlist( - ctx: &PlaylistProcessingContext, +async fn load_cached_input_playlist( + ctx: &PlaylistProcessingContext, input: &Arc, ) -> (PlaylistSource, Option) { match load_input_playlist(&ctx.config, input, None).await { @@ -1018,8 +1111,8 @@ async fn load_cached_input_playlist( } #[allow(clippy::too_many_lines)] -async fn download_input( - ctx: &PlaylistProcessingContext, +async fn download_input( + ctx: &PlaylistProcessingContext, input: &Arc, allow_staged_input: bool, ) -> (Vec, PlaylistSource, Option, bool) { @@ -1055,7 +1148,7 @@ async fn download_input( PlaylistDownloadResult::new(vec![], vec![], true, false) } else { mark_as_processed = true; - playlist_download_from_input(&ctx.client, &ctx.config, input, ctx.stalker_refresh_mode).await + playlist_download_from_input(&ctx.client, &ctx.config, &ctx.events, input, ctx.stalker_refresh_mode).await } } else { PlaylistDownloadResult::new(vec![], vec![], true, false) @@ -1088,8 +1181,14 @@ async fn download_input( input.name ); invalidate_input_cache_status(ctx, input).await; - playlist_download_result = - playlist_download_from_input(&ctx.client, &ctx.config, input, ctx.stalker_refresh_mode).await; + playlist_download_result = playlist_download_from_input( + &ctx.client, + &ctx.config, + &ctx.events, + input, + ctx.stalker_refresh_mode, + ) + .await; } } } else { @@ -1098,12 +1197,10 @@ async fn download_input( } if playlist_download_result.partial { ctx.partial_refresh.store(true, std::sync::atomic::Ordering::Release); - if let Some(events) = ctx.event_manager.as_deref() { - events.send_event(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { - target: input.name.to_string(), - message: stalker_checkpoint_message(&input.name), - })); - } + ctx.events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: input.name.to_string(), + message: stalker_checkpoint_message(&input.name), + })); } let apply_staged_overlay = should_apply_staged_overlay(&playlist_download_result); @@ -1155,18 +1252,14 @@ async fn download_input( (playlist_download_result.download_err, playlist, error, playlist_download_result.partial) } -fn create_broadcast_callback(event_manager: Option<&Arc>) -> StepMeasureCallback { - if let Some(event_mgr) = event_manager { - let events = event_mgr.clone(); - Box::new(move |context: &str, msg: &str| { - events.send_event(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { - target: context.to_owned(), - message: msg.to_owned(), - })); - }) - } else { - Box::new(move |_context: &str, _msg: &str| { /* noop */ }) - } +fn create_broadcast_callback(events: &E) -> StepMeasureCallback { + let events = events.clone(); + Box::new(move |context: &str, msg: &str| { + events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: context.to_owned(), + message: msg.to_owned(), + })); + }) } fn create_input_stat( @@ -1187,12 +1280,11 @@ fn create_input_stat( } } -#[derive(Clone)] -pub struct PlaylistProcessingContext { +pub struct PlaylistProcessingContext { pub client: reqwest::Client, pub config: Arc, pub user_targets: Arc, - pub event_manager: Option>, + pub events: E, pub playlist_state: Option>, /// Reverse-proxy header suppression, carried from the composition root. /// @@ -1211,13 +1303,35 @@ pub struct PlaylistProcessingContext { // New field for STRM probes & background updates pub provider_manager: Option>, - pub metadata_manager: Option>, + pub metadata_manager: Option>, pub pre_processed_inputs: Option>>>, pub stalker_refresh_mode: StalkerRefreshMode, pub partial_refresh: Arc, } -impl PlaylistProcessingContext { +// Written out rather than derived: `#[derive(Clone)]` would demand `M: Clone`, +// but the sink is held behind an `Arc` and is cloneable whatever `M` is. +impl Clone for PlaylistProcessingContext { + fn clone(&self) -> Self { + Self { + client: self.client.clone(), + config: Arc::clone(&self.config), + user_targets: Arc::clone(&self.user_targets), + events: self.events.clone(), + playlist_state: self.playlist_state.clone(), + disabled_headers: self.disabled_headers.clone(), + processed_inputs: Arc::clone(&self.processed_inputs), + input_locks: Arc::clone(&self.input_locks), + provider_manager: self.provider_manager.clone(), + metadata_manager: self.metadata_manager.clone(), + pre_processed_inputs: self.pre_processed_inputs.clone(), + stalker_refresh_mode: self.stalker_refresh_mode, + partial_refresh: Arc::clone(&self.partial_refresh), + } + } +} + +impl PlaylistProcessingContext { pub async fn is_input_downloaded(&self, input_name: &str) -> bool { let processed = self.processed_inputs.lock().await; processed.contains(input_name) @@ -1244,7 +1358,9 @@ impl PlaylistProcessingContext { } } -async fn process_sources(processing_ctx: &PlaylistProcessingContext) -> (Vec, Vec) { +async fn process_sources( + processing_ctx: &PlaylistProcessingContext, +) -> (Vec, Vec) { let mut async_tasks = JoinSet::new(); let sources = processing_ctx.config.sources.load(); let process_parallel = processing_ctx.config.config.load().process_parallel; @@ -1264,7 +1380,9 @@ async fn process_sources(processing_ctx: &PlaylistProcessingContext) -> (Vec (Vec Option>>; +pub type ProcessingPipe = Vec; -fn get_processing_pipe(target: &ConfigTarget) -> ProcessingPipe { - match &target.processing_order { - ProcessingOrder::Frm => vec![filter_playlist, rename_playlist, map_playlist], - ProcessingOrder::Fmr => vec![filter_playlist, map_playlist, rename_playlist], - ProcessingOrder::Rfm => vec![rename_playlist, filter_playlist, map_playlist], - ProcessingOrder::Rmf => vec![rename_playlist, map_playlist, filter_playlist], - ProcessingOrder::Mfr => vec![map_playlist, filter_playlist, rename_playlist], - ProcessingOrder::Mrf => vec![map_playlist, rename_playlist, filter_playlist], +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FinalizationStage { + Merge, + Deduplicate, + Sort, + AssignChannelNumbers, + AssignCounters, +} + +const FINALIZATION_ORDER: [FinalizationStage; 5] = [ + FinalizationStage::Merge, + FinalizationStage::Deduplicate, + FinalizationStage::Sort, + FinalizationStage::AssignChannelNumbers, + FinalizationStage::AssignCounters, +]; + +fn get_processing_pipe(target: &ConfigTarget) -> ProcessingPipe { target.execution_plan.transform_stages.clone() } + +#[derive(Clone, Copy)] +enum GroupingPolicy { + NormalizedCategory, + ExactCategory, + ExactSequential, +} + +struct TransformBuffer { + items: Vec, + grouping: GroupingPolicy, +} + +impl TransformBuffer { + fn new(items: Vec) -> Self { Self { items, grouping: GroupingPolicy::ExactCategory } } + + fn apply_filter(&mut self, target: &ConfigTarget) -> FilterOutcome { + let mut outcome = FilterOutcome::default(); + self.items.retain(|item| { + outcome.inspected += 1; + let provider = ValueProvider { pli: item, match_as_ascii: false }; + if target.filter(&provider) { + outcome.retained += 1; + true + } else { + outcome.removed += 1; + false + } + }); + self.grouping = GroupingPolicy::NormalizedCategory; + self.reorder_for_grouping(); + outcome } + + fn apply_rename(&mut self, target: &ConfigTarget) -> Option { + let renames = target.rename.as_ref().filter(|renames| !renames.is_empty())?; + let mut outcome = RenameOutcome::default(); + for item in &mut self.items { + outcome.inspected += 1; + let changed_fields = exec_rename(item, Some(renames)); + outcome.changed_fields += changed_fields; + outcome.changed_items += usize::from(changed_fields > 0); + } + self.grouping = GroupingPolicy::ExactCategory; + self.reorder_for_grouping(); + Some(outcome) + } + + fn apply_mapping(&mut self, target: &ConfigTarget, stage: MappingStage) -> Option { + if !has_mapping_stage(target, stage) { + return None; + } + let items = std::mem::take(&mut self.items); + let (items, outcome) = map_items_at_stage(items, target, stage, None) + .expect("mapping stage applicability was checked before consuming the buffer"); + self.items = items; + self.grouping = GroupingPolicy::ExactSequential; + self.reorder_for_grouping(); + Some(outcome) + } + + fn reorder_for_grouping(&mut self) { + let mut buckets: IndexMap> = IndexMap::new(); + for item in std::mem::take(&mut self.items) { + let title = item.header.group.clone(); + let key_title = match self.grouping { + GroupingPolicy::NormalizedCategory => shared::utils::deunicode_string(&title).to_lowercase().intern(), + GroupingPolicy::ExactCategory | GroupingPolicy::ExactSequential => title, + }; + buckets.entry((item.header.xtream_cluster, key_title)).or_default().push(item); + } + self.items = buckets.into_values().flatten().collect(); + } + + fn into_groups(self) -> Vec { group_items(self.items, self.grouping) } +} + +fn group_items(items: Vec, policy: GroupingPolicy) -> Vec { + let mut groups: IndexMap = IndexMap::new(); + let mut next_group_id = 0; + for item in items { + let title = item.header.group.clone(); + let cluster = item.header.xtream_cluster; + let key_title = match policy { + GroupingPolicy::NormalizedCategory => shared::utils::deunicode_string(&title).to_lowercase().intern(), + GroupingPolicy::ExactCategory | GroupingPolicy::ExactSequential => title.clone(), + }; + groups + .entry((cluster, key_title)) + .or_insert_with(|| { + let id = match policy { + GroupingPolicy::ExactSequential => { + next_group_id += 1; + next_group_id + } + GroupingPolicy::NormalizedCategory | GroupingPolicy::ExactCategory => item.header.category_id, + }; + PlaylistGroup { id, title, channels: Vec::new(), xtream_cluster: cluster } + }) + .channels + .push(item); + } + groups.into_values().collect() +} + +fn execute_pipeline_on_items( + items: Vec, + target: &ConfigTarget, + pipe: &[TransformStage], +) -> (Vec, PipelineOutcome) { + let mut buffer = TransformBuffer::new(items); + let mut outcome = PipelineOutcome::default(); + for stage in pipe { + match stage { + TransformStage::Filter => outcome.filter = Some(buffer.apply_filter(target)), + TransformStage::Rename => outcome.rename = buffer.apply_rename(target), + TransformStage::Map => outcome.mapping = buffer.apply_mapping(target, MappingStage::Processing), + } + } + (buffer.into_groups(), outcome) +} + +pub(super) fn execute_pipeline_on_groups( + groups: Vec, + target: &ConfigTarget, + pipe: &[TransformStage], +) -> (Vec, PipelineOutcome) { + if pipe.is_empty() { + return (groups, PipelineOutcome::default()); + } + execute_pipeline_on_items(groups.into_iter().flat_map(|group| group.channels).collect(), target, pipe) } fn execute_pipe<'a>( @@ -1330,7 +1588,7 @@ fn execute_pipe<'a>( fpl: &mut FetchedPlaylist<'a>, duplicates: &mut HashSet, consume_source: bool, -) -> Result, TuliproxError> { +) -> Result<(FetchedPlaylist<'a>, PipelineOutcome), TuliproxError> { let source = if consume_source { if fpl.is_memory() { MemoryPlaylistSource::new(fpl.source.take_groups()).into_source() @@ -1349,20 +1607,14 @@ fn execute_pipe<'a>( item.header.freeze_input_stream_id(); } } - if target.options.as_ref().is_some_and(|opt| opt.remove_duplicates) { + if target.execution_plan.pre_transform_identity_dedup { new_fpl.deduplicate(duplicates); } - for f in pipe { - if let Some(groups) = f(&mut new_fpl.source, target) { - new_fpl.source = MemoryPlaylistSource::new(groups).into_source(); - } - } - // Ensure source is memory-based for downstream mutable processing (VOD/series resolution) - if !new_fpl.is_memory() { - new_fpl.source = MemoryPlaylistSource::new(new_fpl.source.take_groups()).into_source(); - } - Ok(new_fpl) + let items = new_fpl.source.into_items().collect(); + let (groups, outcome) = execute_pipeline_on_items(items, target, pipe); + new_fpl.source = MemoryPlaylistSource::new(groups).into_source(); + Ok((new_fpl, outcome)) } // This method is needed, because of duplicate group names in different inputs. @@ -1395,11 +1647,12 @@ struct PreparedTarget { target: ConfigTarget, playlist: Vec, epg: Vec, + processing: PipelineStats, } #[allow(clippy::too_many_arguments)] -async fn prepare_playlist_for_target( - ctx: &PlaylistProcessingContext, +async fn prepare_playlist_for_target( + ctx: &PlaylistProcessingContext, playlists: &mut [FetchedPlaylist<'_>], target: &ConfigTarget, stats: &mut HashMap, InputStats>, @@ -1412,9 +1665,10 @@ async fn prepare_playlist_for_target( let mut duplicates: HashSet = HashSet::new(); let mut new_epg = vec![]; let mut new_playlist: Vec = vec![]; + let mut aggregate_outcome = PipelineOutcome::default(); debug!("Executing processing pipes"); - let broadcast_step = create_broadcast_callback(ctx.event_manager.as_ref()); + let broadcast_step = create_broadcast_callback(&ctx.events); let pipe = get_processing_pipe(target); let mut step = StepMeasure::new(&target.name, broadcast_step); @@ -1423,8 +1677,11 @@ async fn prepare_playlist_for_target( format!("target '{}' input '{}' before_pipe", target.name, provider_fpl.input.name).as_str(), ); step.broadcast("Executing transformations on '{}' playlist", &target.name); - let mut processed_fpl = execute_pipe(target, &pipe, provider_fpl, &mut duplicates, consume_input_source) - .map_err(|err| vec![err])?; + let (mut processed_fpl, input_outcome) = + execute_pipe(target, &pipe, provider_fpl, &mut duplicates, consume_input_source) + .map_err(|err| vec![err])?; + debug!("Target '{}' input '{}' pipeline outcome: {input_outcome:?}", target.name, provider_fpl.input.name); + aggregate_outcome.merge(input_outcome); log_memory_snapshot( format!("target '{}' input '{}' after_pipe", target.name, provider_fpl.input.name).as_str(), ); @@ -1437,7 +1694,7 @@ async fn prepare_playlist_for_target( log_memory_snapshot( format!("target '{}' input '{}' after_epg_apply", target.name, processed_fpl.input.name).as_str(), ); - let deduplicate = target.options.as_ref().is_some_and(|options| options.remove_duplicates); + let deduplicate = target.execution_plan.pre_transform_identity_dedup; if let Some(groups) = map_playlist_at_stage( &mut processed_fpl.source, target, @@ -1459,7 +1716,12 @@ async fn prepare_playlist_for_target( step.tick("filter rename map + epg"); log_memory_snapshot(format!("target '{}' after_filter_rename_map_epg", target.name).as_str()); step.stop("Preparing playlist"); - Ok(PreparedTarget { target: target.clone(), playlist: new_playlist, epg: new_epg }) + Ok(PreparedTarget { + target: target.clone(), + playlist: new_playlist, + epg: new_epg, + processing: aggregate_outcome.to_stats(), + }) } /// Spill each `Epg` source to a temp `BPlusTree` and merge them. Extracted @@ -1501,15 +1763,15 @@ fn spill_epg_to_disk(sources: Vec) -> Result, TuliproxError> { } } -async fn finalize_prepared_target( - ctx: Arc, +async fn finalize_prepared_target( + ctx: Arc>, prepared: PreparedTarget, ) -> (Result<(), Vec>, Vec) { let target = &prepared.target; let mut new_playlist = prepared.playlist; let new_epg = prepared.epg; let mut errors = Vec::new(); - let broadcast_step = create_broadcast_callback(ctx.event_manager.as_ref()); + let broadcast_step = create_broadcast_callback(&ctx.events); let mut step = StepMeasure::new(&target.name, broadcast_step); if target.favourites.is_some() { step.broadcast("Processing favourites for '{}' playlist", &target.name); @@ -1532,27 +1794,40 @@ async fn finalize_prepared_target( step.tick("playlist merge"); log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str()); - if let Some(dedup_config) = target.options.as_ref().and_then(|options| options.deduplicate.as_ref()) { - let removed = crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut flat_new_playlist); - if removed > 0 { - info!("Deduplicated {removed} channels for target {}", target.name); + for stage in FINALIZATION_ORDER.into_iter().skip(1) { + match stage { + FinalizationStage::Merge => unreachable!("merge is completed before post-merge finalization"), + FinalizationStage::Deduplicate => { + if let Some(dedup_config) = target.execution_plan.post_merge_content_dedup.as_ref() { + let removed = + crate::processor::deduplicate::deduplicate_playlist(*dedup_config, &mut flat_new_playlist); + if removed > 0 { + info!("Deduplicated {removed} channels for target {}", target.name); + } + step.tick("playlist dedup"); + log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str()); + } + } + FinalizationStage::Sort => { + if sort_playlist(target, &mut flat_new_playlist) { + step.tick("playlist sort"); + log_memory_snapshot(format!("target '{}' after_playlist_sort", target.name).as_str()); + } + } + FinalizationStage::AssignChannelNumbers => { + assign_channel_no_playlist(&mut flat_new_playlist); + step.tick("assigning channel numbers"); + log_memory_snapshot(format!("target '{}' after_assign_channel_numbers", target.name).as_str()); + } + FinalizationStage::AssignCounters => { + map_playlist_counter(target, &mut flat_new_playlist); + step.tick("assigning channel counter"); + log_memory_snapshot(format!("target '{}' after_assign_channel_counter", target.name).as_str()); + } } - step.tick("playlist dedup"); - log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str()); } - if sort_playlist(target, &mut flat_new_playlist) { - step.tick("playlist sort"); - log_memory_snapshot(format!("target '{}' after_playlist_sort", target.name).as_str()); - } - assign_channel_no_playlist(&mut flat_new_playlist); - step.tick("assigning channel numbers"); - log_memory_snapshot(format!("target '{}' after_assign_channel_numbers", target.name).as_str()); - map_playlist_counter(target, &mut flat_new_playlist); - step.tick("assigning channel counter"); - log_memory_snapshot(format!("target '{}' after_assign_channel_counter", target.name).as_str()); - - if process_watch(&ctx.config, &ctx.client, target, &flat_new_playlist).await { + if process_watch(&ctx.config, &ctx.events, target, &flat_new_playlist).await { step.tick("group watches"); log_memory_snapshot(format!("target '{}' after_group_watches", target.name).as_str()); } @@ -1592,8 +1867,8 @@ async fn finalize_prepared_target( } } -async fn playlist_resolve( - ctx: &PlaylistProcessingContext, +async fn playlist_resolve( + ctx: &PlaylistProcessingContext, target: &ConfigTarget, errors: &mut Vec, pipe: &ProcessingPipe, @@ -1668,7 +1943,11 @@ fn provider_id_from_item(item: &PlaylistItem) -> Option { } #[allow(clippy::too_many_lines)] -async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, fpl: &mut FetchedPlaylist<'_>) { +async fn playlist_probe( + ctx: &PlaylistProcessingContext, + target: &ConfigTarget, + fpl: &mut FetchedPlaylist<'_>, +) { let Some(mgr) = ctx.metadata_manager.as_ref() else { return; }; @@ -1767,7 +2046,12 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, }; debug!( "[Task] Creating ProbeLive task for input {}: id={}, last_probed_ts={:?}, cutoff_ts={}, interval={}s, title=\"{}\"", - input_name, provider_id, last_probed, cutoff_ts, interval_secs, item.header.title + input_name, + provider_id, + last_probed, + cutoff_ts, + interval_secs, + item.header.title ); } Arc::clone(mgr).queue_task_background(input_name.clone(), task); @@ -1836,7 +2120,9 @@ async fn playlist_probe(ctx: &PlaylistProcessingContext, target: &ConfigTarget, } if queued_live_count > 0 || queued_stream_count > 0 { - info!("Queued probe tasks for input {input_name} (live_interval={queued_live_count}, generic={queued_stream_count})"); + info!( + "Queued probe tasks for input {input_name} (live_interval={queued_live_count}, generic={queued_stream_count})" + ); } } @@ -1896,31 +2182,82 @@ async fn trakt_playlist( true } -async fn process_watch( +async fn process_watch( app_config: &Arc, - client: &reqwest::Client, + events: &E, target: &ConfigTarget, new_playlist: &[PlaylistGroup], ) -> bool { - if let Some(watches) = &target.watch { - if default_as_default().eq_ignore_ascii_case(&target.name) { - error!("can't watch a target with no unique name"); - return false; - } + let Some(watches) = &target.watch else { + return false; + }; - futures::stream::iter( - new_playlist - .iter() - .filter(|pl| watches.iter().any(|r| r.is_match(&pl.title))) - .map(|pl| process_group_watch(app_config, client, &target.name, pl)), - ) - .for_each_concurrent(16, |f| f) - .await; - - true - } else { - false + // Configured, but every pattern failed to compile. Silently doing + // nothing here is what made a typo in `watch` indistinguishable from a + // playlist that never changes. + if watches.is_empty() { + error!("target '{}' configured watch patterns but none of them compiled", target.name); + events.emit(EventMessage::PlaylistWatchDisabled(WatchDisabled::new( + target.name.clone(), + WatchDisabledReason::InvalidPatterns, + ))); + return false; } + + if default_as_default().eq_ignore_ascii_case(&target.name) { + error!("can't watch a target with no unique name"); + events.emit(EventMessage::PlaylistWatchDisabled(WatchDisabled::new( + target.name.clone(), + WatchDisabledReason::UnnamedTarget, + ))); + return false; + } + + // Before the per-group fan-out: this is about which groups exist, not + // what is inside the ones the patterns name, so it must see every group + // rather than only the watched ones. + process_target_groups_watch(app_config, events, &target.name, new_playlist).await; + + let mut matched = vec![false; watches.len()]; + let mut watched_groups = Vec::new(); + for group in new_playlist { + let mut any = false; + for (index, pattern) in watches.iter().enumerate() { + if pattern.is_match(&group.title) { + matched[index] = true; + any = true; + } + } + if any { + watched_groups.push(group); + } + } + + // A pattern that matches nothing looks exactly like a group that has not + // changed. `EventKindMask::from_wire_names` already reports unmatched + // subscription names for the same reason: a typo must surface. + let unmatched: Vec = watches + .iter() + .enumerate() + .filter(|(index, _)| !matched[*index]) + .map(|(_, pattern)| pattern.as_str().to_string()) + .collect(); + if !unmatched.is_empty() { + warn!("target '{}' has {} watch pattern(s) matching no group", target.name, unmatched.len()); + events.emit(EventMessage::PlaylistWatchUnmatched(WatchUnmatched::new( + target.name.clone(), + unmatched, + new_playlist.len(), + ))); + } + + futures::stream::iter( + watched_groups.into_iter().map(|pl| process_group_watch(app_config, events, &target.name, pl)), + ) + .for_each_concurrent(16, |f| f) + .await; + + true } /// Work the composition root runs once the playlist lock is held, before the @@ -1928,26 +2265,186 @@ async fn process_watch( /// /// This was an `Option>` used for exactly one call. Passing the /// call instead of the state keeps `processing` from naming the server state. -pub type PlaylistUpdateBootstrap = Arc Pin + Send>> + Send + Sync>; +/// +/// It was then an +/// `Arc Pin + Send>> + Send + Sync>`: +/// two layers of erasure and a heap allocation for a future that is awaited +/// exactly once per update, and every call site had to spell out both +/// coercions. As a trait it is one type parameter, monomorphised, with the +/// future returned by value. +pub trait UpdateBootstrap: Send + Sync + 'static { + fn run(&self) -> impl Future + Send; +} -// `pre_processed_inputs` is always built with the default hasher here; -// generalising a private signature would buy nothing. -#[allow(clippy::implicit_hasher)] -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] -pub async fn exec_processing( - client: &reqwest::Client, +impl UpdateBootstrap for F +where + F: Fn() -> Fut + Send + Sync + 'static, + Fut: Future + Send, +{ + fn run(&self) -> impl Future + Send { self() } +} + +/// The bootstrap type parameter of a run that has no bootstrap. +/// +/// A function pointer rather than a unit struct: it satisfies the blanket +/// `Fn` impl above, so no second impl - and no coherence problem - is needed. +/// A value of this type is never constructed; the field is always `None`. +pub type NoBootstrap = fn() -> std::future::Ready<()>; + +/// Everything one playlist update run needs. +/// +/// `exec_processing` took twelve positional arguments, seven of them +/// `Option<_>`, so a call site was a wall of `None`s and `Some(..)`s in which +/// the reader had to count commas to work out which knob was being set - and +/// the compiler could not catch two same-typed arguments swapped. +/// +/// Four of the twelve are always present, so they are constructor arguments. +/// The rest are optional in fact as well as in type, and a call site names the +/// ones it actually sets. +pub struct ProcessingRun< + E: EventSink + Clone + 'static, + B: UpdateBootstrap = NoBootstrap, + M: MetadataUpdateSink = NoopMetadataSink, +> { + client: reqwest::Client, app_config: Arc, targets: Arc, - event_manager: Option>, - bootstrap: Option, + events: E, + bootstrap: Option, playlist_state: Option>, update_guard: Option, disabled_headers: Option, provider_manager: Option>, - metadata_manager: Option>, + metadata_manager: Option>, pre_processed_inputs: Option>>, acquired_permit: Option, +} + +impl ProcessingRun { + pub fn new(client: reqwest::Client, app_config: Arc, targets: Arc, events: E) -> Self { + Self { + client, + app_config, + targets, + events, + bootstrap: None, + playlist_state: None, + update_guard: None, + disabled_headers: None, + provider_manager: None, + metadata_manager: None, + pre_processed_inputs: None, + acquired_permit: None, + } + } +} + +impl ProcessingRun { + /// Work the composition root runs once the lock is held, before the update + /// proper starts. + /// + /// Changes the run's bootstrap type, so it rebuilds rather than mutates. + #[must_use] + pub fn with_bootstrap(self, bootstrap: B2) -> ProcessingRun { + ProcessingRun { + client: self.client, + app_config: self.app_config, + targets: self.targets, + events: self.events, + bootstrap: Some(bootstrap), + playlist_state: self.playlist_state, + update_guard: self.update_guard, + disabled_headers: self.disabled_headers, + provider_manager: self.provider_manager, + metadata_manager: self.metadata_manager, + pre_processed_inputs: self.pre_processed_inputs, + acquired_permit: self.acquired_permit, + } + } + + #[must_use] + pub fn with_playlist_state(mut self, state: impl Into>>) -> Self { + self.playlist_state = state.into(); + self + } + + /// The lock this run acquires. Ignored when an already-acquired permit is + /// supplied via [`Self::with_acquired_permit`]. + #[must_use] + pub fn with_update_guard(mut self, guard: impl Into>) -> Self { + self.update_guard = guard.into(); + self + } + + #[must_use] + pub fn with_disabled_headers(mut self, headers: impl Into>) -> Self { + self.disabled_headers = headers.into(); + self + } + + #[must_use] + pub fn with_provider_manager(mut self, manager: impl Into>>) -> Self { + self.provider_manager = manager.into(); + self + } + + /// The background metadata worker. + /// + /// Changes the run's sink type, so it rebuilds rather than mutates. + #[must_use] + pub fn with_metadata_manager(self, manager: Arc) -> ProcessingRun { + ProcessingRun { + client: self.client, + app_config: self.app_config, + targets: self.targets, + events: self.events, + bootstrap: self.bootstrap, + playlist_state: self.playlist_state, + update_guard: self.update_guard, + disabled_headers: self.disabled_headers, + provider_manager: self.provider_manager, + metadata_manager: Some(manager), + pre_processed_inputs: self.pre_processed_inputs, + acquired_permit: self.acquired_permit, + } + } + + // Always built with the default hasher here; generalising would buy nothing. + #[allow(clippy::implicit_hasher)] + #[must_use] + pub fn with_pre_processed_inputs(mut self, inputs: impl Into>>>) -> Self { + self.pre_processed_inputs = inputs.into(); + self + } + + /// A playlist lock the caller already holds. Takes precedence over + /// [`Self::with_update_guard`], which would otherwise acquire a second one. + #[must_use] + pub fn with_acquired_permit(mut self, permit: impl Into>) -> Self { + self.acquired_permit = permit.into(); + self + } +} + +#[allow(clippy::too_many_lines)] +pub async fn exec_processing( + run: ProcessingRun, ) { + let ProcessingRun { + client, + app_config, + targets, + events, + bootstrap, + playlist_state, + update_guard, + disabled_headers, + provider_manager, + metadata_manager, + pre_processed_inputs, + acquired_permit, + } = run; + let max_update_duration = Duration::from_secs(PLAYLIST_UPDATE_MAX_DURATION_SECS); let playlist_guard = if let Some(permit) = acquired_permit { Some(permit) @@ -1956,9 +2453,9 @@ pub async fn exec_processing( Some(permit) } else { warn!("Playlist update lock is closed; update skipped."); - if let Some(events) = event_manager.as_deref() { - events.send_event(EventMessage::PlaylistUpdate(shared::model::PlaylistUpdateState::Failure)); - } + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + shared::model::PlaylistUpdateState::Failure, + ))); return; } } else { @@ -1967,13 +2464,13 @@ pub async fn exec_processing( if playlist_guard.is_some() { if let Some(bootstrap) = bootstrap.as_ref() { - if tokio::time::timeout(max_update_duration, bootstrap()).await.is_err() { + if tokio::time::timeout(max_update_duration, bootstrap.run()).await.is_err() { error!( "Playlist update bootstrap timed out after {PLAYLIST_UPDATE_MAX_DURATION_SECS} secs while holding playlist lock", ); - if let Some(events) = event_manager.as_deref() { - events.send_event(EventMessage::PlaylistUpdate(shared::model::PlaylistUpdateState::Failure)); - } + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + shared::model::PlaylistUpdateState::Failure, + ))); return; } } @@ -1992,10 +2489,10 @@ pub async fn exec_processing( // Initialize Context let ctx = PlaylistProcessingContext { - client: client.clone(), + client, config: app_config.clone(), user_targets: targets.clone(), - event_manager: event_manager.clone(), + events: events.clone(), playlist_state: playlist_state.clone(), processed_inputs: Arc::new(Mutex::new(HashSet::new())), input_locks: Arc::new(Mutex::new(HashMap::new())), @@ -2021,18 +2518,18 @@ pub async fn exec_processing( Ok(Ok((stats, errors))) => (stats, errors), Ok(Err(_)) => { error!("Playlist processing panicked"); - if let Some(events) = event_manager.as_deref() { - events.send_event(EventMessage::PlaylistUpdate(shared::model::PlaylistUpdateState::Failure)); - } + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + shared::model::PlaylistUpdateState::Failure, + ))); return; } Err(_) => { error!( "Playlist processing timed out after {PLAYLIST_UPDATE_MAX_DURATION_SECS} secs while holding playlist lock", ); - if let Some(events) = event_manager.as_deref() { - events.send_event(EventMessage::PlaylistUpdate(shared::model::PlaylistUpdateState::Failure)); - } + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary::state_only( + shared::model::PlaylistUpdateState::Failure, + ))); return; } }; @@ -2048,38 +2545,34 @@ pub async fn exec_processing( } if !stats.is_empty() { - // print stats if let Ok(stats_msg) = serde_json::to_string(&stats) { info!("stats: {stats_msg}"); } - // send stats - send_message(&app_config, client, MessageContent::event_stats(stats)).await; } - // send errors - if let Some(message) = get_errors_notify_message!(errors, 255) { - if let Some(events) = event_manager.as_deref() { - events.send_event(EventMessage::PlaylistUpdate(shared::model::PlaylistUpdateState::Failure)); - } - send_message(&app_config, client, MessageContent::event_error(message)).await; - } else if let Some(events) = event_manager.as_deref() { - let update_state = if ctx.partial_refresh.load(std::sync::atomic::Ordering::Acquire) { - shared::model::PlaylistUpdateState::Partial - } else { - shared::model::PlaylistUpdateState::Success - }; - events.send_event(EventMessage::PlaylistUpdate(update_state)); - } + // One event for the whole run, carrying both the outcome and what it + // did. These used to be two independent messages - the statistics went + // straight to the notification layer, the outcome went to the bus - and + // because both resolve to `playlist.update.completed`, a successful + // refresh notified twice. Subscribers now get one event with everything, + // and the bridge renders the single message from it. + let error = get_errors_notify_message!(errors, 255); + let outcome = if error.is_some() { + shared::model::PlaylistUpdateState::Failure + } else if ctx.partial_refresh.load(std::sync::atomic::Ordering::Acquire) { + shared::model::PlaylistUpdateState::Partial + } else { + shared::model::PlaylistUpdateState::Success + }; + events.emit(EventMessage::PlaylistUpdate(PlaylistUpdateSummary { state: outcome, stats, error })); let elapsed = start_time.elapsed().as_secs(); let update_finished_message = format!("🌷 Update process finished! Took {elapsed} secs."); - if let Some(events) = event_manager.as_deref() { - events.send_event(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { - target: "Playlist Update".to_string(), - message: update_finished_message.clone(), - })); - } + events.emit(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: "Playlist Update".to_string(), + message: update_finished_message.clone(), + })); log_memory_snapshot("exec_processing before_interner_gc"); debug!("StringInterner GC removed {} strings", interner_gc()); log_memory_snapshot("exec_processing after_interner_gc"); @@ -2094,13 +2587,13 @@ mod tests { use shared::{ foundation::{get_filter, MapperScript, ValueProvider}, model::{ - ClusterFlags, ConfigInputDto, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, M3uPlaylistItem, - MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster, - XtreamPlaylistItem, + ClusterFlags, ConfigInputDto, ConfigRenameDto, ConfigTargetDto, ConfigTargetOptions, FieldSetAccessor, + ItemField, M3uPlaylistItem, MappingStage, PlaylistEntry, PlaylistItem, PlaylistItemHeader, + PlaylistItemType, XtreamCluster, XtreamPlaylistItem, }, utils::Internable, }; - use tuliprox_core::model::Config; + use tuliprox_core::model::{CompiledMappingRule, CompiledTargetMappings, Config}; fn serialize_without_trailing_fields(value: &T, trailing_fields: &[u8]) -> Vec { let mut encoded = rmp_serde::to_vec(value).expect("playlist item should serialize"); @@ -2175,21 +2668,22 @@ mod tests { header: PlaylistItemHeader { id: "origin-alpha".intern(), name: "Channel".intern(), ..Default::default() }, }; item.header.freeze_input_stream_id(); - let mapping = Mapping { - mapper: Some(vec![tuliprox_core::model::Mapper { - filter: r#"name ~ ".*""#.to_string(), - script: r#"@id = "target-id""#.to_string(), - t_filter: Some(get_filter(r#"name ~ ".*""#, None).expect("filter should parse")), - t_script: Some(MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse")), - }]), + let mapping = CompiledMapping { + rules: vec![CompiledMappingRule { + name: None, + filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"), + program: MappingProgram::Script( + MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"), + ), + }], ..Default::default() }; - let (mapped, _, matched) = map_channel(item, &mapping); + let outcome = map_channel(item, &mapping); - assert!(matched); - assert_eq!(mapped.header.id.as_ref(), "target-id"); - assert_eq!(mapped.header.input_stream_id.as_ref(), "origin-alpha"); + assert_eq!(outcome.matched_rules, 1); + assert_eq!(outcome.channel.header.id.as_ref(), "target-id"); + assert_eq!(outcome.channel.header.input_stream_id.as_ref(), "origin-alpha"); } #[test] @@ -2211,23 +2705,24 @@ mod tests { legacy_xtream.url = "http://provider.example/live/channel.m3u8".intern(); let mut legacy_item = PlaylistItem::from(&legacy_xtream); legacy_item.header.freeze_input_stream_id(); - let mapping = Mapping { - mapper: Some(vec![tuliprox_core::model::Mapper { - filter: r#"name ~ ".*""#.to_string(), - script: r#"@id = "target-id""#.to_string(), - t_filter: Some(get_filter(r#"name ~ ".*""#, None).expect("filter should parse")), - t_script: Some(MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse")), - }]), + let mapping = CompiledMapping { + rules: vec![CompiledMappingRule { + name: None, + filter: get_filter(r#"name ~ ".*""#, None).expect("filter should parse"), + program: MappingProgram::Script( + MapperScript::parse(r#"@id = "target-id""#, None).expect("mapper should parse"), + ), + }], ..Default::default() }; - let (mapped, _, matched) = map_channel(legacy_item, &mapping); - let materialized_m3u = M3uPlaylistItem::from(&mapped); - let materialized_xtream = XtreamPlaylistItem::from(&mapped); + let outcome = map_channel(legacy_item, &mapping); + let materialized_m3u = M3uPlaylistItem::from(&outcome.channel); + let materialized_xtream = XtreamPlaylistItem::from(&outcome.channel); - assert!(matched); - assert_eq!(mapped.header.id.as_ref(), "target-id"); - assert_eq!(mapped.get_input_stream_id(), None); + assert_eq!(outcome.matched_rules, 1); + assert_eq!(outcome.channel.header.id.as_ref(), "target-id"); + assert_eq!(outcome.channel.get_input_stream_id(), None); assert!(materialized_m3u.provider_id.is_empty()); assert_eq!(materialized_m3u.get_input_stream_id(), None); assert_eq!(materialized_xtream.provider_id, 0); @@ -2249,7 +2744,7 @@ mod tests { let mut duplicates = HashSet::new(); let target = ConfigTarget::from(&ConfigTargetDto::default()); - let mut processed = execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false) + let (mut processed, _outcome) = execute_pipe(&target, &vec![], &mut fetched, &mut duplicates, false) .expect("target processing should succeed"); let mut groups = processed.source.take_groups(); @@ -2570,6 +3065,70 @@ mod tests { assert!(!filter.filter(&live_provider)); } + #[test] + fn playlist_retention_reports_filter_counts() { + let groups = vec![PlaylistGroup { + id: 1, + title: "Test Group".intern(), + channels: vec![ + make_test_item("Allowed", PlaylistItemType::Live), + make_test_item("Denied", PlaylistItemType::Live), + ], + xtream_cluster: XtreamCluster::Live, + }]; + let mut source = MemoryPlaylistSource::new(groups).into_source(); + + let (filtered, outcome) = retain_playlist_items(&mut source, |item| item.header.name.as_ref() == "Allowed"); + + assert_eq!(outcome, FilterOutcome { inspected: 2, retained: 1, removed: 1 }); + assert_eq!(filtered.expect("one item should remain")[0].channels[0].header.name.as_ref(), "Allowed"); + } + + #[test] + fn filter_stage_can_remove_every_item() { + let groups = vec![PlaylistGroup { + id: 1, + title: "Test Group".intern(), + channels: vec![make_test_item("Denied", PlaylistItemType::Live)], + xtream_cluster: XtreamCluster::Live, + }]; + let mut target = ConfigTarget::from(&ConfigTargetDto::default()); + target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse"); + + let (groups, outcome) = execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter]); + + assert!(groups.is_empty()); + assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 1, retained: 0, removed: 1 })); + } + + #[test] + fn pipeline_reports_filter_and_rename_outcomes() { + let groups = vec![PlaylistGroup { + id: 1, + title: "Test Group".intern(), + channels: vec![ + make_test_item("Allowed", PlaylistItemType::Live), + make_test_item("Denied", PlaylistItemType::Live), + ], + xtream_cluster: XtreamCluster::Live, + }]; + let mut target = ConfigTarget::from(&ConfigTargetDto::default()); + target.filter = get_filter(r#"name ~ "Allowed""#, None).expect("filter should parse"); + target.rename = Some(vec![ConfigRename::from(&ConfigRenameDto { + field: ItemField::Name, + pattern: "Allowed".to_string(), + new_name: "Renamed".to_string(), + t_pattern: None, + })]); + + let (groups, outcome) = + execute_pipeline_on_groups(groups, &target, &[TransformStage::Filter, TransformStage::Rename]); + + assert_eq!(groups[0].channels[0].header.name.as_ref(), "Renamed"); + assert_eq!(outcome.filter, Some(FilterOutcome { inspected: 2, retained: 1, removed: 1 })); + assert_eq!(outcome.rename, Some(RenameOutcome { inspected: 1, changed_items: 1, changed_fields: 1 })); + } + #[test] fn assign_channel_no_playlist_preserves_non_zero_chno() { let mut groups = vec![ @@ -2759,7 +3318,13 @@ mod tests { maximum.fetch_max(current, Ordering::SeqCst); tokio::time::sleep(Duration::from_millis(10)).await; active.fetch_sub(1, Ordering::SeqCst); - TargetJobResult { index, name: format!("target-{index}"), result: Ok(()), errors: Vec::new() } + TargetJobResult { + index, + name: format!("target-{index}"), + result: Ok(()), + errors: Vec::new(), + processing: PipelineStats::default(), + } }); } while let Some(result) = tasks.join_next().await { @@ -2811,33 +3376,28 @@ mod tests { use tokio::runtime::Runtime; use tuliprox_core::{ model::{ - EpgConfig, EpgSmartMatchConfig, IcsEpgSourceConfig, Mapper, MediaToolCapabilities, PersistedEpgSource, + EpgConfig, EpgSmartMatchConfig, IcsEpgSourceConfig, MediaToolCapabilities, PersistedEpgSource, PersistedEpgSourceKind, SourcesConfig, }, utils::FileLockManager, }; - fn build_mapping(id: &str, stage: MappingStage, script: &str) -> Mapping { - let script = script.to_string(); - let t_filter = get_filter(r#"name ~ ".*""#, None).expect("filter parses"); - let t_script = MapperScript::parse(&script, None).expect("script parses"); - Mapping { + fn build_mapping(id: &str, stage: MappingStage, script: &str) -> CompiledMapping { + CompiledMapping { id: id.to_string(), match_as_ascii: false, stage, - mapper: Some(vec![Mapper { - filter: r#"name ~ ".*""#.to_string(), - script, - t_filter: Some(t_filter), - t_script: Some(t_script), - }]), - counter: None, - t_counter: None, + rules: vec![CompiledMappingRule { + name: None, + filter: get_filter(r#"name ~ ".*""#, None).expect("filter parses"), + program: MappingProgram::Script(MapperScript::parse(script, None).expect("script parses")), + }], + counters: vec![], templates: None, } } - fn build_target(mappings: Vec, remove_duplicates: bool) -> ConfigTarget { + fn build_target(mappings: Vec, remove_duplicates: bool) -> ConfigTarget { let dto = ConfigTargetDto { options: if remove_duplicates { Some(ConfigTargetOptions { remove_duplicates, ..Default::default() }) @@ -2847,11 +3407,16 @@ mod tests { ..Default::default() }; let mut target = ConfigTarget::from(&dto); - target.mapping = Arc::new(ArcSwapOption::from(Some(Arc::new(mappings)))); + target.mapping = Arc::new(ArcSwapOption::from(Some(Arc::new(CompiledTargetMappings::new( + mappings.into_iter().map(Arc::new).collect(), + ))))); target } - fn processing_context() -> PlaylistProcessingContext { + /// Pinned to `NoopSink` rather than staying generic: these tests + /// exercise the pipeline, not the bus, and an inferred sink type + /// would just make every call site name one. + fn processing_context() -> PlaylistProcessingContext { let paths = ConfigPaths { home_path: String::new(), config_path: String::new(), @@ -2886,7 +3451,7 @@ mod tests { targets: Vec::new(), target_names: Vec::new(), }), - event_manager: None, + events: shared::model::NoopSink, playlist_state: None, disabled_headers: None, processed_inputs: Arc::new(Mutex::new(HashSet::new())), @@ -2934,7 +3499,7 @@ mod tests { let target = build_target(vec![processing, after_epg], false); let mut source = memory_source(vec![make_channel("Alpha")]); - let groups = map_playlist(&mut source, &target).expect("processing mapping should run"); + let (groups, _) = execute_pipeline_on_groups(source.take_groups(), &target, &[TransformStage::Map]); assert_eq!(groups[0].channels[0].header.name.as_ref(), "Alpha-P"); let mut source = MemoryPlaylistSource::new(groups).into_source(); @@ -3072,7 +3637,7 @@ match { let mut fetched = FetchedPlaylist { input: &input, source: memory_source(vec![channel.clone(), channel]), epg: None }; let mut duplicates = HashSet::new(); - let mut processed = + let (mut processed, _outcome) = execute_pipe(&target, &get_processing_pipe(&target), &mut fetched, &mut duplicates, false) .expect("processing pipe must run"); assert_eq!(processed.get_channel_count(), 1, "processing pipe must remove the duplicate"); diff --git a/backend/processing/src/processor/providers.rs b/backend/processing/src/processor/providers.rs new file mode 100644 index 000000000..311ca244d --- /dev/null +++ b/backend/processing/src/processor/providers.rs @@ -0,0 +1,156 @@ +//! The [`PlaylistProvider`] implementations whose orchestration lives in this crate. +//! +//! The M3U and Xtream providers ship with the client code in `tuliprox-iptv`. These three +//! cannot: Stalker's resumable refresh, the local library scan and the Plex catalog import +//! all reach into `tuliprox-repository` and this crate's own processors, and `tuliprox-iptv` +//! sits below both. The trait is what lets them keep that dependency direction and still +//! answer the dispatcher in one shape. + +use super::{ + library::download_library_playlist, stalker::download_stalker_playlist, stalker_refresh::StalkerRefreshMode, +}; +use crate::{metadata_sink::MetadataUpdateSink, parser::xmltv::TVGuide, processor::PlaylistProcessingContext}; +use shared::{ + error::TuliproxError, + model::{EventSink, PlaylistGroup}, +}; +use tuliprox_core::model::ConfigInput; +use tuliprox_iptv::{ + epg::{EpgFetchRequest, EpgOutcome, EpgProvider, EpgRecordSink}, + provider::{PlaylistFetch, PlaylistFetchRequest, PlaylistProvider}, +}; +use tuliprox_media_server::{ + media_server_catalog_snapshot_to_playlist, refresh_media_server_catalog_complete_before_publish, + MediaServerCatalogRefreshPolicy, MediaServerHttpClient, +}; + +/// Stalker/Ministra portal. Carries the refresh mode and whether the fetched generation +/// should be published immediately, both of which only this provider has. +pub struct StalkerProvider { + refresh_mode: StalkerRefreshMode, + materialize_active: bool, +} + +impl StalkerProvider { + pub fn new(refresh_mode: StalkerRefreshMode, materialize_active: bool) -> Self { + Self { refresh_mode, materialize_active } + } +} + +impl PlaylistProvider for StalkerProvider { + fn name(&self) -> &'static str { "stalker" } + + async fn fetch(&self, request: &PlaylistFetchRequest<'_>) -> PlaylistFetch { + let (groups, errors, persisted, partial) = download_stalker_playlist( + request.app_config, + request.client, + request.input, + None, + self.refresh_mode, + self.materialize_active, + ) + .await; + PlaylistFetch::groups(groups).with_errors(errors).persisted(persisted).partial(partial) + } +} + +/// Local media library scan. +#[derive(Debug, Clone, Copy, Default)] +pub struct LibraryProvider; + +impl PlaylistProvider for LibraryProvider { + fn name(&self) -> &'static str { "library" } + + async fn fetch(&self, request: &PlaylistFetchRequest<'_>) -> PlaylistFetch { + let (groups, errors) = download_library_playlist(request.client, request.app_config, request.input).await; + PlaylistFetch::groups(groups).with_errors(errors) + } +} + +/// Plex media server catalog import. +#[derive(Debug, Clone, Copy, Default)] +pub struct PlexProvider; + +impl PlaylistProvider for PlexProvider { + fn name(&self) -> &'static str { "plex" } + + async fn fetch(&self, request: &PlaylistFetchRequest<'_>) -> PlaylistFetch { + let (groups, errors) = download_plex_media_server_playlist(request.client, request.input).await; + PlaylistFetch::groups(groups).with_errors(errors) + } +} + +async fn download_plex_media_server_playlist( + client: &reqwest::Client, + input: &ConfigInput, +) -> (Vec, Vec) { + let Some(media_server) = input.media_server.as_ref() else { + return ( + vec![], + vec![TuliproxError::Download(format!( + "media-server input '{}' is missing media_server configuration", + input.name + ))], + ); + }; + let http_client = MediaServerHttpClient::new(client.clone()); + let plex_client = match input.plex_catalog_client(http_client) { + Ok(client) => client, + Err(error) => return (vec![], vec![TuliproxError::Download(error.to_string())]), + }; + let policy = MediaServerCatalogRefreshPolicy { + page_size: usize::from(media_server.catalog.page_size), + request_delay_ms: media_server.catalog.request_delay_ms, + }; + + match refresh_media_server_catalog_complete_before_publish(&plex_client, policy).await { + Ok(snapshot) => (media_server_catalog_snapshot_to_playlist(&snapshot), vec![]), + Err(error) => (vec![], vec![TuliproxError::Download(error.to_string())]), + } +} + +/// XMLTV/ICS EPG for M3U, Xtream and library inputs. +/// +/// The counterpart to `tuliprox_iptv::epg::StalkerEpgProvider`: this one downloads whole +/// documents and hands back a [`TVGuide`] over the files, rather than streaming programme +/// records. Which of the two happened is exactly what +/// [`EpgOutcome`](tuliprox_iptv::epg::EpgOutcome) exists to say - forcing a +/// several-hundred-megabyte XMLTV document through a record sink would mean materialising +/// all of it in memory to answer a question nobody asked. +pub struct XmltvEpgProvider<'a, E: EventSink + Clone + 'static, M: MetadataUpdateSink> { + ctx: &'a PlaylistProcessingContext, + errors: parking_lot::Mutex>, +} + +impl<'a, E: EventSink + Clone + 'static, M: MetadataUpdateSink> XmltvEpgProvider<'a, E, M> { + pub fn new(ctx: &'a PlaylistProcessingContext) -> Self { + Self { ctx, errors: parking_lot::Mutex::new(Vec::new()) } + } + + /// The per-source failures collected during the fetch. + /// + /// EPG download is per-source and partial by nature: three sources where one 404s + /// still yields a usable guide from the other two, so the failures travel alongside + /// the outcome rather than replacing it. + pub fn take_errors(&self) -> Vec { std::mem::take(&mut *self.errors.lock()) } +} + +impl EpgProvider for XmltvEpgProvider<'_, E, M> { + type Guide = TVGuide; + + fn name(&self) -> &'static str { "xmltv" } + + async fn fetch( + &self, + request: &EpgFetchRequest<'_>, + _sink: &mut impl EpgRecordSink, + ) -> Result, TuliproxError> { + if request.input.epg.is_none() { + return Ok(EpgOutcome::NotConfigured); + } + let storage_dir = { self.ctx.config.config.load().storage_dir.clone() }; + let (guide, errors) = crate::epg::get_xmltv(self.ctx, request.input, None, &storage_dir).await; + self.errors.lock().extend(errors); + Ok(guide.map_or(EpgOutcome::NotConfigured, EpgOutcome::Guide)) + } +} diff --git a/backend/processing/src/processor/sort.rs b/backend/processing/src/processor/sort.rs index 34cb72eed..6ccec0864 100644 --- a/backend/processing/src/processor/sort.rs +++ b/backend/processing/src/processor/sort.rs @@ -181,7 +181,7 @@ fn build_rule_cache_entry( return RuleCacheEntry { filter_pass: false, value: None, sequence_match: None }; } - let value = provider.get(rule.rule.field.as_ref()); + let value = provider.get_typed(rule.rule.field); let sequence_match = match (&rule.sequence_plan, value.as_deref()) { (Some(sequence_plan), Some(value)) => Some(evaluate_sequence(sequence_plan, value)), _ => None, @@ -190,6 +190,7 @@ fn build_rule_cache_entry( RuleCacheEntry { filter_pass: true, value, sequence_match } } +#[cfg(test)] fn build_group_rule_cache(rule: &PreparedRule, groups: &[PlaylistGroup], match_as_ascii: bool) -> Vec { groups.iter().map(|group| build_rule_cache_entry(rule, group.channels.first(), match_as_ascii)).collect() } @@ -203,38 +204,11 @@ fn build_channel_rule_cache( } fn reorder_by_indices(items: &mut Vec, indices: &[usize]) { - if items.len() != indices.len() { - log::error!( - "Invalid sort permutation: index length {} does not match item length {}", - indices.len(), - items.len() - ); - return; - } - - let mut seen = vec![false; items.len()]; - for &idx in indices { - if idx >= items.len() { - log::error!("Invalid sort permutation: index {idx} out of bounds for {} items", items.len()); - return; - } - if std::mem::replace(&mut seen[idx], true) { - log::error!("Invalid sort permutation: duplicate index {idx}"); - return; - } - } - let mut original: Vec> = std::mem::take(items).into_iter().map(Some).collect(); items.reserve(indices.len()); for &idx in indices { - if let Some(item) = original[idx].take() { - items.push(item); - } else { - log::error!("Invalid sort permutation: missing index {idx} after validation"); - items.extend(original.into_iter().flatten()); - return; - } + items.push(original[idx].take().expect("sort permutation was validated before application")); } } @@ -259,6 +233,7 @@ fn playlist_comparator( } } +#[cfg(test)] fn get_group_source_ordinal(group: &PlaylistGroup) -> u32 { group.channels.first().map_or(u32::MAX, |c| normalized_source_ordinal(c.header.source_ordinal)) } @@ -295,23 +270,136 @@ fn is_effective_rule(rule: &ConfigSortRule) -> bool { rule.order != SortOrder::None || rule.sequence.as_ref().is_some_and(|sequence| !sequence.is_empty()) } -pub(in crate::processor) fn sort_playlist(target: &ConfigTarget, playlist: &mut Vec) -> bool { - let Some(sort) = &target.sort else { - for group in &mut *playlist { - group.channels.sort_by_key(|a| normalized_source_ordinal(a.header.source_ordinal)); +#[derive(Debug)] +pub(in crate::processor) struct SortPlan { + group_indices: Vec, + channel_indices: Vec>, +} + +fn validate_permutation(indices: &[usize], item_count: usize, label: &str) -> Result<(), String> { + if indices.len() != item_count { + return Err(format!("Invalid {label} sort permutation: {} indices for {item_count} items", indices.len())); + } + let mut seen = vec![false; item_count]; + for &index in indices { + if index >= item_count { + return Err(format!("Invalid {label} sort permutation: index {index} is out of bounds")); } - playlist.sort_by_key(get_group_source_ordinal); - return true; + if std::mem::replace(&mut seen[index], true) { + return Err(format!("Invalid {label} sort permutation: duplicate index {index}")); + } + } + Ok(()) +} + +impl SortPlan { + fn validate(&self, groups: &[PlaylistGroup]) -> Result<(), String> { + validate_permutation(&self.group_indices, groups.len(), "group")?; + if self.channel_indices.len() != groups.len() { + return Err(format!( + "Invalid channel sort plan: {} permutations for {} groups", + self.channel_indices.len(), + groups.len() + )); + } + for (group_index, (group, indices)) in groups.iter().zip(&self.channel_indices).enumerate() { + validate_permutation(indices, group.channels.len(), &format!("channel group {group_index}"))?; + } + Ok(()) + } +} + +fn planned_first_channel<'a>(group: &'a PlaylistGroup, indices: &[usize]) -> Option<&'a shared::model::PlaylistItem> { + indices.first().and_then(|index| group.channels.get(*index)) +} + +pub(in crate::processor) fn calculate_sort_plan(target: &ConfigTarget, groups: &[PlaylistGroup]) -> SortPlan { + let (rules, match_as_ascii) = + target.sort.as_ref().map_or((&[][..], false), |sort| (sort.rules.as_slice(), sort.match_as_ascii)); + let channel_rules: Vec<_> = rules + .iter() + .filter(|rule| matches!(rule.target, SortTarget::Channel) && is_effective_rule(rule)) + .map(PreparedRule::new) + .collect(); + let channel_indices = groups + .iter() + .map(|group| { + let mut indices = (0..group.channels.len()).collect::>(); + if channel_rules.is_empty() { + indices.sort_by_key(|index| normalized_source_ordinal(group.channels[*index].header.source_ordinal)); + } else { + let caches = channel_rules + .iter() + .map(|rule| build_channel_rule_cache(rule, &group.channels, match_as_ascii)) + .collect::>(); + indices.sort_by(|left, right| { + compare_cached_rule_entries(&channel_rules, &caches, *left, *right).then_with(|| { + normalized_source_ordinal(group.channels[*left].header.source_ordinal) + .cmp(&normalized_source_ordinal(group.channels[*right].header.source_ordinal)) + }) + }); + } + indices + }) + .collect::>(); + + let group_rules: Vec<_> = rules + .iter() + .filter(|rule| matches!(rule.target, SortTarget::Group) && is_effective_rule(rule)) + .map(PreparedRule::new) + .collect(); + let planned_group_ordinal = |index: usize| { + planned_first_channel(&groups[index], &channel_indices[index]) + .map_or(u32::MAX, |channel| normalized_source_ordinal(channel.header.source_ordinal)) }; + let mut group_indices = (0..groups.len()).collect::>(); + if group_rules.is_empty() { + group_indices.sort_by_key(|index| planned_group_ordinal(*index)); + } else { + let caches = group_rules + .iter() + .map(|rule| { + groups + .iter() + .enumerate() + .map(|(index, group)| { + build_rule_cache_entry( + rule, + planned_first_channel(group, &channel_indices[index]), + match_as_ascii, + ) + }) + .collect::>() + }) + .collect::>(); + group_indices.sort_by(|left, right| { + compare_cached_rule_entries(&group_rules, &caches, *left, *right) + .then_with(|| planned_group_ordinal(*left).cmp(&planned_group_ordinal(*right))) + }); + } - let rules = &sort.rules; - let match_as_ascii = sort.match_as_ascii; - sort_channels_in_groups(playlist.as_mut_slice(), rules, match_as_ascii); - sort_groups(playlist, rules, match_as_ascii); + SortPlan { group_indices, channel_indices } +} +pub(in crate::processor) fn apply_sort_plan(groups: &mut Vec, plan: &SortPlan) -> Result<(), String> { + plan.validate(groups)?; + for (group, indices) in groups.iter_mut().zip(&plan.channel_indices) { + reorder_by_indices(&mut group.channels, indices); + } + reorder_by_indices(groups, &plan.group_indices); + Ok(()) +} + +pub(in crate::processor) fn sort_playlist(target: &ConfigTarget, playlist: &mut Vec) -> bool { + let plan = calculate_sort_plan(target, playlist); + if let Err(error) = apply_sort_plan(playlist, &plan) { + log::error!("{error}"); + return false; + } true } +#[cfg(test)] fn sort_groups(groups: &mut Vec, rules: &[ConfigSortRule], match_as_ascii: bool) { let group_rules: Vec<_> = rules .iter() @@ -341,6 +429,7 @@ fn sort_groups(groups: &mut Vec, rules: &[ConfigSortRule], match_ reorder_by_indices(groups, &group_indices); } +#[cfg(test)] fn sort_channels_in_groups(groups: &mut [PlaylistGroup], rules: &[ConfigSortRule], match_as_ascii: bool) { let channel_rules: Vec<_> = rules .iter() @@ -378,7 +467,8 @@ fn sort_channels_in_groups(groups: &mut [PlaylistGroup], rules: &[ConfigSortRule #[cfg(test)] mod tests { use super::{ - compare_rule_entries, playlist_comparator, sort_channels_in_groups, sort_groups, PreparedRule, RuleCacheEntry, + apply_sort_plan, compare_rule_entries, playlist_comparator, sort_channels_in_groups, sort_groups, PreparedRule, + RuleCacheEntry, SortPlan, }; use shared::{ foundation::Filter, @@ -391,6 +481,27 @@ mod tests { PlaylistGroup { id, title: title.into(), channels, xtream_cluster: XtreamCluster::Live } } + #[test] + fn invalid_sort_plan_does_not_partially_reorder_playlist() { + let mut groups = vec![ + make_group( + 1, + "First", + vec![PlaylistItem { header: PlaylistItemHeader { name: "A".into(), ..Default::default() } }], + ), + make_group( + 2, + "Second", + vec![PlaylistItem { header: PlaylistItemHeader { name: "B".into(), ..Default::default() } }], + ), + ]; + let plan = SortPlan { group_indices: vec![1, 0], channel_indices: vec![vec![0], vec![1]] }; + + assert!(apply_sort_plan(&mut groups, &plan).is_err()); + assert_eq!(groups[0].title.as_ref(), "First"); + assert_eq!(groups[1].title.as_ref(), "Second"); + } + #[test] fn test_sort() { let channels: Vec = vec![ diff --git a/backend/processing/src/processor/stalker.rs b/backend/processing/src/processor/stalker.rs index 7490276dd..1ba311f8d 100644 --- a/backend/processing/src/processor/stalker.rs +++ b/backend/processing/src/processor/stalker.rs @@ -360,9 +360,12 @@ fn groups_for_cluster( groups_map.into_values().collect() } -fn stalker_err_to_repo(err: StalkerError) -> TuliproxError { - TuliproxError::ProviderConnection(format!("Stalker client error: {err}")) -} +/// Map a Stalker failure onto the workspace error type, keeping its classification. +/// +/// Flattening everything to `ProviderConnection` read as "the network had a bad moment", +/// so a misconfigured portal URL and a rejected password were both reported as connection +/// trouble - and both counted as worth retrying. +fn stalker_err_to_repo(err: StalkerError) -> TuliproxError { tuliprox_iptv::error::stalker_error_to_tuliprox(&err) } /// Cache key for the runtime client map. Built from explicit, non-secret /// fields — `StalkerInputConfig` carries the portal account credentials, so diff --git a/backend/processing/src/processor/stream_probe.rs b/backend/processing/src/processor/stream_probe.rs index 0a8cc6795..047d9f457 100644 --- a/backend/processing/src/processor/stream_probe.rs +++ b/backend/processing/src/processor/stream_probe.rs @@ -3,8 +3,9 @@ use log::{debug, info, warn}; use shared::{ error::TuliproxError, model::{ - EpisodeStreamProperties, InputType, LiveStreamProperties, M3uPlaylistItem, PlaylistItemType, StreamProperties, - UUIDType, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, + EpisodeStreamProperties, EventMessage, EventSink, InputType, LiveStreamProperties, M3uPlaylistItem, + PlaylistItemType, StreamProbeFailure, StreamProbeFailureReason, StreamProperties, UUIDType, + VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, }, utils::sanitize_sensitive_info, }; @@ -73,7 +74,7 @@ fn uses_seekable_remote_probe(item_type: PlaylistItemType, is_remote_probe: bool /// - `unique_id`: For M3U this is the `provider_id` (String). For Library this is the `UUID` string. /// For Xtream this is the numeric provider id as string. #[allow(clippy::too_many_arguments, clippy::too_many_lines)] -pub async fn update_generic_stream_metadata( +pub async fn update_generic_stream_metadata( app_config: &Arc, client: &reqwest::Client, input: &ConfigInput, @@ -83,6 +84,7 @@ pub async fn update_generic_stream_metadata( active_provider: &Arc, active_handle: Option<&tuliprox_core::model::ProviderHandle>, probe_priority: i8, + events: &E, ) -> Result { let prepared = match prepare_generic_stream_metadata( app_config, @@ -94,6 +96,7 @@ pub async fn update_generic_stream_metadata( active_provider, active_handle, probe_priority, + events, ) .await? { @@ -109,7 +112,7 @@ pub async fn update_generic_stream_metadata( /// /// This is used by metadata workers that can batch the eventual B+Tree writes. #[allow(clippy::too_many_arguments)] -pub async fn probe_generic_stream_metadata( +pub async fn probe_generic_stream_metadata( app_config: &Arc, client: &reqwest::Client, input: &ConfigInput, @@ -119,6 +122,7 @@ pub async fn probe_generic_stream_metadata( active_provider: &Arc, active_handle: Option<&tuliprox_core::model::ProviderHandle>, probe_priority: i8, + events: &E, ) -> Result { let prepared = match prepare_generic_stream_metadata( app_config, @@ -130,6 +134,7 @@ pub async fn probe_generic_stream_metadata( active_provider, active_handle, probe_priority, + events, ) .await? { @@ -145,8 +150,28 @@ pub async fn probe_generic_stream_metadata( })) } +/// Publish "this stream did not probe". +/// +/// The URL is sanitized here rather than by the caller: it is a resolved +/// provider URL carrying account credentials, and this record is rendered +/// into notification channels. +fn emit_probe_failure( + events: &E, + input: &ConfigInput, + unique_id: &str, + probe_url: &str, + reason: StreamProbeFailureReason, +) { + events.emit(EventMessage::StreamProbeFailed(StreamProbeFailure::new( + Arc::clone(&input.name), + unique_id.into(), + sanitize_sensitive_info(probe_url).as_ref().into(), + reason, + ))); +} + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] -async fn prepare_generic_stream_metadata( +async fn prepare_generic_stream_metadata( app_config: &Arc, client: &reqwest::Client, input: &ConfigInput, @@ -156,6 +181,7 @@ async fn prepare_generic_stream_metadata( active_provider: &Arc, active_handle: Option<&tuliprox_core::model::ProviderHandle>, probe_priority: i8, + events: &E, ) -> Result { let storage_dir = &app_config.config.load().storage_dir; @@ -236,9 +262,9 @@ async fn prepare_generic_stream_metadata( let ffprobe_timeout = metadata_update.ffprobe.timeout.unwrap_or(60); let user_agent = config.default_user_agent.clone(); let (analyze_duration, probe_size) = if item_type.is_live() { - (metadata_update.ffprobe.live_analyze_duration_micros, metadata_update.ffprobe.live_probe_size_bytes) + (metadata_update.ffprobe.live_analyze_duration_micros, metadata_update.ffprobe.live_probe_size_bytes.get()) } else { - (metadata_update.ffprobe.analyze_duration_micros, metadata_update.ffprobe.probe_size_bytes) + (metadata_update.ffprobe.analyze_duration_micros, metadata_update.ffprobe.probe_size_bytes.get()) }; debug_if_enabled!("Probing Generic Stream '{unique_id}'"); @@ -269,16 +295,26 @@ async fn prepare_generic_stream_metadata( raw_audio.map(|value| Arc::::from(value.to_string())), stats, ), + // The three failure arms are the only place the *reason* survives: + // both outcome enums above collapse to a single `ProbeFailed`, so a + // caller cannot tell a dead provider from a cancelled shutdown. The + // event is published here rather than plumbed out for that reason. ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => { warn!("Probe target not found (404) for generic stream: {unique_id}"); + emit_probe_failure(events, input, unique_id, &probe_url, StreamProbeFailureReason::NotFound); return Err(shared::error::TuliproxError::Probe(format!( "Probe target returned 404 Not Found for stream {unique_id}" ))); } ProbeUrlOutcome::Failed(ProbeFailureKind::Other) => { warn!("Probe failed or timed out for generic stream: {unique_id}"); + emit_probe_failure(events, input, unique_id, &probe_url, StreamProbeFailureReason::Unreachable); return Ok(PreparedGenericProbeOutcome::ProbeFailed); } + // Deliberately silent. A cancelled probe is this server shutting + // down or preempting the task, not a statement about the stream, + // and an operator alerted on it would be alerted on our own + // lifecycle. ProbeUrlOutcome::Failed(ProbeFailureKind::Cancelled) => { warn!("Probe cancelled for generic stream: {unique_id}"); return Ok(PreparedGenericProbeOutcome::ProbeFailed); @@ -320,7 +356,7 @@ async fn persist_prepared_generic_stream_metadata( &mut item.additional_properties, item_type, &item.name, - item.virtual_id, + item.virtual_id.get(), raw_video, raw_audio, stats, @@ -342,7 +378,7 @@ async fn persist_prepared_generic_stream_metadata( &mut item.additional_properties, item_type, &item.name, - item.virtual_id, + item.virtual_id.get(), raw_video, raw_audio, stats, @@ -368,7 +404,7 @@ async fn persist_prepared_generic_stream_metadata( &mut item.additional_properties, item_type, &item.name, - item.virtual_id, + item.virtual_id.get(), raw_video, raw_audio, stats, diff --git a/backend/processing/src/processor/trakt.rs b/backend/processing/src/processor/trakt.rs index 4db25018d..e53f29772 100644 --- a/backend/processing/src/processor/trakt.rs +++ b/backend/processing/src/processor/trakt.rs @@ -3,7 +3,7 @@ use log::{debug, info, trace, warn}; use shared::{ error::TuliproxError, model::{ - FieldGetAccessor, FieldSetAccessor, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, + FieldGet, FieldSet, HeaderField, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, TraktContentType, UUIDType, XtreamCluster, }, utils::{hash_string, Internable, CONSTANTS}, @@ -221,7 +221,9 @@ fn clone_item_for_trakt_category(item: &PlaylistItem, category_name: &str, group }; with!(mut modified_item.header => header { - let title = header.get_field("caption").unwrap_or_else(|| Arc::clone(&header.title)); + let title = header + .get(HeaderField::Caption) + .map_or_else(|| Arc::clone(&header.title), |value| value.to_arc()); if extract_quality(&title).is_none() { if let Some(quality) = extract_quality(&header.group) { let mut caption = String::with_capacity(title.len() + 6); @@ -229,7 +231,7 @@ fn clone_item_for_trakt_category(item: &PlaylistItem, category_name: &str, group caption.push_str(quality); caption.push_str("] "); caption.push_str(&title); - header.set_field("caption", &caption); + header.set(HeaderField::Caption, &caption); } } header.group = group_title.clone(); @@ -601,7 +603,7 @@ mod tests { title: title.intern(), name: title.intern(), url: format!("media-server://plex/server/{virtual_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(), - virtual_id, + virtual_id: shared::model::VirtualId::new(virtual_id), xtream_cluster: XtreamCluster::Series, item_type: PlaylistItemType::Series, additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { diff --git a/backend/processing/src/processor/xtream.rs b/backend/processing/src/processor/xtream.rs index 0ef396586..d69d910e1 100644 --- a/backend/processing/src/processor/xtream.rs +++ b/backend/processing/src/processor/xtream.rs @@ -130,7 +130,7 @@ pub async fn update_live_stream_metadata( let ffprobe_timeout = metadata_update.ffprobe.timeout.unwrap_or(60); let user_agent = config.default_user_agent.clone(); let analyze_duration = metadata_update.ffprobe.live_analyze_duration_micros; - let probe_size = metadata_update.ffprobe.live_probe_size_bytes; + let probe_size = metadata_update.ffprobe.live_probe_size_bytes.get(); let display_id = stream_id_opt.map_or_else(|| "StringID".to_string(), |v| v.to_string()); debug!("Probing Live Stream ID {} for input {}", display_id, input.name); diff --git a/backend/processing/src/processor/xtream_series.rs b/backend/processing/src/processor/xtream_series.rs index 7cda2696c..19ba76fa3 100644 --- a/backend/processing/src/processor/xtream_series.rs +++ b/backend/processing/src/processor/xtream_series.rs @@ -1,8 +1,9 @@ use crate::{ fetched_playlist::FetchedPlaylist, + metadata_sink::MetadataUpdateSink, processor::{ create_resolve_options_function_for_xtream_target, - playlist::{PlaylistProcessingContext, ProcessingPipe}, + playlist::{execute_pipeline_on_groups, PlaylistProcessingContext, ProcessingPipe}, process_foreground_retry_once, select_cancel_token, ProbeHandleGuard, ResolveOptions, ResolveOptionsFlags, FOREGROUND_BATCH_SIZE as BATCH_SIZE, FOREGROUND_MIN_RETRY_DELAY_SECS, FOREGROUND_RETRY_BATCH_MAX_SIZE as RETRY_BATCH_MAX_SIZE, @@ -16,7 +17,7 @@ use shared::{ error::TuliproxError, foundation::ValueProvider, model::{ - MediaQuality, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, SeriesStreamProperties, + EventSink, MediaQuality, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType, SeriesStreamProperties, StreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamSeriesInfo, }, }; @@ -51,7 +52,7 @@ use tuliprox_library::{ use tuliprox_parser::xtream::{create_xtream_series_episode_url, parse_xtream_series_info}; use tuliprox_repository::{ get_input_storage_path, persist_input_series_info_batch, persists_input_series_info, xtream_get_file_path, - BPlusTreeQuery, MemoryPlaylistSource, + BPlusTreeQuery, }; use tuliprox_session::ActiveProviderManager; @@ -72,14 +73,14 @@ impl SeriesProbeSettings { analyze_duration_micros: metadata_update .map_or(defaults.ffprobe.analyze_duration_micros, |cfg| cfg.ffprobe.analyze_duration_micros), probe_size_bytes: metadata_update - .map_or(defaults.ffprobe.probe_size_bytes, |cfg| cfg.ffprobe.probe_size_bytes), + .map_or(defaults.ffprobe.probe_size_bytes.get(), |cfg| cfg.ffprobe.probe_size_bytes.get()), } } } #[allow(clippy::too_many_arguments)] -pub async fn playlist_resolve_series( - ctx: &PlaylistProcessingContext, +pub async fn playlist_resolve_series( + ctx: &PlaylistProcessingContext, target: &ConfigTarget, errors: &mut Vec, pipe: &ProcessingPipe, @@ -110,8 +111,8 @@ pub async fn playlist_resolve_series( } #[allow(clippy::too_many_lines, clippy::too_many_arguments)] -async fn playlist_resolve_series_info( - ctx: &PlaylistProcessingContext, +async fn playlist_resolve_series_info( + ctx: &PlaylistProcessingContext, _errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>, resolve_options: ResolveOptions, @@ -139,15 +140,8 @@ async fn playlist_resolve_series_info( }; // Apply pipe transformations to new groups - let mut new_playlist = groups_to_add; - for f in pipe { - let mut source = MemoryPlaylistSource::new(new_playlist).into_source(); - if let Some(v) = f(&mut source, target) { - new_playlist = v; - } else { - new_playlist = source.take_groups(); - } - } + let (new_playlist, pipeline_outcome) = execute_pipeline_on_groups(groups_to_add, target, pipe); + debug!("Resolved series pipeline outcome: {pipeline_outcome:?}"); // Apply resolved episodes to playlist fpl.extend_playlist(new_playlist); @@ -197,8 +191,8 @@ fn sync_resolved_series_properties(provider_fpl: &mut FetchedPlaylist<'_>, proce } } -fn queue_background_series_info( - ctx: &PlaylistProcessingContext, +fn queue_background_series_info( + ctx: &PlaylistProcessingContext, fpl: &mut FetchedPlaylist<'_>, filter: impl Fn(&PlaylistItem) -> bool, resolve_options: &ResolveOptions, @@ -289,8 +283,8 @@ fn queue_background_series_info( } #[allow(clippy::too_many_lines)] -async fn process_immediate_series_info( - ctx: &PlaylistProcessingContext, +async fn process_immediate_series_info( + ctx: &PlaylistProcessingContext, fpl: &mut FetchedPlaylist<'_>, filter: impl Fn(&PlaylistItem) -> bool, resolve_options: &ResolveOptions, @@ -574,8 +568,8 @@ fn expand_series_item(pli: &PlaylistItem, input: &ConfigInput) -> Option( + ctx: &PlaylistProcessingContext, active_provider: &Arc, input: &ConfigInput, pli: &PlaylistItem, @@ -800,7 +794,7 @@ pub async fn update_series_metadata( if content.is_empty() { debug!("Series {display_id}: provider returned empty content for info fetch"); } else { - let canonical_series_id = existing_item.as_ref().map_or(series_id, PlaylistEntry::get_virtual_id); + let canonical_series_id = existing_item.as_ref().map_or(series_id, |item| item.get_virtual_id().get()); match serde_json::from_str::(&content) { Ok(mut json_value) => { if let Some(info) = json_value.get_mut("info").and_then(|v| v.as_object_mut()) { @@ -974,7 +968,7 @@ pub async fn update_series_metadata( let probe_url = match input.resolve_url(&episode_url) { Ok(url) => url, Err(err) => { - if matches!(err, shared::error::TuliproxError::ConfigInput(_)) { + if err.kind() == shared::error::ErrorKind::ConfigInput { debug!( "Provider config resolution failed for series episode '{}' (S{}E{}): {err}", ep.title, ep.season, ep.episode_num diff --git a/backend/processing/src/processor/xtream_vod.rs b/backend/processing/src/processor/xtream_vod.rs index 5b2d658ae..aff1315c7 100644 --- a/backend/processing/src/processor/xtream_vod.rs +++ b/backend/processing/src/processor/xtream_vod.rs @@ -1,6 +1,7 @@ use crate::{ fetched_playlist::FetchedPlaylist, input_cache::resolve_input_storage_path, + metadata_sink::MetadataUpdateSink, processor::{ create_resolve_options_function_for_xtream_target, playlist::PlaylistProcessingContext, process_foreground_retry_once, select_cancel_token, ProbeHandleGuard, ResolveOptions, ResolveOptionsFlags, @@ -16,8 +17,8 @@ use shared::{ error::TuliproxError, foundation::ValueProvider, model::{ - MediaQuality, PlaylistEntry, PlaylistItem, PlaylistItemType, StreamProperties, VideoStreamDetailProperties, - VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamVideoInfo, + EventSink, MediaQuality, PlaylistEntry, PlaylistItem, PlaylistItemType, StreamProperties, + VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, XtreamVideoInfo, }, }; use std::{ @@ -56,8 +57,8 @@ use tuliprox_session::ActiveProviderManager; create_resolve_options_function_for_xtream_target!(vod); #[allow(clippy::too_many_lines)] -pub async fn playlist_resolve_vod( - ctx: &PlaylistProcessingContext, +pub async fn playlist_resolve_vod( + ctx: &PlaylistProcessingContext, target: &ConfigTarget, errors: &mut Vec, provider_fpl: &mut FetchedPlaylist<'_>, @@ -139,8 +140,8 @@ fn sync_resolved_vod_properties(provider_fpl: &mut FetchedPlaylist<'_>, processe } #[allow(clippy::too_many_lines, clippy::too_many_arguments)] -async fn playlist_resolve_vod_info( - ctx: &PlaylistProcessingContext, +async fn playlist_resolve_vod_info( + ctx: &PlaylistProcessingContext, _errors: &mut Vec, fpl: &mut FetchedPlaylist<'_>, resolve_options: ResolveOptions, @@ -163,8 +164,8 @@ async fn playlist_resolve_vod_info( } #[allow(clippy::too_many_lines)] -async fn process_immediate_vod_info( - ctx: &PlaylistProcessingContext, +async fn process_immediate_vod_info( + ctx: &PlaylistProcessingContext, fpl: &mut FetchedPlaylist<'_>, filter: impl Fn(&PlaylistItem) -> bool, resolve_options: ResolveOptions, @@ -449,8 +450,8 @@ fn check_resolve_tmdb( } } -fn queue_background_vod_info( - ctx: &PlaylistProcessingContext, +fn queue_background_vod_info( + ctx: &PlaylistProcessingContext, fpl: &mut FetchedPlaylist<'_>, filter: impl Fn(&PlaylistItem) -> bool, resolve_options: &ResolveOptions, @@ -537,8 +538,8 @@ fn queue_background_vod_info( } } -async fn update_vod_info_immediate( - ctx: &PlaylistProcessingContext, +async fn update_vod_info_immediate( + ctx: &PlaylistProcessingContext, active_provider: &Arc, input: &ConfigInput, pli: &PlaylistItem, @@ -857,7 +858,7 @@ pub async fn update_vod_metadata( true, ); let probe_url = input.resolve_url(&stream_url).map_err(|err| { - if matches!(err, shared::error::TuliproxError::ConfigInput(_)) { + if err.kind() == shared::error::ErrorKind::ConfigInput { shared::error::TuliproxError::ConfigInput(format!( "Provider config resolution failed for VOD probe URL '{stream_url}': {err}" )) @@ -874,7 +875,7 @@ pub async fn update_vod_metadata( let ffprobe_timeout = metadata_update.ffprobe.timeout.unwrap_or(60); let user_agent = config.default_user_agent.clone(); let analyze_duration = metadata_update.ffprobe.analyze_duration_micros; - let probe_size = metadata_update.ffprobe.probe_size_bytes; + let probe_size = metadata_update.ffprobe.probe_size_bytes.get(); let probe_priority = config .metadata_update diff --git a/backend/repository/Cargo.toml b/backend/repository/Cargo.toml index a28bbf6cf..bd3702752 100644 --- a/backend/repository/Cargo.toml +++ b/backend/repository/Cargo.toml @@ -25,7 +25,6 @@ chrono.workspace = true crc32fast = "1.5.1" env_logger = "0.11.11" filetime = "0.2.29" -fs2 = "0.4.3" futures.workspace = true indexmap.workspace = true log.workspace = true diff --git a/backend/repository/src/db_viewer.rs b/backend/repository/src/db_viewer.rs index c2cb7dcd4..ac1fb1c26 100644 --- a/backend/repository/src/db_viewer.rs +++ b/backend/repository/src/db_viewer.rs @@ -256,7 +256,9 @@ mod tests { use lz4_flex::compress_prepend_size; use serde_json::{json, Value}; use shared::{ - model::{LiveStreamProperties, PlaylistItemType, StreamProperties, XtreamCluster, XtreamPlaylistItem}, + model::{ + LiveStreamProperties, PlaylistItemType, StreamProperties, VirtualId, XtreamCluster, XtreamPlaylistItem, + }, utils::Internable, }; use tempfile::tempdir; @@ -298,7 +300,7 @@ mod tests { #[test] fn human_readable_dump_decodes_compressed_video_and_audio_fields() { let item = XtreamPlaylistItem { - virtual_id: 42, + virtual_id: VirtualId::new(42), provider_id: 52_568, name: "Example".intern(), logo: "".intern(), diff --git a/backend/repository/src/epg_repository.rs b/backend/repository/src/epg_repository.rs index 11b5cb1fe..b299baa01 100644 --- a/backend/repository/src/epg_repository.rs +++ b/backend/repository/src/epg_repository.rs @@ -240,6 +240,7 @@ mod tests { mapping: Arc::new(ArcSwapOption::new(None)), favourites: None, processing_order: ProcessingOrder::default(), + execution_plan: tuliprox_core::model::TargetExecutionPlan::default(), watch: None, use_memory_cache: false, } diff --git a/backend/repository/src/identity_registry.rs b/backend/repository/src/identity_registry.rs index b7d0924eb..2f0866b88 100644 --- a/backend/repository/src/identity_registry.rs +++ b/backend/repository/src/identity_registry.rs @@ -137,8 +137,8 @@ impl IdentityRegistry { /// Build an empty registry without touching disk. Used by the /// fail-closed path so the caller can still call methods that - /// require a registry. - fn empty(path: PathBuf) -> Self { Self { state: RwLock::new(PersistedIdentityRegistry::default()), path } } + /// require a registry, and by tests that never persist. + pub fn empty(path: PathBuf) -> Self { Self { state: RwLock::new(PersistedIdentityRegistry::default()), path } } async fn load_or_detect_failure(path: &Path) -> Result, FailClosedReason> { match tokio::fs::read(path).await { @@ -223,6 +223,28 @@ impl IdentityRegistry { Ok(new_id) } + /// Insert a brand-new API-user mapping and persist atomically. + /// + /// The API-user namespace had a `sync_current_principals` path in but no + /// way in for a principal that appears after bootstrap, so a proxy user + /// added at runtime had no stable id to be issued. + pub async fn register_api_user(&self, username: &str) -> Result { + let key = canonical_username(username); + if key.is_empty() { + return Err(RegistryError::EmptyUsername); + } + let mut state = self.state.write().await; + if let Some(existing) = state.api_users.get(&key) { + return Ok(existing.clone()); + } + let new_id = UserId::from(format!("{}{}", UserId::API_NAMESPACE, Self::new_uuid_hex())); + state.api_users.insert(key, new_id.clone()); + let snapshot = state.clone(); + drop(state); + Self::write_to_disk(&self.path, &snapshot).await.map_err(RegistryError::Persist)?; + Ok(new_id) + } + /// Look up a `UserId` by the canonical username. Returns `None` /// for unknown users. pub async fn lookup_by_username(&self, username: &str) -> Option { @@ -230,6 +252,12 @@ impl IdentityRegistry { self.state.read().await.web_users.get(&key).cloned() } + /// Look up an API user's `UserId` by canonical username. + pub async fn lookup_api_by_username(&self, username: &str) -> Option { + let key = canonical_username(username); + self.state.read().await.api_users.get(&key).cloned() + } + /// Look up the canonical username for a `UserId`. Returns `None` /// if the registry has no entry for that ID. The lookup is /// exhaustive across both web and API namespaces. diff --git a/backend/repository/src/lib.rs b/backend/repository/src/lib.rs index 5bcac643b..e23630e14 100644 --- a/backend/repository/src/lib.rs +++ b/backend/repository/src/lib.rs @@ -14,7 +14,9 @@ mod m3u_playlist_iterator; mod m3u_repository; mod metadata_retry_record; mod network_access; +mod playlist_backend; pub mod playlist_cache_loader; +mod playlist_items; pub mod playlist_mem_cache; mod playlist_repository; mod playlist_scratch; @@ -29,6 +31,7 @@ mod startup_migration; pub mod storage_const; mod stream_history; mod strm_repository; +pub mod token_revocations; mod user_repository; mod xtream_playlist_iterator; mod xtream_repository; @@ -45,6 +48,8 @@ pub use m3u_playlist_iterator::*; pub use m3u_repository::*; pub use metadata_retry_record::*; pub use network_access::*; +pub use playlist_backend::*; +pub use playlist_items::*; pub use playlist_mem_cache::*; pub use playlist_repository::*; pub use playlist_source::*; diff --git a/backend/repository/src/library_repository.rs b/backend/repository/src/library_repository.rs index 40c4dacb9..e571236d7 100644 --- a/backend/repository/src/library_repository.rs +++ b/backend/repository/src/library_repository.rs @@ -5,7 +5,7 @@ use crate::{ use indexmap::IndexMap; use shared::{ error::TuliproxError, - model::{PlaylistGroup, PlaylistItem, StreamProperties, UUIDType, XtreamCluster, XtreamPlaylistItem}, + model::{PlaylistGroup, PlaylistItem, StreamProperties, UUIDType, XtreamPlaylistItem}, }; use std::{collections::HashMap, path::Path, sync::Arc}; use tokio::task; @@ -140,7 +140,7 @@ pub async fn load_input_local_library_playlist( let mut group_cnt = 0; for entry in query.iter() { let (_, item) = entry.map_err(|error| repository_read_error(&lib_path, error))?; - let cluster = XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live); + let cluster = item.item_type.cluster(); let key = (cluster, item.group.clone()); groups .entry(key) @@ -176,7 +176,9 @@ pub async fn load_input_local_library_playlist( mod tests { use super::*; use shared::{ - model::{EpisodeStreamProperties, VideoStreamDetailProperties, VideoStreamProperties}, + model::{ + EpisodeStreamProperties, VideoStreamDetailProperties, VideoStreamProperties, VirtualId, XtreamCluster, + }, utils::Internable, }; @@ -184,7 +186,9 @@ mod tests { fn bplustree_read_errors_are_generic_repository_errors() { let error = repository_read_error(Path::new("library.db"), std::io::Error::other("corrupt page")); assert!( - matches!(error, TuliproxError::Repository(message) if message.contains("library.db") && message.contains("corrupt page")) + error.kind() == shared::error::ErrorKind::Repository + && error.message().contains("library.db") + && error.message().contains("corrupt page") ); } @@ -197,7 +201,7 @@ mod tests { bitrate: u32, ) -> XtreamPlaylistItem { XtreamPlaylistItem { - virtual_id: 1, + virtual_id: VirtualId::new(1), provider_id: 1, name: "movie".intern(), logo: "".intern(), @@ -232,7 +236,7 @@ mod tests { fn episode_item(url: &str, added: Option<&str>, video: Option<&str>, audio: Option<&str>) -> XtreamPlaylistItem { XtreamPlaylistItem { - virtual_id: 2, + virtual_id: VirtualId::new(2), provider_id: 2, name: "ep".intern(), logo: "".intern(), diff --git a/backend/repository/src/m3u_playlist_iterator.rs b/backend/repository/src/m3u_playlist_iterator.rs index df71a867e..04de72f7f 100644 --- a/backend/repository/src/m3u_playlist_iterator.rs +++ b/backend/repository/src/m3u_playlist_iterator.rs @@ -149,7 +149,7 @@ fn apply_rewrite( ctx.base_url, ctx.username, target_id, - m3u_pli.virtual_id, + m3u_pli.virtual_id.get(), effective_source_url.as_ref(), catchup, ) @@ -456,7 +456,7 @@ mod tests { use shared::{ model::{ CatchupProperties, LiveStreamProperties, M3uPlaylistItem, PlaylistItemType, ProviderUrlSelectionPolicy, - ProxyType, StreamProperties, + ProxyType, StreamProperties, VirtualId, }, utils::Internable, }; @@ -484,7 +484,7 @@ mod tests { fn m3u_item(url: &str) -> M3uPlaylistItem { M3uPlaylistItem { - virtual_id: 813_294, + virtual_id: VirtualId::new(813_294), provider_id: "813294".intern(), name: "France 4K".intern(), chno: 0, @@ -759,7 +759,7 @@ mod tests { async fn iterator_forwards_one_storage_error_then_ends() { let (tx, rx) = mpsc::channel(2); assert!(tx.send(Ok((m3u_item("http://example.test/live.ts"), true))).await.is_ok()); - assert!(tx.send(Err(shared::error::TuliproxError::RepositoryM3u("corrupt page".into()))).await.is_ok()); + assert!(tx.send(Err(shared::error::TuliproxError::RepositoryM3u("corrupt page"))).await.is_ok()); drop(tx); let mut iterator = M3uPlaylistIterator { inner: LockedReceiverStream::new_empty(rx) }; diff --git a/backend/repository/src/m3u_repository.rs b/backend/repository/src/m3u_repository.rs index 9d23ded8a..fcbf49c3f 100644 --- a/backend/repository/src/m3u_repository.rs +++ b/backend/repository/src/m3u_repository.rs @@ -2,18 +2,15 @@ use super::playlist_mem_cache::PlaylistStorageState; use crate::{ bplustree::{BPlusTree, BPlusTreeQuery}, m3u_playlist_iterator::M3uPlaylistM3uTextIterator, - open_playlist_reader, + playlist_backend::{ensure_storage_path, iter_raw_playlist, M3u, PlaylistBackend}, playlist_repository::get_input_m3u_playlist_file_path, - storage::{ - ensure_target_storage_subpath, get_file_path_for_db_index, get_input_storage_path, get_target_storage_path, - }, + storage::{get_input_storage_path, get_target_storage_path}, storage_const, xtream_repository::CategoryKey, LockedReceiverStream, }; use indexmap::IndexMap; use log::{error, warn}; -use serde::{Deserialize, Serialize}; use shared::{ concat_string, error::{str_to_io_error, string_to_io_error, TuliproxError}, @@ -29,8 +26,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use tokio::{fs, io::AsyncWriteExt, sync::mpsc, task}; -use tokio_stream::Stream; +use tokio::{fs, io::AsyncWriteExt, task}; use tuliprox_core::{ model::{AppConfig, Config, ConfigInput, ConfigTarget, M3uTargetOutput, ProxyUserCredentials}, utils::{async_file_writer, file_exists_async}, @@ -62,13 +58,12 @@ pub fn m3u_get_epg_file_path_for_target(target_path: &Path) -> PathBuf { target_path.join(storage_const::PATH_M3U).join(concat_string!("epg.", storage_const::FILE_SUFFIX_DB)) } -pub fn m3u_get_storage_path(cfg: &Config, target_name: &str) -> Option { - get_target_storage_path(cfg, target_name) - .map(|target_path| target_path.join(PathBuf::from(storage_const::PATH_M3U))) -} +#[inline] +pub fn m3u_get_storage_path(cfg: &Config, target_name: &str) -> Option { M3u::storage_path(cfg, target_name) } +#[inline] pub async fn ensure_m3u_storage_path(cfg: &Config, target_name: &str) -> Result { - ensure_target_storage_subpath(cfg, target_name, "m3u", m3u_get_storage_path, TuliproxError::RepositoryM3u).await + ensure_storage_path::(cfg, target_name).await } fn provider_m3u_filename(path: &Path) -> PathBuf { @@ -350,91 +345,35 @@ pub async fn m3u_get_item_for_stream_id( } } +/// Keep items belonging to `cluster`; keep everything when `None`. +/// +/// Returned as an opaque closure so `iter_raw_playlist` monomorphizes over it +/// rather than taking a boxed predicate. +fn m3u_cluster_filter(cluster: Option) -> impl Fn(&M3uPlaylistItem) -> bool + Send + 'static { + move |item| cluster.is_none_or(|wanted| item.item_type.is_cluster(wanted)) +} + pub async fn iter_raw_m3u_target_playlist( config: &AppConfig, target: &ConfigTarget, cluster: Option, -) -> Option> + Send + Unpin>> { +) -> Option>> { let target_path = get_target_storage_path(&config.config.load(), target.name.as_str())?; let m3u_path = m3u_get_file_path_for_db(&target_path); - iter_raw_m3u_playlist::(config, &m3u_path, cluster).await + iter_raw_playlist::(config, &m3u_path, m3u_cluster_filter(cluster)).await } pub async fn iter_raw_m3u_input_playlist( app_config: &AppConfig, input: &ConfigInput, cluster: Option, -) -> Option> + Send + Unpin>> { +) -> Option>> { let cfg = app_config.config.load(); let storage_path = get_input_storage_path(&input.name, &cfg.storage_dir).await.ok()?; let m3u_path = get_input_m3u_playlist_file_path(&storage_path, &input.name); - iter_raw_m3u_playlist::>(app_config, &m3u_path, cluster).await -} - -async fn iter_raw_m3u_playlist( - app_config: &AppConfig, - m3u_path: &Path, - cluster: Option, -) -> Option> + Send + Unpin>> -where - ItemKey: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Send + Sync + 'static, - SortKey: Ord + for<'de> Deserialize<'de> + Send + 'static, -{ - // Two read locks: iter_lock is held by LockedReceiverStream for the consumer lifetime, - // while bg_lock is moved into spawn_blocking to guard the on-disk reader. - let iter_lock = app_config.file_locks.read_lock(m3u_path).await; - if !file_exists_async(m3u_path).await { - return None; - } - let bg_lock = app_config.file_locks.read_lock(m3u_path).await; - - let m3u_path = m3u_path.to_path_buf(); - let index_path = get_file_path_for_db_index(&m3u_path); - let (tx, rx) = mpsc::channel::>(256); - - let m3u_path_for_log = m3u_path.clone(); - let err_tx = tx.clone(); - let join_err_tx = tx.clone(); - let handle = task::spawn_blocking(move || { - let _guard = bg_lock; - let reader = match open_playlist_reader::(&m3u_path, &index_path, None) { - Ok(reader) => reader, - Err(err) => { - let _ = err_tx.blocking_send(Err(err)); - return; - } - }; - - for entry in reader { - let item = match entry { - Ok((_, item)) => item, - Err(err) => { - error!("Skipping unreadable M3U playlist entry: {err}"); - continue; - } - }; - if cluster.is_none_or(|c| item.item_type.is_cluster(c)) && tx.blocking_send(Ok(item)).is_err() { - break; - } - } - }); - tokio::spawn(async move { - if let Err(err) = handle.await { - error!("M3U playlist reader task failed for {}: {err}", m3u_path_for_log.display()); - let _ = join_err_tx - .send(Err(TuliproxError::RepositoryM3u(format!( - "M3U playlist reader task failed for {}: {err}", - m3u_path_for_log.display() - )))) - .await; - } - }); - - let stream: Box> + Send + Unpin> = - Box::new(LockedReceiverStream::new(rx, iter_lock)); - Some(stream) + iter_raw_playlist::, _>(app_config, &m3u_path, m3u_cluster_filter(cluster)).await } pub async fn persist_input_m3u_playlist( @@ -527,7 +466,7 @@ pub async fn load_input_m3u_playlist( let mut group_cnt = 0; for entry in query.iter() { let (_, item) = entry.map_err(|error| TuliproxError::RepositoryM3u(error.to_string()))?; - let cluster = XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live); + let cluster = item.item_type.cluster(); let key = (cluster, item.group.clone()); groups .entry(key) diff --git a/backend/repository/src/playlist_backend.rs b/backend/repository/src/playlist_backend.rs new file mode 100644 index 000000000..5edab93a6 --- /dev/null +++ b/backend/repository/src/playlist_backend.rs @@ -0,0 +1,211 @@ +//! Static-dispatch description of a target-scoped, B+Tree-backed playlist store. +//! +//! The M3U and Xtream repositories were two transcriptions of one design: the +//! same storage-path derivation, the same two-read-lock iteration over a +//! B+Tree with sorted-index fallback, the same `spawn_blocking` producer +//! feeding a bounded channel, differing only in the item type, the storage +//! subdirectory and which [`TuliproxError`] variant wrapped a failure. +//! +//! This module names those differences as associated types and constants so the +//! shared body can be written once. Implementors are zero-sized markers, so +//! `iter_raw_playlist::` and `iter_raw_playlist::` +//! monomorphize into two independent functions: no trait object, no vtable and +//! no allocation is introduced by the abstraction. +//! +//! Stalker is deliberately **not** a [`PlaylistBackend`]. Its store is keyed by +//! input rather than target, carries no sorted index, and yields bare items +//! rather than `Result`s. Forcing it into this trait would mean methods that +//! exist only to return `None` for one of three implementors, which is how an +//! abstraction stops describing anything. + +use crate::{ + open_playlist_reader, + storage::{ensure_target_storage_subpath, get_file_path_for_db_index, get_target_storage_path}, + storage_const, LockedReceiverStream, +}; +use log::error; +use serde::{Deserialize, Serialize}; +use shared::{ + error::TuliproxError, + model::{M3uPlaylistItem, XtreamPlaylistItem}, +}; +use std::path::{Path, PathBuf}; +use tokio::{sync::mpsc, task}; +use tuliprox_core::{ + model::{AppConfig, Config}, + utils::file_exists_async, +}; + +/// Bounded channel depth for the producer task. Large enough to keep the disk +/// reader ahead of the consumer, small enough that a stalled consumer cannot +/// pull an entire playlist into memory. +const PLAYLIST_CHANNEL_CAPACITY: usize = 256; + +/// Bounds a B+Tree key must satisfy to be read back by [`iter_raw_playlist`]. +/// +/// A bound alias only — the blanket impl means naming it costs nothing and adds +/// no indirection. It exists so the key can stay a *function* parameter: the M3U +/// backend keys its target store by `u32` and its input store by `Arc`, so +/// the key is not a property of the backend and cannot be an associated type. +pub trait PlaylistKey: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Send + Sync + 'static {} + +impl PlaylistKey for T where T: Ord + Serialize + for<'de> Deserialize<'de> + Clone + Send + Sync + 'static {} + +/// One on-disk, target-scoped playlist backend, described for static dispatch. +pub trait PlaylistBackend { + /// The item persisted as the B+Tree value. + type Item: Serialize + for<'de> Deserialize<'de> + Clone + Send + 'static; + + /// The sorted-index key used when a sidecar index is present. + type SortKey: Ord + for<'de> Deserialize<'de> + Send + 'static; + + /// Storage subdirectory beneath the target directory. + const SUBDIR: &'static str; + + /// Human-readable backend name, used in log lines and error messages. + const LABEL: &'static str; + + /// Whether the consumer-visible stream keeps a read lock on the playlist + /// file for its entire lifetime. + /// + /// M3U holds one; Xtream historically does not, and that difference is + /// preserved here rather than silently normalised — changing when a read + /// lock is released alters what a concurrent writer can do mid-iteration. + /// See the note in [`iter_raw_playlist`]. + const HOLD_ITER_LOCK: bool; + + /// Wrap `message` in this backend's error variant. + fn repo_error(message: String) -> TuliproxError; + + /// Storage directory for `target_name`, if the target resolves at all. + fn storage_path(cfg: &Config, target_name: &str) -> Option { + get_target_storage_path(cfg, target_name).map(|target_path| target_path.join(PathBuf::from(Self::SUBDIR))) + } +} + +/// M3U playlist storage. +pub struct M3u; + +/// Xtream playlist storage. +pub struct Xtream; + +/// Create this backend's storage directory for `target_name`. +pub async fn ensure_storage_path( + cfg: &Config, + target_name: &str, +) -> Result { + ensure_target_storage_subpath(cfg, target_name, B::LABEL, B::storage_path, B::repo_error).await +} + +/// Stream every item in the playlist database at `path`, keeping only those +/// `accept` returns `true` for. +/// +/// Returns `None` when the database does not exist, which callers treat as "no +/// playlist persisted yet" rather than an error. +/// +/// # Locking +/// +/// Two read locks are taken when `B::HOLD_ITER_LOCK` is set: `iter_lock` travels +/// with the returned stream so the file cannot be replaced while the consumer is +/// still reading, and `bg_lock` moves into the blocking producer to guard the +/// reader itself. Backends with `HOLD_ITER_LOCK == false` take only `bg_lock`, +/// matching the behaviour they had before this function was extracted. +pub async fn iter_raw_playlist( + app_config: &AppConfig, + path: &Path, + accept: F, +) -> Option>> +where + B: PlaylistBackend, + K: PlaylistKey, + F: Fn(&B::Item) -> bool + Send + 'static, +{ + let iter_lock = if B::HOLD_ITER_LOCK { Some(app_config.file_locks.read_lock(path).await) } else { None }; + + if !file_exists_async(path).await { + return None; + } + + let bg_lock = app_config.file_locks.read_lock(path).await; + + let path = path.to_path_buf(); + let index_path = get_file_path_for_db_index(&path); + let (tx, rx) = mpsc::channel::>(PLAYLIST_CHANNEL_CAPACITY); + + let path_for_log = path.clone(); + let index_path_for_log = index_path.clone(); + let open_err_tx = tx.clone(); + let join_err_tx = tx.clone(); + + let handle = task::spawn_blocking(move || { + let _guard = bg_lock; + let reader = match open_playlist_reader::(&path, &index_path, None) { + Ok(reader) => reader, + Err(err) => { + error!( + "Failed to open {} playlist reader {} (index {}): {err}", + B::LABEL, + path.display(), + index_path.display() + ); + let _ = open_err_tx.blocking_send(Err(err)); + return; + } + }; + + for entry in reader { + let item = match entry { + Ok((_, item)) => item, + Err(err) => { + error!("Skipping unreadable {} playlist entry: {err}", B::LABEL); + continue; + } + }; + if accept(&item) && tx.blocking_send(Ok(item)).is_err() { + break; + } + } + }); + + tokio::spawn(async move { + if let Err(err) = handle.await { + error!( + "{} playlist producer task failed for {} (index {}): {err}", + B::LABEL, + path_for_log.display(), + index_path_for_log.display() + ); + let _ = join_err_tx + .send(Err(B::repo_error(format!( + "{} playlist producer task failed for {}: {err}", + B::LABEL, + path_for_log.display() + )))) + .await; + } + }); + + Some(LockedReceiverStream::with_optional_guard(rx, iter_lock)) +} + +impl PlaylistBackend for M3u { + type Item = M3uPlaylistItem; + type SortKey = u32; + + const SUBDIR: &'static str = storage_const::PATH_M3U; + const LABEL: &'static str = "M3U"; + const HOLD_ITER_LOCK: bool = true; + + fn repo_error(message: String) -> TuliproxError { TuliproxError::RepositoryM3u(message) } +} + +impl PlaylistBackend for Xtream { + type Item = XtreamPlaylistItem; + type SortKey = u32; + + const SUBDIR: &'static str = storage_const::PATH_XTREAM; + const LABEL: &'static str = "Xtream"; + const HOLD_ITER_LOCK: bool = false; + + fn repo_error(message: String) -> TuliproxError { TuliproxError::RepositoryXtream(message) } +} diff --git a/backend/repository/src/playlist_items.rs b/backend/repository/src/playlist_items.rs new file mode 100644 index 000000000..bf1b5636b --- /dev/null +++ b/backend/repository/src/playlist_items.rs @@ -0,0 +1,345 @@ +//! Concrete, nameable iterators over a [`crate::PlaylistSource`]. +//! +//! `PlaylistSourceOps` used to return `Box + Send + '_>` from +//! `into_items`, `items` and `items_mut`. The trait is private and was never +//! used as a trait object — `PlaylistSource` dispatches through the +//! `PlaylistSourceKind` enum — so the box bought nothing and cost one +//! allocation per call plus one indirect, uninlinable call **per playlist +//! item**. On a refresh of a few hundred thousand channels that is the hottest +//! traversal in the repository. +//! +//! Composed adapter chains (`flat_map(..).map(..)`) cannot be named, because +//! closure types cannot be written down, so the erasure could not simply be +//! deleted. This module replaces the chains with hand-rolled state machines +//! whose types *are* nameable, which lets the enum hold them directly: +//! +//! ```text +//! before: PlaylistSource::into_items() -> Box + Send + '_> +//! after: PlaylistSource::into_items() -> ClusterFiltered> +//! ``` +//! +//! Nothing here allocates and nothing here dispatches dynamically: `next()` is +//! a discriminant branch the compiler can inline through and predict. + +use crate::bplustree::BPlusTreeDiskIterator; +use log::error; +use serde::Deserialize; +use shared::model::{ + stalker_item::StalkerPlaylistItem, M3uPlaylistItem, PlaylistGroup, PlaylistItem, UUIDType, XtreamCluster, + XtreamPlaylistItem, +}; +use std::{borrow::Cow, collections::HashSet, sync::Arc}; + +// --------------------------------------------------------------------------- +// B+Tree building blocks +// --------------------------------------------------------------------------- + +/// One on-disk B+Tree store, yielding values and skipping unreadable entries. +/// +/// Replaces `q.iter().filter_map(log_and_skip_btree_error).map(..)`; the log +/// message is kept verbatim so operator-facing output does not change. +pub struct BTreeValues<'a, K, V> { + inner: Option>, +} + +impl<'a, K, V> BTreeValues<'a, K, V> { + #[inline] + pub fn new(inner: Option>) -> Self { Self { inner } } + + #[inline] + pub fn empty() -> Self { Self { inner: None } } +} + +impl Iterator for BTreeValues<'_, K, V> +where + K: Ord + for<'de> Deserialize<'de>, + V: for<'de> Deserialize<'de>, +{ + type Item = V; + + #[inline] + fn next(&mut self) -> Option { + let inner = self.inner.as_mut()?; + loop { + match inner.next()? { + Ok((_, value)) => return Some(value), + Err(error) => { + error!("Skipping unreadable B+Tree playlist entry; iteration continues when possible: {error}"); + } + } + } + } +} + +/// `N` B+Tree stores of the same shape, traversed back to back. +/// +/// Replaces the `live.chain(vod).chain(series)` shape: `N == 1` for the +/// single-file sources, `3` for Xtream, `4` for Stalker. +pub struct BTreeStores<'a, K, V, const N: usize> { + stores: [BTreeValues<'a, K, V>; N], + at: usize, +} + +impl<'a, K, V, const N: usize> BTreeStores<'a, K, V, N> { + #[inline] + pub fn new(stores: [BTreeValues<'a, K, V>; N]) -> Self { Self { stores, at: 0 } } +} + +impl Iterator for BTreeStores<'_, K, V, N> +where + K: Ord + for<'de> Deserialize<'de>, + V: for<'de> Deserialize<'de>, +{ + type Item = V; + + #[inline] + fn next(&mut self) -> Option { + while self.at < N { + if let Some(value) = self.stores[self.at].next() { + return Some(value); + } + self.at += 1; + } + None + } +} + +// --------------------------------------------------------------------------- +// In-memory building blocks +// --------------------------------------------------------------------------- + +/// Moves every channel out of every group, emptying the groups as it goes. +/// +/// This is the in-memory `into_items`, which is destructive by design. +pub struct MemoryDrain<'a> { + groups: std::slice::IterMut<'a, PlaylistGroup>, + current: Option>, +} + +impl<'a> MemoryDrain<'a> { + #[inline] + pub fn new(groups: &'a mut [PlaylistGroup]) -> Self { Self { groups: groups.iter_mut(), current: None } } +} + +impl Iterator for MemoryDrain<'_> { + type Item = PlaylistItem; + + #[inline] + fn next(&mut self) -> Option { + loop { + if let Some(item) = self.current.as_mut().and_then(Iterator::next) { + return Some(item); + } + self.current = Some(self.groups.next()?.channels.drain(..)); + } + } +} + +/// Yields a mutable reference to every channel in every group. +pub struct MemoryItemsMut<'a> { + groups: std::slice::IterMut<'a, PlaylistGroup>, + current: Option>, +} + +impl<'a> MemoryItemsMut<'a> { + #[inline] + pub fn new(groups: &'a mut [PlaylistGroup]) -> Self { Self { groups: groups.iter_mut(), current: None } } +} + +impl<'a> Iterator for MemoryItemsMut<'a> { + type Item = &'a mut PlaylistItem; + + #[inline] + fn next(&mut self) -> Option<&'a mut PlaylistItem> { + loop { + if let Some(item) = self.current.as_mut().and_then(Iterator::next) { + return Some(item); + } + self.current = Some(self.groups.next()?.channels.iter_mut()); + } + } +} + +/// Yields a shared reference to every channel in every group. +pub struct MemoryItems<'a> { + groups: std::slice::Iter<'a, PlaylistGroup>, + current: Option>, +} + +impl<'a> MemoryItems<'a> { + #[inline] + pub fn new(groups: &'a [PlaylistGroup]) -> Self { Self { groups: groups.iter(), current: None } } +} + +impl<'a> Iterator for MemoryItems<'a> { + type Item = &'a PlaylistItem; + + #[inline] + fn next(&mut self) -> Option<&'a PlaylistItem> { + loop { + if let Some(item) = self.current.as_mut().and_then(Iterator::next) { + return Some(item); + } + self.current = Some(self.groups.next()?.channels.iter()); + } + } +} + +// --------------------------------------------------------------------------- +// Owned-item iteration over any source +// --------------------------------------------------------------------------- + +/// Every playlist item of one source, as an owned [`PlaylistItem`]. +/// +/// One variant per `PlaylistSourceKind`, collapsed where the payload and the +/// conversion coincide: the library and media-server stores are both +/// `XtreamPlaylistItem` keyed by [`UUIDType`], so they share `SingleXtream`. +// The B+Tree disk iterators carry sizeable cursor state, so holding three or +// four inline makes this enum ~1.9KB. Boxing the large variants -- clippy's +// suggestion -- would reintroduce exactly the per-traversal heap allocation this +// type exists to remove. The cost here is stack space for one value per +// traversal, not per item: the enum is constructed once, then driven through +// `&mut`, so it is never copied per element. +#[allow(clippy::large_enum_variant)] +pub enum SourceItems<'a> { + Empty, + Xtream(BTreeStores<'a, u32, XtreamPlaylistItem, 3>), + Stalker { stores: BTreeStores<'a, u32, StalkerPlaylistItem, 4>, input_name: Arc }, + M3u(BTreeStores<'a, Arc, M3uPlaylistItem, 1>), + SingleXtream(BTreeStores<'a, UUIDType, XtreamPlaylistItem, 1>), + Memory(MemoryDrain<'a>), +} + +impl Iterator for SourceItems<'_> { + type Item = PlaylistItem; + + #[inline] + fn next(&mut self) -> Option { + match self { + Self::Empty => None, + Self::Xtream(stores) => stores.next().map(|item| PlaylistItem::from(&item)), + Self::SingleXtream(stores) => stores.next().map(|item| PlaylistItem::from(&item)), + Self::M3u(stores) => stores.next().map(|item| PlaylistItem::from(&item)), + Self::Stalker { stores, input_name } => { + stores.next().map(|item| PlaylistItem::from_stalker(&item, input_name)) + } + Self::Memory(drain) => drain.next(), + } + } +} + +// --------------------------------------------------------------------------- +// Cow and mutable iteration over any source +// --------------------------------------------------------------------------- + +/// Every playlist item of one source, as a [`Cow`]. +/// +/// Disk sources have to materialise each item, so they yield [`Cow::Owned`] and +/// reuse [`SourceItems`] verbatim. Only the in-memory source can hand out a +/// borrow, and it is the only one that yields [`Cow::Borrowed`]. +/// +/// `Owned` must never be constructed from [`SourceItems::Memory`]: the +/// in-memory owned iterator *drains* its groups, whereas `items()` is +/// non-destructive. `PlaylistSource::items` upholds this. +#[allow(clippy::large_enum_variant)] // Inherits SourceItems' size; see the note there. +pub enum SourceCowItems<'a> { + Owned(SourceItems<'a>), + Borrowed(MemoryItems<'a>), +} + +impl<'a> Iterator for SourceCowItems<'a> { + type Item = Cow<'a, PlaylistItem>; + + #[inline] + fn next(&mut self) -> Option> { + match self { + Self::Owned(items) => items.next().map(Cow::Owned), + Self::Borrowed(items) => items.next().map(Cow::Borrowed), + } + } +} + +/// Every playlist item of one source, mutably. +/// +/// Disk-backed sources are read-only, so they are always `Empty`; the warning +/// that used to be emitted from `items_mut` is now emitted where the `Empty` +/// variant is built, which keeps the observable behaviour identical. +pub enum SourceItemsMut<'a> { + Empty, + Memory(MemoryItemsMut<'a>), +} + +impl<'a> Iterator for SourceItemsMut<'a> { + type Item = &'a mut PlaylistItem; + + #[inline] + fn next(&mut self) -> Option<&'a mut PlaylistItem> { + match self { + Self::Empty => None, + Self::Memory(items) => items.next(), + } + } +} + +// --------------------------------------------------------------------------- +// Skip-set filtering +// --------------------------------------------------------------------------- + +/// Reads an item's Xtream cluster regardless of how the iterator yields it. +/// +/// Lets one filter serve the owned, `Cow` and mutable iterators instead of +/// three near-identical closures. +pub trait HasCluster { + fn cluster(&self) -> XtreamCluster; +} + +impl HasCluster for PlaylistItem { + #[inline] + fn cluster(&self) -> XtreamCluster { self.header.xtream_cluster } +} + +impl HasCluster for &mut PlaylistItem { + #[inline] + fn cluster(&self) -> XtreamCluster { self.header.xtream_cluster } +} + +impl HasCluster for Cow<'_, PlaylistItem> { + #[inline] + fn cluster(&self) -> XtreamCluster { self.as_ref().header.xtream_cluster } +} + +/// Drops items whose cluster is in the skip set. +/// +/// The skip set used to be applied by boxing the inner iterator a *second* +/// time inside a `.filter(..)`. Folding it in here means a filtered traversal +/// allocates nothing at all. +pub struct ClusterFiltered { + inner: I, + skip: Option>>, +} + +impl ClusterFiltered { + #[inline] + pub fn new(inner: I, skip: Option>>) -> Self { Self { inner, skip } } +} + +impl Iterator for ClusterFiltered +where + I: Iterator, + I::Item: HasCluster, +{ + type Item = I::Item; + + #[inline] + fn next(&mut self) -> Option { + let Some(skip) = self.skip.as_ref() else { + return self.inner.next(); + }; + loop { + let item = self.inner.next()?; + if !skip.contains(&item.cluster()) { + return Some(item); + } + } + } +} diff --git a/backend/repository/src/playlist_mem_cache.rs b/backend/repository/src/playlist_mem_cache.rs index 8afc87747..d28f88cf3 100644 --- a/backend/repository/src/playlist_mem_cache.rs +++ b/backend/repository/src/playlist_mem_cache.rs @@ -58,7 +58,7 @@ impl PlaylistStorageState { XtreamCluster::Video => &mut xtream.vod, XtreamCluster::Series => &mut xtream.series, } - .insert(pli.virtual_id, pli.clone()); + .insert(pli.virtual_id.get(), pli.clone()); } } } @@ -75,7 +75,7 @@ impl PlaylistStorageState { XtreamCluster::Video => &mut xtream.vod, XtreamCluster::Series => &mut xtream.series, } - .insert(pli.header.virtual_id, XtreamPlaylistItem::from(&pli)); + .insert(pli.header.virtual_id.get(), XtreamPlaylistItem::from(&pli)); } } } diff --git a/backend/repository/src/playlist_repository.rs b/backend/repository/src/playlist_repository.rs index ae744db08..2d817d64e 100644 --- a/backend/repository/src/playlist_repository.rs +++ b/backend/repository/src/playlist_repository.rs @@ -109,7 +109,7 @@ pub async fn persist_playlist( let parent_virtual_id = if item_type.is_series() { target_id_mapping.get_parent_virtual_id_by_uuid(uuid).unwrap_or_default() } else { - 0 + VirtualId::default() }; header.virtual_id = target_id_mapping.get_and_update_virtual_id(uuid, provider_id, item_type, parent_virtual_id); @@ -222,7 +222,7 @@ fn assign_local_series_info_episode_key( local_library_series .entry(header.parent_code.clone()) .or_default() - .push(LocalEpisodeKey { path: header.url.clone(), virtual_id: header.virtual_id }); + .push(LocalEpisodeKey { path: header.url.clone(), virtual_id: header.virtual_id.get() }); } } @@ -235,7 +235,7 @@ fn assign_provider_series_info_episode_key( if item_type == PlaylistItemType::Series { provider_series.entry(header.parent_code.clone()).or_default().push(ProviderEpisodeKey { provider_id: header.get_provider_id().unwrap_or_default(), - virtual_id: header.virtual_id, + virtual_id: header.virtual_id.get(), }); } } @@ -274,7 +274,7 @@ fn media_server_series_episode_detail(header: &PlaylistItemHeader) -> Option Result, TuliproxError> { +) -> Result, TuliproxError> { let target_id_mapping_file = get_target_id_mapping_file(target_path); let _file_lock = app_config.file_locks.read_lock(&target_id_mapping_file).await; // Move B+Tree load to spawn_blocking to avoid blocking tokio runtime let path_clone = target_id_mapping_file.clone(); let target_name = target.name.clone(); - tokio::task::spawn_blocking(move || BPlusTree::::load(&path_clone)) + tokio::task::spawn_blocking(move || BPlusTree::::load(&path_clone)) .await .map_err(|e| TuliproxError::Config(format!("Blocking task failed: {e}")))? .map_err(|err| TuliproxError::Config(format!("Could not find path for target {target_name} err:{err}"))) @@ -877,7 +881,7 @@ mod tests { ConfigTargetOptions, EpgOutputOptions, EpisodeStreamProperties, M3uPlaylistItem, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, - UUIDType, XtreamCluster, XtreamPlaylistItem, + UUIDType, VirtualId, XtreamCluster, XtreamPlaylistItem, }, utils::Internable, }; @@ -1023,7 +1027,7 @@ mod tests { url: direct_source.intern(), item_type: PlaylistItemType::LocalSeries, xtream_cluster: XtreamCluster::Series, - virtual_id, + virtual_id: VirtualId::new(virtual_id), ..PlaylistItemHeader::default() } } @@ -1043,7 +1047,7 @@ mod tests { url: format!("media-server://plex/server/{item_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(), item_type: PlaylistItemType::Series, xtream_cluster: XtreamCluster::Series, - virtual_id, + virtual_id: VirtualId::new(virtual_id), additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties { episode_id: 0, episode, @@ -1112,7 +1116,7 @@ mod tests { url: "media-server://plex/server/malformed?part_key=%2Flibrary%2Fparts%2Fredacted".intern(), item_type: PlaylistItemType::Series, xtream_cluster: XtreamCluster::Series, - virtual_id: 7003, + virtual_id: VirtualId::new(7003), additional_properties: None, ..PlaylistItemHeader::default() }, @@ -1124,7 +1128,7 @@ mod tests { url: "http://provider.example.invalid/series/999.mkv".intern(), item_type: PlaylistItemType::Series, xtream_cluster: XtreamCluster::Series, - virtual_id: 7999, + virtual_id: VirtualId::new(7999), ..PlaylistItemHeader::default() }, }; @@ -1220,7 +1224,8 @@ mod tests { let uuid = channel.header.uuid; let provider_id = channel.header.get_provider_id().unwrap_or_default(); let item_type = channel.header.item_type; - channel.header.virtual_id = target_id_mapping.get_and_update_virtual_id(&uuid, provider_id, item_type, 0); + channel.header.virtual_id = + target_id_mapping.get_and_update_virtual_id(&uuid, provider_id, item_type, VirtualId::new(0)); channel.header.source_ordinal = u32::try_from(idx + 1).expect("ordinal"); } @@ -1231,7 +1236,7 @@ mod tests { target_id_mapping.persist().expect("persist"); let mut query = BPlusTreeQuery::::try_new(&mapping_path).expect("query"); - let record = query.query_zero_copy(&episode_virtual_id).expect("query ok").expect("record missing"); + let record = query.query_zero_copy(&episode_virtual_id.get()).expect("query ok").expect("record missing"); assert_eq!(record.parent_virtual_id, series_virtual_id); assert_eq!(playlist[0].channels[0].header.virtual_id, episode_virtual_id); @@ -1245,7 +1250,7 @@ mod tests { let input_name = "provider-input".intern(); let xtream_series_info = XtreamPlaylistItem { - virtual_id: 0, + virtual_id: VirtualId::new(0), provider_id: 9001, name: "Provider Series".intern(), logo: "".intern(), @@ -1268,7 +1273,7 @@ mod tests { }; let provider_parent_code = xtream_series_info.get_uuid().intern(); let xtream_provider_episode = XtreamPlaylistItem { - virtual_id: 0, + virtual_id: VirtualId::new(0), provider_id: 201, name: "Episode 1".intern(), logo: "".intern(), @@ -1304,7 +1309,8 @@ mod tests { let uuid = channel.get_uuid(); let provider_id = channel.header.get_provider_id().unwrap_or_default(); let item_type = channel.header.item_type; - channel.header.virtual_id = target_id_mapping.get_and_update_virtual_id(&uuid, provider_id, item_type, 0); + channel.header.virtual_id = + target_id_mapping.get_and_update_virtual_id(&uuid, provider_id, item_type, VirtualId::new(0)); channel.header.source_ordinal = u32::try_from(idx + 1).expect("ordinal"); } @@ -1315,7 +1321,7 @@ mod tests { target_id_mapping.persist().expect("persist"); let mut query = BPlusTreeQuery::::try_new(&mapping_path).expect("query"); - let record = query.query_zero_copy(&episode_virtual_id).expect("query ok").expect("record missing"); + let record = query.query_zero_copy(&episode_virtual_id.get()).expect("query ok").expect("record missing"); assert_eq!(record.parent_virtual_id, series_virtual_id); assert_eq!(playlist[0].channels[0].header.virtual_id, episode_virtual_id); @@ -1341,8 +1347,12 @@ mod tests { let provider_id = episode.header.get_provider_id().unwrap_or_default(); let uuid = *episode.header.get_uuid(); - let original_virtual_id = - target_id_mapping.get_and_update_virtual_id(&uuid, provider_id, episode.header.item_type, 77); + let original_virtual_id = target_id_mapping.get_and_update_virtual_id( + &uuid, + provider_id, + episode.header.item_type, + VirtualId::new(77), + ); let preserved_parent_virtual_id = target_id_mapping.get_parent_virtual_id_by_uuid(&uuid).unwrap_or_default(); episode.header.virtual_id = target_id_mapping.get_and_update_virtual_id( @@ -1354,9 +1364,9 @@ mod tests { target_id_mapping.persist().expect("persist"); let mut query = BPlusTreeQuery::::try_new(&mapping_path).expect("query"); - let record = query.query_zero_copy(&original_virtual_id).expect("query ok").expect("record missing"); + let record = query.query_zero_copy(&original_virtual_id.get()).expect("query ok").expect("record missing"); - assert_eq!(record.parent_virtual_id, 77); + assert_eq!(record.parent_virtual_id, VirtualId::new(77)); assert_eq!(episode.header.virtual_id, original_virtual_id); } diff --git a/backend/repository/src/playlist_source.rs b/backend/repository/src/playlist_source.rs index f529ed28d..42da1260f 100644 --- a/backend/repository/src/playlist_source.rs +++ b/backend/repository/src/playlist_source.rs @@ -1,5 +1,11 @@ -use crate::{stalker_generation_repository::StalkerActiveManifest, xtream_get_file_path, BPlusTreeQuery}; -use futures::future::BoxFuture; +use crate::{ + playlist_items::{ + BTreeStores, BTreeValues, ClusterFiltered, MemoryDrain, MemoryItems, MemoryItemsMut, SourceCowItems, + SourceItems, SourceItemsMut, + }, + stalker_generation_repository::StalkerActiveManifest, + xtream_get_file_path, BPlusTreeQuery, +}; use indexmap::IndexMap; use log::{error, warn}; use serde::{Deserialize, Serialize}; @@ -12,8 +18,8 @@ use shared::{ utils::Internable, }; use std::{ - borrow::Cow, collections::{HashMap, HashSet}, + future::Future, path::{Path, PathBuf}, sync::Arc, }; @@ -30,10 +36,10 @@ trait PlaylistSourceOps: Send + Sync { fn get_group_count_excluding_clusters(&mut self, skip_set: &HashSet) -> usize; fn is_empty(&mut self) -> bool; #[allow(clippy::wrong_self_convention)] - fn into_items(&mut self) -> Box + Send + '_>; - fn items_mut(&mut self) -> Box + Send + '_>; - fn items<'a>(&'a mut self) -> Box> + Send + 'a>; - fn update_playlist<'a>(&'a mut self, plg: &'a PlaylistGroup) -> BoxFuture<'a, ()>; + fn into_items(&mut self) -> SourceItems<'_>; + fn items_mut(&mut self) -> SourceItemsMut<'_>; + fn items(&mut self) -> SourceCowItems<'_>; + async fn update_playlist(&mut self, plg: &PlaylistGroup); fn get_missing_vod_info_count(&mut self) -> usize; fn get_missing_series_info_count(&mut self) -> usize; fn get_missing_vod_info_count_excluding_clusters(&mut self, skip_set: &HashSet) -> usize; @@ -42,10 +48,48 @@ trait PlaylistSourceOps: Send + Sync { fn take_groups(&mut self) -> Vec; fn clone_source(&self) -> Result; fn release_resources(&mut self, cluster: XtreamCluster); - fn obtain_resources(&mut self) -> BoxFuture<'_, Result<(), TuliproxError>>; + async fn obtain_resources(&mut self) -> Result<(), TuliproxError>; fn sort_by_provider_ordinal(&mut self); } +/// Forward a method to whichever source the kind holds. +/// +/// `PlaylistSourceOps` is private and never used as a trait object, so this +/// macro — not a vtable — is the dispatch mechanism. Writing the seven arms +/// once replaces roughly 126 hand-written match arms, and it doubles as the +/// conformance check: a variant missing a method fails to compile here. +macro_rules! dispatch { + ($self:ident.$method:ident($($arg:expr),*)) => { + match &mut $self.kind { + PlaylistSourceKind::Empty(source) => source.$method($($arg),*), + PlaylistSourceKind::XtreamDisk(source) => source.$method($($arg),*), + PlaylistSourceKind::StalkerDisk(source) => source.$method($($arg),*), + PlaylistSourceKind::M3uDisk(source) => source.$method($($arg),*), + PlaylistSourceKind::LocalLibraryDisk(source) => source.$method($($arg),*), + PlaylistSourceKind::MediaServerDisk(source) => source.$method($($arg),*), + PlaylistSourceKind::Memory(source) => source.$method($($arg),*), + } + }; +} + +/// `dispatch!` for the `async fn` members of `PlaylistSourceOps`. +/// +/// The awaited futures are the concrete ones each source's `async fn` returns, +/// so nothing is pinned to the heap on the way through. +macro_rules! dispatch_await { + ($self:ident.$method:ident($($arg:expr),*)) => { + match &mut $self.kind { + PlaylistSourceKind::Empty(source) => source.$method($($arg),*).await, + PlaylistSourceKind::XtreamDisk(source) => source.$method($($arg),*).await, + PlaylistSourceKind::StalkerDisk(source) => source.$method($($arg),*).await, + PlaylistSourceKind::M3uDisk(source) => source.$method($($arg),*).await, + PlaylistSourceKind::LocalLibraryDisk(source) => source.$method($($arg),*).await, + PlaylistSourceKind::MediaServerDisk(source) => source.$method($($arg),*).await, + PlaylistSourceKind::Memory(source) => source.$method($($arg),*).await, + } + }; +} + pub struct PlaylistSource { kind: PlaylistSourceKind, skip_set: Option>>, @@ -195,83 +239,34 @@ impl PlaylistSource { } } + /// Every item, owned, with any skipped clusters removed. + /// + /// The skip set is folded into the returned iterator rather than applied by + /// wrapping it in a second boxed `.filter(..)`, so a filtered traversal + /// allocates nothing. #[allow(clippy::wrong_self_convention)] - pub fn into_items(&mut self) -> Box + Send + '_> { - let iter = match &mut self.kind { - PlaylistSourceKind::Empty(source) => source.into_items(), - PlaylistSourceKind::XtreamDisk(source) => source.into_items(), - PlaylistSourceKind::StalkerDisk(source) => source.into_items(), - PlaylistSourceKind::M3uDisk(source) => source.into_items(), - PlaylistSourceKind::LocalLibraryDisk(source) => source.into_items(), - PlaylistSourceKind::MediaServerDisk(source) => source.into_items(), - PlaylistSourceKind::Memory(source) => source.into_items(), - }; - - if let Some(skip_set) = self.skip_set.clone() { - Box::new(iter.filter(move |item| !skip_set.contains(&item.header.xtream_cluster))) - } else { - iter - } + pub fn into_items(&mut self) -> ClusterFiltered> { + // Clone the skip set before borrowing `kind` mutably. + let skip_set = self.skip_set.clone(); + ClusterFiltered::new(dispatch!(self.into_items()), skip_set) } - pub fn items_mut(&mut self) -> Box + Send + '_> { - let iter = match &mut self.kind { - PlaylistSourceKind::Empty(source) => source.items_mut(), - PlaylistSourceKind::XtreamDisk(source) => source.items_mut(), - PlaylistSourceKind::StalkerDisk(source) => source.items_mut(), - PlaylistSourceKind::M3uDisk(source) => source.items_mut(), - PlaylistSourceKind::LocalLibraryDisk(source) => source.items_mut(), - PlaylistSourceKind::MediaServerDisk(source) => source.items_mut(), - PlaylistSourceKind::Memory(source) => source.items_mut(), - }; - - if let Some(skip_set) = self.skip_set.clone() { - Box::new(iter.filter_map( - move |item| { - if skip_set.contains(&item.header.xtream_cluster) { - None - } else { - Some(item) - } - }, - )) - } else { - iter - } + pub fn items_mut(&mut self) -> ClusterFiltered> { + let skip_set = self.skip_set.clone(); + ClusterFiltered::new(dispatch!(self.items_mut()), skip_set) } - pub fn items<'a>(&'a mut self) -> Box> + Send + 'a> { - let iter = match &mut self.kind { - PlaylistSourceKind::Empty(source) => source.items(), - PlaylistSourceKind::XtreamDisk(source) => source.items(), - PlaylistSourceKind::StalkerDisk(source) => source.items(), - PlaylistSourceKind::M3uDisk(source) => source.items(), - PlaylistSourceKind::LocalLibraryDisk(source) => source.items(), - PlaylistSourceKind::MediaServerDisk(source) => source.items(), - PlaylistSourceKind::Memory(source) => source.items(), - }; - - if let Some(skip_set) = self.skip_set.clone() { - Box::new(iter.filter(move |item| !skip_set.contains(&item.as_ref().header.xtream_cluster))) - } else { - iter - } + pub fn items(&mut self) -> ClusterFiltered> { + let skip_set = self.skip_set.clone(); + ClusterFiltered::new(dispatch!(self.items()), skip_set) } - pub fn update_playlist<'a>(&'a mut self, plg: &'a PlaylistGroup) -> BoxFuture<'a, ()> { + pub async fn update_playlist(&mut self, plg: &PlaylistGroup) { if self.skip_set.as_ref().is_some_and(|skip_set| skip_set.contains(&plg.xtream_cluster)) { - return Box::pin(async move {}); + return; } - match &mut self.kind { - PlaylistSourceKind::Empty(source) => source.update_playlist(plg), - PlaylistSourceKind::XtreamDisk(source) => source.update_playlist(plg), - PlaylistSourceKind::StalkerDisk(source) => source.update_playlist(plg), - PlaylistSourceKind::M3uDisk(source) => source.update_playlist(plg), - PlaylistSourceKind::LocalLibraryDisk(source) => source.update_playlist(plg), - PlaylistSourceKind::MediaServerDisk(source) => source.update_playlist(plg), - PlaylistSourceKind::Memory(source) => source.update_playlist(plg), - } + dispatch_await!(self.update_playlist(plg)); } /// Batched, indexed equivalent of repeatedly calling [`Self::update_playlist`] @@ -418,17 +413,7 @@ impl PlaylistSource { } } - pub fn obtain_resources(&mut self) -> BoxFuture<'_, Result<(), TuliproxError>> { - match &mut self.kind { - PlaylistSourceKind::Empty(source) => source.obtain_resources(), - PlaylistSourceKind::XtreamDisk(source) => source.obtain_resources(), - PlaylistSourceKind::StalkerDisk(source) => source.obtain_resources(), - PlaylistSourceKind::M3uDisk(source) => source.obtain_resources(), - PlaylistSourceKind::LocalLibraryDisk(source) => source.obtain_resources(), - PlaylistSourceKind::MediaServerDisk(source) => source.obtain_resources(), - PlaylistSourceKind::Memory(source) => source.obtain_resources(), - } - } + pub async fn obtain_resources(&mut self) -> Result<(), TuliproxError> { dispatch_await!(self.obtain_resources()) } pub fn sort_by_provider_ordinal(&mut self) { match &mut self.kind { @@ -455,10 +440,6 @@ fn filter_group(skip_set: &HashSet, mut group: PlaylistGroup) -> } } -fn cluster_from_item_type(item_type: PlaylistItemType) -> XtreamCluster { - XtreamCluster::try_from(item_type).unwrap_or(XtreamCluster::Live) -} - fn clone_xtream_query( label: &str, source: Option<&XtreamQueryHandle>, @@ -482,12 +463,11 @@ impl PlaylistSourceOps for EmptyPlaylistSource { fn get_channel_count_excluding_clusters(&mut self, _skip_set: &HashSet) -> usize { 0 } fn get_group_count_excluding_clusters(&mut self, _skip_set: &HashSet) -> usize { 0 } fn is_empty(&mut self) -> bool { true } - fn into_items(&mut self) -> Box + Send + '_> { Box::new(std::iter::empty()) } - fn items_mut(&mut self) -> Box + Send + '_> { Box::new(std::iter::empty()) } - fn items<'a>(&'a mut self) -> Box> + Send + 'a> { - Box::new(std::iter::empty()) + fn into_items(&mut self) -> SourceItems<'_> { SourceItems::Empty } + fn items_mut(&mut self) -> SourceItemsMut<'_> { SourceItemsMut::Empty } + fn items(&mut self) -> SourceCowItems<'_> { SourceCowItems::Owned(SourceItems::Empty) } + async fn update_playlist(&mut self, _plg: &PlaylistGroup) { /* noop */ } - fn update_playlist<'a>(&'a mut self, _plg: &'a PlaylistGroup) -> BoxFuture<'a, ()> { Box::pin(async move {}) } fn get_missing_vod_info_count(&mut self) -> usize { 0 } fn get_missing_series_info_count(&mut self) -> usize { 0 } fn get_missing_vod_info_count_excluding_clusters(&mut self, _skip_set: &HashSet) -> usize { 0 } @@ -500,7 +480,7 @@ impl PlaylistSourceOps for EmptyPlaylistSource { } fn release_resources(&mut self, _cluster: XtreamCluster) { /* noop */ } - fn obtain_resources(&mut self) -> BoxFuture<'_, Result<(), TuliproxError>> { Box::pin(async move { Ok(()) }) } + fn obtain_resources(&mut self) -> impl Future> { std::future::ready(Ok(())) } fn sort_by_provider_ordinal(&mut self) { /* noop */ } } @@ -550,6 +530,20 @@ impl XtreamDiskPlaylistSource { } } +impl XtreamDiskPlaylistSource { + /// The three cluster stores, traversed live -> vod -> series. + /// + /// Borrows three distinct fields out of one `&mut self`, which the borrow + /// checker splits, so all three readers can be live at once. + fn stores(&mut self) -> BTreeStores<'_, u32, XtreamPlaylistItem, 3> { + BTreeStores::new([ + BTreeValues::new(self.live.as_mut().map(|(query, _)| query.iter())), + BTreeValues::new(self.vod.as_mut().map(|(query, _)| query.iter())), + BTreeValues::new(self.series.as_mut().map(|(query, _)| query.iter())), + ]) + } +} + impl PlaylistSourceOps for XtreamDiskPlaylistSource { fn is_memory(&self) -> bool { false } @@ -627,76 +621,34 @@ impl PlaylistSourceOps for XtreamDiskPlaylistSource { && self.series.as_mut().is_none_or(|(q, _)| q.is_empty().unwrap_or(true)) } - fn into_items(&mut self) -> Box + Send + '_> { - let live = self - .live - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(|(_, item)| PlaylistItem::from(&item)); - let vod = self - .vod - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(|(_, item)| PlaylistItem::from(&item)); - let series = self - .series - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(|(_, item)| PlaylistItem::from(&item)); - Box::new(live.chain(vod).chain(series)) - } + fn into_items(&mut self) -> SourceItems<'_> { SourceItems::Xtream(self.stores()) } - fn items<'a>(&'a mut self) -> Box> + Send + 'a> { - let live = self - .live - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(|(_, item)| Cow::Owned(PlaylistItem::from(&item))); - let vod = self - .vod - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(|(_, item)| Cow::Owned(PlaylistItem::from(&item))); - let series = self - .series - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(|(_, item)| Cow::Owned(PlaylistItem::from(&item))); - Box::new(live.chain(vod).chain(series)) - } + fn items(&mut self) -> SourceCowItems<'_> { SourceCowItems::Owned(self.into_items()) } - fn items_mut(&mut self) -> Box + Send + '_> { + fn items_mut(&mut self) -> SourceItemsMut<'_> { warn!( "Disk-based playlist sources are read-only. Use clone_source() and convert to memory for mutable access." ); - Box::new(std::iter::empty()) + SourceItemsMut::Empty } - fn update_playlist<'a>(&'a mut self, _plg: &'a PlaylistGroup) -> BoxFuture<'a, ()> { - Box::pin(async move { - warn!("update_playlist should not be called for Xtream Disk playlist"); - // // Drop read guards before write lock - // self.live = None; - // self.vod = None; - // self.series = None; - // - // let xtream_path = xtream_get_file_path(&self.storage_path, plg.xtream_cluster); - // { - // let _lock = self.app_config.file_locks.write_lock(&xtream_path).await; - // if let Ok(mut tree) = BPlusTreeUpdate::::try_new(&xtream_path) { - // let xtream_items: Vec = plg.channels.iter().map(XtreamPlaylistItem::from).collect(); - // let batch: Vec<(&u32, &XtreamPlaylistItem)> = xtream_items.iter().map(|item| (&item.virtual_id, item)).collect(); - // let _ = tree.upsert_batch(&batch); - // } - // } - // self.reload().await; - }) + async fn update_playlist(&mut self, _plg: &PlaylistGroup) { + warn!("update_playlist should not be called for Xtream Disk playlist"); + // // Drop read guards before write lock + // self.live = None; + // self.vod = None; + // self.series = None; + // + // let xtream_path = xtream_get_file_path(&self.storage_path, plg.xtream_cluster); + // { + // let _lock = self.app_config.file_locks.write_lock(&xtream_path).await; + // if let Ok(mut tree) = BPlusTreeUpdate::::try_new(&xtream_path) { + // let xtream_items: Vec = plg.channels.iter().map(XtreamPlaylistItem::from).collect(); + // let batch: Vec<(&u32, &XtreamPlaylistItem)> = xtream_items.iter().map(|item| (&item.virtual_id, item)).collect(); + // let _ = tree.upsert_batch(&batch); + // } + // } + // self.reload().await; } fn get_missing_vod_info_count(&mut self) -> usize { @@ -746,25 +698,12 @@ impl PlaylistSourceOps for XtreamDiskPlaylistSource { fn take_groups(&mut self) -> Vec { // Build groups on-the-fly using disk iterator (streams one leaf at a time) let mut groups_map: IndexMap<(XtreamCluster, u32), PlaylistGroup> = IndexMap::new(); - let mut iters: Vec<(XtreamCluster, Box + Send>)> = vec![]; - if let Some((q, _)) = self.live.as_mut() { - iters.push(( - XtreamCluster::Live, - Box::new(q.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| item)), - )); - } - if let Some((q, _)) = self.vod.as_mut() { - iters.push(( - XtreamCluster::Video, - Box::new(q.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| item)), - )); - } - if let Some((q, _)) = self.series.as_mut() { - iters.push(( - XtreamCluster::Series, - Box::new(q.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| item)), - )); - } + // Every reader has the same concrete type, so the vec needs no erasure. + let iters: Vec<(XtreamCluster, BTreeValues<'_, u32, XtreamPlaylistItem>)> = vec![ + (XtreamCluster::Live, BTreeValues::new(self.live.as_mut().map(|(query, _)| query.iter()))), + (XtreamCluster::Video, BTreeValues::new(self.vod.as_mut().map(|(query, _)| query.iter()))), + (XtreamCluster::Series, BTreeValues::new(self.series.as_mut().map(|(query, _)| query.iter()))), + ]; for (cluster, iter) in iters { for item in iter { @@ -816,9 +755,7 @@ impl PlaylistSourceOps for XtreamDiskPlaylistSource { } } - fn obtain_resources(&mut self) -> BoxFuture<'_, Result<(), TuliproxError>> { - Box::pin(async move { self.reload().await }) - } + async fn obtain_resources(&mut self) -> Result<(), TuliproxError> { self.reload().await } fn sort_by_provider_ordinal(&mut self) { warn!("Sorting by provider ordinal is not supported for disk based playlists"); } @@ -919,6 +856,18 @@ impl StalkerDiskPlaylistSource { } } +impl StalkerDiskPlaylistSource { + /// The four stores, traversed live -> vod -> series roots -> series. + fn stores(&mut self) -> BTreeStores<'_, u32, StalkerPlaylistItem, 4> { + BTreeStores::new([ + BTreeValues::new(self.live.as_mut().map(|(query, _)| query.iter())), + BTreeValues::new(self.vod.as_mut().map(|(query, _)| query.iter())), + BTreeValues::new(self.series_roots.as_mut().map(|(query, _)| query.iter())), + BTreeValues::new(self.series.as_mut().map(|(query, _)| query.iter())), + ]) + } +} + impl PlaylistSourceOps for StalkerDiskPlaylistSource { fn is_memory(&self) -> bool { false } @@ -983,81 +932,22 @@ impl PlaylistSourceOps for StalkerDiskPlaylistSource { self.live_count == 0 && self.vod_count == 0 && self.series_roots_count == 0 && self.series_count == 0 } - fn into_items(&mut self) -> Box + Send + '_> { - let input_live = Arc::clone(&self.input_name); - let input_vod = Arc::clone(&self.input_name); - let input_roots = Arc::clone(&self.input_name); - let input_series = Arc::clone(&self.input_name); - let live = self - .live - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(move |(_, item)| PlaylistItem::from_stalker(&item, &input_live)); - let vod = self - .vod - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(move |(_, item)| PlaylistItem::from_stalker(&item, &input_vod)); - let series_roots = self - .series_roots - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(move |(_, item)| PlaylistItem::from_stalker(&item, &input_roots)); - let series = self - .series - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(move |(_, item)| PlaylistItem::from_stalker(&item, &input_series)); - Box::new(live.chain(vod).chain(series_roots).chain(series)) + fn into_items(&mut self) -> SourceItems<'_> { + let input_name = Arc::clone(&self.input_name); + SourceItems::Stalker { stores: self.stores(), input_name } } - fn items<'a>(&'a mut self) -> Box> + Send + 'a> { - let input_live = Arc::clone(&self.input_name); - let input_vod = Arc::clone(&self.input_name); - let input_roots = Arc::clone(&self.input_name); - let input_series = Arc::clone(&self.input_name); - let live = self - .live - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(move |(_, item)| Cow::Owned(PlaylistItem::from_stalker(&item, &input_live))); - let vod = self - .vod - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(move |(_, item)| Cow::Owned(PlaylistItem::from_stalker(&item, &input_vod))); - let series_roots = self - .series_roots - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(move |(_, item)| Cow::Owned(PlaylistItem::from_stalker(&item, &input_roots))); - let series = self - .series - .as_mut() - .into_iter() - .flat_map(|(q, _)| q.iter().filter_map(log_and_skip_btree_error)) - .map(move |(_, item)| Cow::Owned(PlaylistItem::from_stalker(&item, &input_series))); - Box::new(live.chain(vod).chain(series_roots).chain(series)) - } + fn items(&mut self) -> SourceCowItems<'_> { SourceCowItems::Owned(self.into_items()) } - fn items_mut(&mut self) -> Box + Send + '_> { + fn items_mut(&mut self) -> SourceItemsMut<'_> { warn!( "Disk-based playlist sources are read-only. Use clone_source() and convert to memory for mutable access." ); - Box::new(std::iter::empty()) + SourceItemsMut::Empty } - fn update_playlist<'a>(&'a mut self, _plg: &'a PlaylistGroup) -> BoxFuture<'a, ()> { - Box::pin(async move { - warn!("update_playlist should not be called for Stalker Disk playlist"); - }) + async fn update_playlist(&mut self, _plg: &PlaylistGroup) { + warn!("update_playlist should not be called for Stalker Disk playlist"); } fn get_missing_vod_info_count(&mut self) -> usize { @@ -1086,31 +976,13 @@ impl PlaylistSourceOps for StalkerDiskPlaylistSource { fn take_groups(&mut self) -> Vec { let input_name = Arc::clone(&self.input_name); let mut groups_map: IndexMap<(XtreamCluster, u32), PlaylistGroup> = IndexMap::new(); - let mut iters: Vec<(XtreamCluster, Box + Send>)> = vec![]; - if let Some((q, _)) = self.live.as_mut() { - iters.push(( - XtreamCluster::Live, - Box::new(q.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| item)), - )); - } - if let Some((q, _)) = self.vod.as_mut() { - iters.push(( - XtreamCluster::Video, - Box::new(q.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| item)), - )); - } - if let Some((q, _)) = self.series_roots.as_mut() { - iters.push(( - XtreamCluster::Series, - Box::new(q.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| item)), - )); - } - if let Some((q, _)) = self.series.as_mut() { - iters.push(( - XtreamCluster::Series, - Box::new(q.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| item)), - )); - } + // Every reader has the same concrete type, so the vec needs no erasure. + let iters: Vec<(XtreamCluster, BTreeValues<'_, u32, StalkerPlaylistItem>)> = vec![ + (XtreamCluster::Live, BTreeValues::new(self.live.as_mut().map(|(query, _)| query.iter()))), + (XtreamCluster::Video, BTreeValues::new(self.vod.as_mut().map(|(query, _)| query.iter()))), + (XtreamCluster::Series, BTreeValues::new(self.series_roots.as_mut().map(|(query, _)| query.iter()))), + (XtreamCluster::Series, BTreeValues::new(self.series.as_mut().map(|(query, _)| query.iter()))), + ]; for (cluster, iter) in iters { for item in iter { @@ -1173,9 +1045,7 @@ impl PlaylistSourceOps for StalkerDiskPlaylistSource { } } - fn obtain_resources(&mut self) -> BoxFuture<'_, Result<(), TuliproxError>> { - Box::pin(async move { self.reload().await }) - } + async fn obtain_resources(&mut self) -> Result<(), TuliproxError> { self.reload().await } fn sort_by_provider_ordinal(&mut self) { warn!("Sorting by provider ordinal is not supported for disk based playlists"); } @@ -1195,7 +1065,7 @@ fn clone_stalker_query( } macro_rules! impl_single_file_disk_source { - ($name:ident, $key_type:ty, $entry_type:ty) => { + ($name:ident, $key_type:ty, $entry_type:ty, $items_variant:ident) => { paste::paste! { pub struct [<$name DiskPlaylistSource>] { app_config: Arc, @@ -1249,7 +1119,7 @@ macro_rules! impl_single_file_disk_source { query .iter() .filter_map(log_and_skip_btree_error) - .filter(|(_, item)| !skip_set.contains(&cluster_from_item_type(item.item_type))) + .filter(|(_, item)| !skip_set.contains(&item.item_type.cluster())) .count() }) } @@ -1258,7 +1128,7 @@ macro_rules! impl_single_file_disk_source { let mut groups = HashSet::<(XtreamCluster, Arc)>::new(); if let Some(query) = self.playlist.as_mut() { for (_, item) in query.iter().filter_map(log_and_skip_btree_error) { - let cluster = cluster_from_item_type(item.item_type); + let cluster = item.item_type.cluster(); if !skip_set.contains(&cluster) { groups.insert((cluster, Arc::clone(&item.group))); } @@ -1269,34 +1139,22 @@ macro_rules! impl_single_file_disk_source { fn is_empty(&mut self) -> bool { self.playlist.as_mut().map_or(true, |t| t.is_empty().unwrap_or(true)) } - fn into_items(&mut self) -> Box + Send + '_> { - if let Some(q) = self.playlist.as_mut() { - Box::new(q.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| PlaylistItem::from(&item))) - } else { - Box::new(std::iter::empty()) - } + fn into_items(&mut self) -> SourceItems<'_> { + SourceItems::$items_variant(BTreeStores::new([ + BTreeValues::new(self.playlist.as_mut().map(BPlusTreeQuery::iter)), + ])) } - fn items<'a>(&'a mut self) -> Box> + Send + 'a> { - if let Some(pl) = self.playlist.as_mut() { - let iter = pl.iter().filter_map(log_and_skip_btree_error).map(|(_, item)| Cow::Owned(PlaylistItem::from(&item))); - Box::new(iter) - } else { - Box::new(std::iter::empty()) - } - } + fn items(&mut self) -> SourceCowItems<'_> { SourceCowItems::Owned(self.into_items()) } - fn items_mut(&mut self) -> Box + Send + '_> { + fn items_mut(&mut self) -> SourceItemsMut<'_> { warn!("Disk-based playlist sources are read-only. Use clone_source() and convert to memory for mutable access."); - Box::new(std::iter::empty()) + SourceItemsMut::Empty } - fn update_playlist<'a>(&'a mut self, _plg: &'a PlaylistGroup) -> BoxFuture<'a, ()> { - Box::pin(async move { - warn!("update_playlist should not be called for Disk playlist"); - }) + async fn update_playlist(&mut self, _plg: &PlaylistGroup) { + warn!("update_playlist should not be called for Disk playlist"); } - fn get_missing_vod_info_count(&mut self) -> usize { 0 } fn get_missing_series_info_count(&mut self) -> usize { 0 } fn get_missing_vod_info_count_excluding_clusters(&mut self, _skip_set: &HashSet) -> usize { 0 } @@ -1309,7 +1167,7 @@ macro_rules! impl_single_file_disk_source { if let Some(q) = self.playlist.as_mut() { let mut groups_map: IndexMap<(XtreamCluster, Arc), PlaylistGroup> = IndexMap::new(); for (_, item) in q.iter().filter_map(log_and_skip_btree_error) { - let cluster = XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live); + let cluster = item.item_type.cluster(); let normalized_group = shared::utils::deunicode_string(&item.group).to_lowercase().intern(); let key = (cluster, normalized_group); groups_map.entry(key) @@ -1364,9 +1222,7 @@ macro_rules! impl_single_file_disk_source { self.playlist = None; } - fn obtain_resources(&mut self) -> BoxFuture<'_, Result<(), TuliproxError>> { - Box::pin(async move { self.reload().await }) - } + async fn obtain_resources(&mut self) -> Result<(), TuliproxError> { self.reload().await } fn sort_by_provider_ordinal(&mut self) { warn!("Sorting by provider ordinal is not supported for disk based playlists"); @@ -1376,10 +1232,10 @@ macro_rules! impl_single_file_disk_source { }; } -impl_single_file_disk_source!(M3u, Arc, M3uPlaylistItem); +impl_single_file_disk_source!(M3u, Arc, M3uPlaylistItem, M3u); -impl_single_file_disk_source!(LocalLibrary, UUIDType, XtreamPlaylistItem); -impl_single_file_disk_source!(MediaServer, UUIDType, XtreamPlaylistItem); +impl_single_file_disk_source!(LocalLibrary, UUIDType, XtreamPlaylistItem, SingleXtream); +impl_single_file_disk_source!(MediaServer, UUIDType, XtreamPlaylistItem, SingleXtream); pub struct MemoryPlaylistSource { playlist: Arc>, @@ -1454,39 +1310,38 @@ impl PlaylistSourceOps for MemoryPlaylistSource { .count() } fn is_empty(&mut self) -> bool { self.playlist.is_empty() } - fn into_items(&mut self) -> Box + Send + '_> { - let playlist = Arc::make_mut(&mut self.playlist); - Box::new(playlist.iter_mut().flat_map(|group| group.channels.drain(..))) - } - fn items_mut(&mut self) -> Box + Send + '_> { - let playlist = Arc::make_mut(&mut self.playlist); - Box::new(playlist.iter_mut().flat_map(|group| group.channels.iter_mut())) + fn into_items(&mut self) -> SourceItems<'_> { + SourceItems::Memory(MemoryDrain::new(Arc::make_mut(&mut self.playlist).as_mut_slice())) } - fn items<'a>(&'a mut self) -> Box> + Send + 'a> { - Box::new(self.playlist.iter().flat_map(|group| group.channels.iter().map(Cow::Borrowed))) + fn items_mut(&mut self) -> SourceItemsMut<'_> { + SourceItemsMut::Memory(MemoryItemsMut::new(Arc::make_mut(&mut self.playlist).as_mut_slice())) } - fn update_playlist<'a>(&'a mut self, plg: &'a PlaylistGroup) -> BoxFuture<'a, ()> { - Box::pin(async move { - let playlist = Arc::make_mut(&mut self.playlist); - let incoming_title = shared::utils::deunicode_string(&plg.title).to_lowercase(); - for grp in playlist.iter_mut() { - let existing_title = shared::utils::deunicode_string(&grp.title).to_lowercase(); - if grp.xtream_cluster == plg.xtream_cluster && existing_title == incoming_title { - grp.channels.extend(plg.channels.iter().cloned()); - return; - } + /// Non-destructive, unlike `into_items`: hands out borrows rather than + /// draining the groups, so this is the one source that yields + /// `Cow::Borrowed`. + fn items(&mut self) -> SourceCowItems<'_> { SourceCowItems::Borrowed(MemoryItems::new(self.playlist.as_slice())) } + + async fn update_playlist(&mut self, plg: &PlaylistGroup) { + let playlist = Arc::make_mut(&mut self.playlist); + let incoming_title = shared::utils::deunicode_string(&plg.title).to_lowercase(); + for grp in playlist.iter_mut() { + let existing_title = shared::utils::deunicode_string(&grp.title).to_lowercase(); + if grp.xtream_cluster == plg.xtream_cluster && existing_title == incoming_title { + grp.channels.extend(plg.channels.iter().cloned()); + return; } - for grp in playlist.iter_mut() { - if grp.xtream_cluster == plg.xtream_cluster && grp.id == plg.id { - grp.channels.extend(plg.channels.iter().cloned()); - return; - } + } + for grp in playlist.iter_mut() { + if grp.xtream_cluster == plg.xtream_cluster && grp.id == plg.id { + grp.channels.extend(plg.channels.iter().cloned()); + return; } - playlist.push(plg.clone()); - }) + } + playlist.push(plg.clone()); } + fn get_missing_vod_info_count(&mut self) -> usize { self.playlist .iter() @@ -1536,7 +1391,7 @@ impl PlaylistSourceOps for MemoryPlaylistSource { } fn release_resources(&mut self, _cluster: XtreamCluster) { /* noop */ } - fn obtain_resources(&mut self) -> BoxFuture<'_, Result<(), TuliproxError>> { Box::pin(async move { Ok(()) }) } + fn obtain_resources(&mut self) -> impl Future> { std::future::ready(Ok(())) } fn sort_by_provider_ordinal(&mut self) { let playlist = Arc::make_mut(&mut self.playlist); @@ -1591,7 +1446,6 @@ mod tests { use crate::BPlusTreeQuery; use arc_swap::{ArcSwap, ArcSwapOption}; use shared::{ - error::TuliproxError, model::{ConfigPaths, PlaylistItemHeader, PlaylistItemType, XtreamPlaylistItem}, utils::Internable, }; @@ -1716,12 +1570,12 @@ mod tests { let result = source.clone_source(); - assert!(matches!( - result, - Err(TuliproxError::RepositoryPlaylist(message)) - if message.contains("Failed to clone live disk playlist query") - && message.contains("mapped query without a path cannot be cloned") - )); + let Err(error) = result else { + panic!("cloning a mapped query without a path must fail"); + }; + assert_eq!(error.kind(), shared::error::ErrorKind::RepositoryPlaylist); + assert!(error.message().contains("Failed to clone live disk playlist query")); + assert!(error.message().contains("mapped query without a path cannot be cloned")); } fn make_cluster_item(title: &str, cluster: XtreamCluster) -> PlaylistItem { diff --git a/backend/repository/src/playlist_stream.rs b/backend/repository/src/playlist_stream.rs index 108f49854..a9fb108c2 100644 --- a/backend/repository/src/playlist_stream.rs +++ b/backend/repository/src/playlist_stream.rs @@ -73,6 +73,17 @@ impl LockedReceiverStream { /// is semantically empty from the start, for example /// `XtreamPlaylistIterator::empty()`. pub fn new_empty(rx: mpsc::Receiver) -> Self { Self { rx: ReceiverStream::new(rx), _guard: None } } + + /// Creates a stream that holds `guard` for its whole lifetime when `Some`. + /// + /// `None` is only correct when the producer's own guard already covers every + /// read it performs, so the consumer never reads through this stream after + /// that guard is released. Backends differ on this (see + /// `PlaylistBackend::HOLD_ITER_LOCK`), which is why the choice is a + /// parameter rather than baked in. + pub fn with_optional_guard(rx: mpsc::Receiver, guard: Option) -> Self { + Self { rx: ReceiverStream::new(rx), _guard: guard } + } } impl Stream for LockedReceiverStream { diff --git a/backend/repository/src/stalker_repository.rs b/backend/repository/src/stalker_repository.rs index a690f638c..e16c1f1df 100644 --- a/backend/repository/src/stalker_repository.rs +++ b/backend/repository/src/stalker_repository.rs @@ -17,7 +17,6 @@ use crate::{ storage::ensure_input_storage_path, storage_const, }; -use futures::Stream; use log::warn; use serde::{de::DeserializeOwned, Serialize}; use shared::{ @@ -374,7 +373,7 @@ pub async fn iter_stalker_items( app_config: &Arc, storage_path: &Path, kind: StalkerStreamKind, -) -> Result + Send + Unpin>>, TuliproxError> { +) -> Result>, TuliproxError> { let file_path = get_stalker_file_path(storage_path, kind); iter_stalker_file(app_config, file_path).await } @@ -382,14 +381,14 @@ pub async fn iter_stalker_items( pub async fn iter_stalker_series_roots( app_config: &Arc, storage_path: &Path, -) -> Result + Send + Unpin>>, TuliproxError> { +) -> Result>, TuliproxError> { iter_stalker_file(app_config, get_stalker_series_root_file_path(storage_path)).await } async fn iter_stalker_file( app_config: &Arc, file_path: PathBuf, -) -> Result + Send + Unpin>>, TuliproxError> { +) -> Result>, TuliproxError> { use tokio::sync::mpsc; if !stalker_path_exists(&file_path).await { @@ -423,8 +422,7 @@ async fn iter_stalker_file( warn!("Stalker iterator aborted: {err}"); } }); - let stream: Box + Send + Unpin> = Box::new(ReceiverStream::new(rx)); - Ok(Some(stream)) + Ok(Some(ReceiverStream::new(rx))) } /// Drop all persisted items for a given input. Used when the user changes the diff --git a/backend/repository/src/startup_migration.rs b/backend/repository/src/startup_migration.rs index bc9fb742f..89be17d7d 100644 --- a/backend/repository/src/startup_migration.rs +++ b/backend/repository/src/startup_migration.rs @@ -14,7 +14,6 @@ use crate::{ storage_const, target_id_mapping::VirtualIdRecord, }; -use fs2::FileExt as _; use log::{info, trace, warn}; use shared::model::{ ClusterFlags, ConfigPaths, EpgChannel, M3uPlaylistItem, NetworkAccessDto, ProxyType, ProxyUserStatus, UUIDType, @@ -330,12 +329,12 @@ impl BPlusTreeStartupMigrator { } let mut file = OpenOptions::new().read(true).write(true).open(path)?; - if let Err(err) = file.try_lock_exclusive() { - if err.kind() == io::ErrorKind::WouldBlock { + if let Err(err) = file.try_lock() { + if matches!(err, std::fs::TryLockError::WouldBlock) { warn!("Skipping B+Tree migration for locked file {}: {}", path.display(), err); return Ok(FileMigrationOutcome::Locked); } - return Err(err); + return Err(err.into()); } // Re-read header after lock to guard against concurrent updates between @@ -1187,13 +1186,13 @@ mod tests { let path = write_legacy_geoip(temp.path(), 2)?; let before = std::fs::read(&path)?; let file = OpenOptions::new().read(true).write(true).open(&path)?; - file.lock_exclusive()?; + file.lock()?; let stats = migrate_bplustree_databases(&[temp.path().to_path_buf()])?; assert_eq!(stats.bplustree_files, 1); assert_eq!(stats.migrated_files, 0); assert_eq!(std::fs::read(&path)?, before); - fs2::FileExt::unlock(&file)?; + file.unlock()?; Ok(()) } diff --git a/backend/repository/src/storage.rs b/backend/repository/src/storage.rs index 53f3726ad..904c8937d 100644 --- a/backend/repository/src/storage.rs +++ b/backend/repository/src/storage.rs @@ -1,9 +1,8 @@ use crate::storage_const; -use fs2::FileExt; use shared::{concat_string, error::TuliproxError}; use std::{ collections::HashMap, - fs::{File, OpenOptions}, + fs::{File, OpenOptions, TryLockError}, io, path::{Path, PathBuf}, time::{Duration, SystemTime}, @@ -115,7 +114,7 @@ impl XtreamRefreshGenerationGuard { pub fn acquire(storage_path: &Path, generation: Uuid) -> io::Result { let path = refresh_generation_guard_path(storage_path, generation); let file = OpenOptions::new().read(true).write(true).create_new(true).open(&path)?; - if let Err(error) = file.lock_exclusive() { + if let Err(error) = file.lock() { drop(file); if let Err(remove_error) = std::fs::remove_file(&path) { if remove_error.kind() != io::ErrorKind::NotFound { @@ -136,7 +135,7 @@ impl XtreamRefreshGenerationGuard { impl Drop for XtreamRefreshGenerationGuard { fn drop(&mut self) { if let Some(file) = self.file.take() { - if let Err(error) = FileExt::unlock(&file) { + if let Err(error) = file.unlock() { log::warn!( "Failed to unlock Xtream refresh generation guard {}: {error}; the OS will release it on close", self.path.display() @@ -176,10 +175,10 @@ fn try_claim_refresh_generation(storage_path: &Path, generation: Uuid) -> io::Re } Err(error) => return Err(error), }; - return match file.try_lock_exclusive() { + return match file.try_lock() { Ok(()) => Ok(RefreshGenerationClaim::Acquired(XtreamRefreshGenerationGuard::from_locked_file(path, file))), - Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(RefreshGenerationClaim::Active), - Err(error) => Err(error), + Err(TryLockError::WouldBlock) => Ok(RefreshGenerationClaim::Active), + Err(TryLockError::Error(error)) => Err(error), }; } Err(io::Error::new( @@ -353,7 +352,6 @@ mod tests { cleanup_orphaned_staging_artifacts, cleanup_orphaned_staging_artifacts_with_hook, is_orphan_staging_name, refresh_generation_guard_path, XtreamRefreshGenerationGuard, }; - use fs2::FileExt; use std::{ env, fs, io, path::{Path, PathBuf}, @@ -606,9 +604,8 @@ mod tests { ); let competing_file = fs::OpenOptions::new().read(true).write(true).open(&guard_path).expect("open claimed generation guard"); - let contention = - competing_file.try_lock_exclusive().expect_err("cleanup must retain generation lock through deletion"); - assert_eq!(contention.kind(), io::ErrorKind::WouldBlock); + let contention = competing_file.try_lock().expect_err("cleanup must retain generation lock through deletion"); + assert!(matches!(contention, fs::TryLockError::WouldBlock)); release_sender.send(()).expect("release cleanup"); cleanup.join().expect("cleanup thread should complete"); diff --git a/backend/repository/src/strm_repository.rs b/backend/repository/src/strm_repository.rs index 3f82de746..763eb9f63 100644 --- a/backend/repository/src/strm_repository.rs +++ b/backend/repository/src/strm_repository.rs @@ -5,10 +5,7 @@ use log::{error, trace}; use serde::Serialize; use shared::{ error::TuliproxError, - model::{ - MediaQuality, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, StrmExportStyle, UUIDType, - XtreamCluster, - }, + model::{MediaQuality, PlaylistGroup, PlaylistItem, PlaylistItemType, StreamProperties, StrmExportStyle, UUIDType}, utils::{ arc_str_option_serde, arc_str_serde, clean_playlist_title, hash_bytes, hash_string_as_hex, is_blank_optional_arc_str, sanitize_sensitive_info, truncate_string, ExportStyleConfig, CONSTANTS, @@ -278,7 +275,7 @@ fn extract_item_info(pli: &mut PlaylistItem, use_metadata: bool) -> StrmItemInfo title, item_type, provider_id, - virtual_id, + virtual_id: virtual_id.get(), input_name, url, series_name, @@ -1132,12 +1129,7 @@ fn build_provider_resolve_url( target_id: u16, str_item_info: &StrmItemInfo, ) -> Result { - let cluster = XtreamCluster::try_from(str_item_info.item_type).map_err(|err| { - format!( - "Failed to determine STRM provider resolve cluster for item '{}': {err}", - sanitize_sensitive_info(&str_item_info.title) - ) - })?; + let cluster = str_item_info.item_type.cluster(); let token = encode_provider_resolve_playlist_item_token( secret, &ProviderResolvePlaylistItemToken { @@ -1284,7 +1276,7 @@ mod tests { use shared::model::{ ConfigPaths, ConfigProviderDto, InputType, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, ProviderUrlSelectionPolicy, ProxyType, SeriesStreamProperties, StreamProperties, StrmExportStyle, - VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, + VideoStreamDetailProperties, VideoStreamProperties, VirtualId, XtreamCluster, }; use std::{collections::HashMap, sync::Arc}; use tuliprox_core::{ @@ -1518,7 +1510,7 @@ mod tests { title: Arc::from(title), name: Arc::from(title), group: Arc::from(group), - virtual_id, + virtual_id: VirtualId::new(virtual_id), item_type: PlaylistItemType::Video, additional_properties: Some(StreamProperties::Video(Box::new(props))), ..Default::default() diff --git a/backend/repository/src/target_id_mapping.rs b/backend/repository/src/target_id_mapping.rs index 4ad0d81f9..5d709d90e 100644 --- a/backend/repository/src/target_id_mapping.rs +++ b/backend/repository/src/target_id_mapping.rs @@ -4,7 +4,7 @@ use log::error; use serde::{Deserialize, Serialize}; use shared::{ error::TuliproxError, - model::{PlaylistItemType, UUIDType}, + model::{PlaylistItemType, UUIDType, VirtualId}, }; use std::{ cmp::max, @@ -18,20 +18,20 @@ const EXPIRATION_DURATION: i64 = 86400; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct VirtualIdRecord { - pub virtual_id: u32, + pub virtual_id: VirtualId, pub provider_id: u32, pub uuid: UUIDType, pub item_type: PlaylistItemType, - pub parent_virtual_id: u32, // only for series to hold series info id. + pub parent_virtual_id: VirtualId, // only for series to hold series info id. pub last_updated: i64, } impl VirtualIdRecord { pub fn new( provider_id: u32, - virtual_id: u32, + virtual_id: VirtualId, item_type: PlaylistItemType, - parent_virtual_id: u32, + parent_virtual_id: VirtualId, uuid: UUIDType, ) -> Self { let last_updated = Local::now().timestamp(); @@ -63,13 +63,13 @@ where pub struct TargetIdMapping { virtual_id_counter: u32, // Disk-based handles - disk_by_virtual_id: BPlusTreeUpdate, + disk_by_virtual_id: BPlusTreeUpdate, disk_by_uuid: BPlusTreeUpdate, // In-memory working sets (Always populated) - mem_by_uuid: HashMap, - mem_by_virtual_id: HashMap, + mem_by_uuid: HashMap, + mem_by_virtual_id: HashMap, // Batch buffers for efficient disk writes - pending_virtual_id_upserts: HashMap, + pending_virtual_id_upserts: HashMap, pending_uuid_upserts: HashMap, path: PathBuf, } @@ -86,7 +86,7 @@ impl TargetIdMapping { })?; // Open disk-based update handles - let mut disk_by_virtual_id = match BPlusTreeUpdate::::try_new_with_backoff(path) { + let mut disk_by_virtual_id = match BPlusTreeUpdate::::try_new_with_backoff(path) { Ok(tree) => tree, Err(e) => { error!("Failed to open primary tree at {}: {e}", path.display()); @@ -148,8 +148,8 @@ impl TargetIdMapping { uuid: &UUIDType, provider_id: u32, item_type: PlaylistItemType, - parent_virtual_id: u32, - ) -> u32 { + parent_virtual_id: VirtualId, + ) -> VirtualId { // Lookup existing virtual_id in memory let existing_virtual_id = self.mem_by_uuid.get(uuid).copied(); @@ -158,17 +158,18 @@ impl TargetIdMapping { // New entry: allocate new virtual_id self.virtual_id_counter += 1; let virtual_id = self.virtual_id_counter; - let record = VirtualIdRecord::new(provider_id, virtual_id, item_type, parent_virtual_id, *uuid); + let record = + VirtualIdRecord::new(provider_id, VirtualId::new(virtual_id), item_type, parent_virtual_id, *uuid); // Buffer for disk write - self.pending_virtual_id_upserts.insert(virtual_id, record.clone()); + self.pending_virtual_id_upserts.insert(VirtualId::new(virtual_id), record.clone()); self.pending_uuid_upserts.insert(*uuid, virtual_id); // Update memory maps - self.mem_by_uuid.insert(*uuid, virtual_id); - self.mem_by_virtual_id.insert(virtual_id, record); + self.mem_by_uuid.insert(*uuid, VirtualId::new(virtual_id)); + self.mem_by_virtual_id.insert(VirtualId::new(virtual_id), record); - virtual_id + VirtualId::new(virtual_id) } Some(virtual_id) => { // Existing entry: check if update needed @@ -198,7 +199,7 @@ impl TargetIdMapping { if self.has_pending_changes() { // Flush pending virtual_id upserts if !self.pending_virtual_id_upserts.is_empty() { - let mut batch: Vec<(&u32, &VirtualIdRecord)> = self.pending_virtual_id_upserts.iter().collect(); + let mut batch: Vec<(&VirtualId, &VirtualIdRecord)> = self.pending_virtual_id_upserts.iter().collect(); batch.sort_by_key(|(k, _)| **k); self.disk_by_virtual_id.upsert_batch(&batch)?; self.pending_virtual_id_upserts.clear(); @@ -231,7 +232,7 @@ impl TargetIdMapping { !self.pending_virtual_id_upserts.is_empty() || !self.pending_uuid_upserts.is_empty() } - pub fn find_virtual_ids(&self, provider_id: u32) -> Vec { + pub fn find_virtual_ids(&self, provider_id: u32) -> Vec { self.mem_by_virtual_id .values() .filter(|record| record.provider_id == provider_id) @@ -239,9 +240,9 @@ impl TargetIdMapping { .collect() } - pub fn get_virtual_id_by_uuid(&self, uuid: &UUIDType) -> Option { self.mem_by_uuid.get(uuid).copied() } + pub fn get_virtual_id_by_uuid(&self, uuid: &UUIDType) -> Option { self.mem_by_uuid.get(uuid).copied() } - pub fn get_parent_virtual_id_by_uuid(&self, uuid: &UUIDType) -> Option { + pub fn get_parent_virtual_id_by_uuid(&self, uuid: &UUIDType) -> Option { self.get_virtual_id_by_uuid(uuid) .and_then(|virtual_id| self.mem_by_virtual_id.get(&virtual_id)) .map(|record| record.parent_virtual_id) @@ -312,16 +313,17 @@ mod tests { let uuid1 = UUIDType::default(); { let mut mapping = TargetIdMapping::new(&path, false)?; - let vid1 = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Live, 0); - assert_eq!(vid1, 1); + let vid1 = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Live, VirtualId::default()); + assert_eq!(vid1, VirtualId::new(1)); mapping.persist().map_err(|_| TuliproxError::Config("Failed to persist mapping".to_string()))?; } // Reopen and verify persistence { let mut mapping = TargetIdMapping::new(&path, false)?; - let vid1_again = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Live, 0); - assert_eq!(vid1_again, 1); // Should get same virtual_id + let vid1_again = + mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Live, VirtualId::default()); + assert_eq!(vid1_again, VirtualId::new(1)); // Should get same virtual_id } Ok(()) @@ -335,16 +337,17 @@ mod tests { let uuid1 = UUIDType::default(); { let mut mapping = TargetIdMapping::new(&path, false)?; - let vid1 = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Video, 0); - assert_eq!(vid1, 1); + let vid1 = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Video, VirtualId::default()); + assert_eq!(vid1, VirtualId::new(1)); mapping.persist().map_err(|err| TuliproxError::Config(err.to_string()))?; } // Reopen with memory cache and verify { let mut mapping = TargetIdMapping::new(&path, true)?; - let vid1_again = mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Video, 0); - assert_eq!(vid1_again, 1); + let vid1_again = + mapping.get_and_update_virtual_id(&uuid1, 100, PlaylistItemType::Video, VirtualId::default()); + assert_eq!(vid1_again, VirtualId::new(1)); } Ok(()) diff --git a/backend/repository/src/token_revocations.rs b/backend/repository/src/token_revocations.rs new file mode 100644 index 000000000..2aae6c993 --- /dev/null +++ b/backend/repository/src/token_revocations.rs @@ -0,0 +1,245 @@ +//! Revoking tokens that have already been issued. +//! +//! The tokens this server mints are stateless JWTs: once issued, nothing could +//! take one back. A leaked token stayed valid until it expired, and there was +//! no way to end a session, sign a principal out of every device, or respond +//! to a compromise short of rotating the signing secret - which invalidates +//! every session for every principal at once. +//! +//! This is a revocation *watermark* rather than a deny-list of individual +//! tokens: per subject, "everything issued at or before this instant is dead", +//! plus one global watermark for the same statement across all subjects. Two +//! properties fall out of that shape: +//! +//! * it is bounded. A deny-list grows with every revoked token and needs an +//! expiry sweep to stay finite; a watermark is one timestamp per principal +//! and one for the server. +//! * it revokes sessions a deny-list cannot name. "Sign out everywhere" and +//! "revoke everything issued before the breach" do not have a list of token +//! ids behind them. +//! +//! It costs precision: a watermark cannot revoke one session and spare +//! another issued in the same second. That is the right trade for the thing it +//! is for. +//! +//! Revocations are persisted, because the tokens they revoke survive a +//! restart. An in-memory revocation would be a security control that quietly +//! stops applying the next time the process starts. + +use serde::{Deserialize, Serialize}; +use shared::model::{Claims, UserId}; +use std::{ + collections::HashMap, + io, + path::{Path, PathBuf}, +}; +use tokio::sync::RwLock; + +/// The on-disk schema version. Bump when the shape changes. +pub const CURRENT_REVOCATIONS_VERSION: u32 = 1; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PersistedRevocations { + #[serde(default)] + pub version: u32, + /// Per subject: tokens issued at or before this unix second are revoked. + #[serde(default)] + pub by_subject: HashMap, + /// Across every subject. `0` means never. + #[serde(default)] + pub global: i64, +} + +/// Persisted revocation watermarks. +pub struct TokenRevocations { + state: RwLock, + path: PathBuf, +} + +impl TokenRevocations { + /// Load from disk, or start empty. + /// + /// A file that will not parse is *not* treated as "nothing is revoked" - + /// that would silently reinstate every revoked session. It is reported, and + /// the caller decides. + pub async fn load(path: PathBuf) -> Result { + match tokio::fs::read(&path).await { + Ok(bytes) if bytes.is_empty() => { + Err(io::Error::new(io::ErrorKind::InvalidData, "token revocation file is empty")) + } + Ok(bytes) => { + let mut state: PersistedRevocations = serde_json::from_slice(&bytes) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; + state.version = CURRENT_REVOCATIONS_VERSION; + Ok(Self { state: RwLock::new(state), path }) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Self::empty(path)), + Err(err) => Err(err), + } + } + + /// An empty store that never touches disk until something is revoked. + pub fn empty(path: PathBuf) -> Self { + Self { + state: RwLock::new(PersistedRevocations { + version: CURRENT_REVOCATIONS_VERSION, + ..PersistedRevocations::default() + }), + path, + } + } + + /// Revoke every token issued for `subject` at or before `at`. + /// + /// `at` is normally "now". Watermarks only move forward: a second call + /// with an earlier instant cannot resurrect sessions the first one killed. + pub async fn revoke_subject(&self, subject: &UserId, at: i64) -> Result<(), io::Error> { + let snapshot = { + let mut state = self.state.write().await; + let entry = state.by_subject.entry(subject.0.clone()).or_insert(at); + *entry = (*entry).max(at); + state.clone() + }; + Self::write(&self.path, &snapshot).await + } + + /// Revoke every token for every subject issued at or before `at`. + pub async fn revoke_all(&self, at: i64) -> Result<(), io::Error> { + let snapshot = { + let mut state = self.state.write().await; + state.global = state.global.max(at); + state.clone() + }; + Self::write(&self.path, &snapshot).await + } + + /// `true` when this token has been revoked. + /// + /// The comparison is `<=`, not `<`: `iat` has one-second resolution, so a + /// token minted in the same second as the revocation would otherwise + /// survive it. Over-revoking by up to a second is the safe direction. + pub async fn is_revoked(&self, claims: &Claims) -> bool { + let state = self.state.read().await; + if state.global > 0 && claims.iat <= state.global { + return true; + } + claims + .subject_id + .as_ref() + .and_then(|subject| state.by_subject.get(&subject.0)) + .is_some_and(|watermark| claims.iat <= *watermark) + } + + async fn write(path: &Path, state: &PersistedRevocations) -> Result<(), io::Error> { + if path.as_os_str().is_empty() { + // The empty path is the test/no-persistence case. + return Ok(()); + } + let bytes = serde_json::to_vec_pretty(state) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; + let tmp = path.with_extension("json.tmp"); + tokio::fs::write(&tmp, &bytes).await?; + tokio::fs::rename(&tmp, path).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::{PermissionSet, RoleSet, CURRENT_PERMISSION_SCHEMA_VERSION}; + + fn claims_for(subject: &str, iat: i64) -> Claims { + Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat, + exp: iat + 3600, + roles: RoleSet::new(), + permissions: PermissionSet::new(), + pwd_version: 1, + subject_id: Some(UserId::from(subject)), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + } + } + + #[tokio::test] + async fn nothing_is_revoked_by_default() { + let store = TokenRevocations::empty(PathBuf::new()); + assert!(!store.is_revoked(&claims_for("web:alice", 1_000)).await); + } + + #[tokio::test] + async fn revoking_a_subject_kills_older_tokens_and_spares_newer_ones() { + let store = TokenRevocations::empty(PathBuf::new()); + store.revoke_subject(&UserId::from("web:alice"), 1_000).await.expect("revoke"); + + assert!(store.is_revoked(&claims_for("web:alice", 999)).await); + // Same second as the revocation: killed, because `iat` cannot + // distinguish "just before" from "just after". + assert!(store.is_revoked(&claims_for("web:alice", 1_000)).await); + assert!(!store.is_revoked(&claims_for("web:alice", 1_001)).await); + // Another subject is untouched. + assert!(!store.is_revoked(&claims_for("web:bob", 999)).await); + } + + #[tokio::test] + async fn the_global_watermark_covers_every_subject() { + let store = TokenRevocations::empty(PathBuf::new()); + store.revoke_all(2_000).await.expect("revoke all"); + + assert!(store.is_revoked(&claims_for("web:alice", 1_999)).await); + assert!(store.is_revoked(&claims_for("api:bob", 2_000)).await); + assert!(!store.is_revoked(&claims_for("web:alice", 2_001)).await); + } + + #[tokio::test] + async fn watermarks_only_move_forward() { + let store = TokenRevocations::empty(PathBuf::new()); + store.revoke_subject(&UserId::from("web:alice"), 2_000).await.expect("revoke"); + // An earlier revocation must not resurrect what the later one killed. + store.revoke_subject(&UserId::from("web:alice"), 1_000).await.expect("revoke"); + assert!(store.is_revoked(&claims_for("web:alice", 1_500)).await); + + store.revoke_all(2_000).await.expect("revoke all"); + store.revoke_all(1_000).await.expect("revoke all"); + assert!(store.is_revoked(&claims_for("web:bob", 1_500)).await); + } + + #[tokio::test] + async fn a_token_with_no_subject_is_only_caught_by_the_global_watermark() { + let store = TokenRevocations::empty(PathBuf::new()); + let mut claims = claims_for("web:alice", 1_000); + claims.subject_id = None; + + store.revoke_subject(&UserId::from("web:alice"), 5_000).await.expect("revoke"); + assert!(!store.is_revoked(&claims).await); + + store.revoke_all(5_000).await.expect("revoke all"); + assert!(store.is_revoked(&claims).await); + } + + #[tokio::test] + async fn revocations_survive_a_reload() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("revocations.json"); + + let store = TokenRevocations::load(path.clone()).await.expect("load"); + store.revoke_subject(&UserId::from("web:alice"), 1_000).await.expect("revoke"); + + // The tokens a revocation kills outlive the process that killed them, + // so the revocation has to as well. + let reloaded = TokenRevocations::load(path).await.expect("reload"); + assert!(reloaded.is_revoked(&claims_for("web:alice", 999)).await); + } + + #[tokio::test] + async fn a_corrupt_file_is_an_error_not_an_empty_store() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("revocations.json"); + tokio::fs::write(&path, b"{ not json").await.expect("write"); + + // Reading it as "nothing is revoked" would silently reinstate every + // revoked session. + assert!(TokenRevocations::load(path).await.is_err()); + } +} diff --git a/backend/repository/src/xtream_playlist_iterator.rs b/backend/repository/src/xtream_playlist_iterator.rs index 170d4cfad..8bacf477c 100644 --- a/backend/repository/src/xtream_playlist_iterator.rs +++ b/backend/repository/src/xtream_playlist_iterator.rs @@ -237,7 +237,7 @@ mod tests { use crate::LockedReceiverStream; use futures::StreamExt; use shared::{ - model::{ClusterFlags, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}, + model::{ClusterFlags, PlaylistItemType, VirtualId, XtreamCluster, XtreamPlaylistItem}, utils::Internable, }; use tokio::sync::mpsc; @@ -256,7 +256,7 @@ mod tests { #[test] fn matches_filters_applies_user_content_filter() { let make_item = |group: &str| XtreamPlaylistItem { - virtual_id: 1, + virtual_id: VirtualId::new(1), provider_id: 1, name: "name".intern(), logo: "".intern(), @@ -303,7 +303,7 @@ mod tests { async fn iterator_forwards_one_storage_error_then_ends() { let (tx, rx) = mpsc::channel(2); let item = XtreamPlaylistItem { - virtual_id: 1, + virtual_id: VirtualId::new(1), provider_id: 1, name: "name".intern(), logo: "".intern(), @@ -325,7 +325,7 @@ mod tests { upstream_user_agent: None, }; assert!(tx.send(Ok((item, true))).await.is_ok()); - assert!(tx.send(Err(shared::error::TuliproxError::RepositoryXtream("corrupt page".into()))).await.is_ok()); + assert!(tx.send(Err(shared::error::TuliproxError::RepositoryXtream("corrupt page"))).await.is_ok()); drop(tx); let mut iterator = XtreamPlaylistIterator { inner: LockedReceiverStream::new_empty(rx) }; diff --git a/backend/repository/src/xtream_repository.rs b/backend/repository/src/xtream_repository.rs index b5ce3d756..6d1ff81f2 100644 --- a/backend/repository/src/xtream_repository.rs +++ b/backend/repository/src/xtream_repository.rs @@ -4,18 +4,18 @@ use crate::{ ensure_distinct_sidecar_lock_domains, publish_staged_database, BPlusTree, BPlusTreeError, BPlusTreeQuery, BPlusTreeStagingArtifacts, BPlusTreeUpdate, FlushPolicy, }, - open_playlist_reader, + playlist_backend::{ensure_storage_path, iter_raw_playlist, PlaylistBackend, PlaylistKey, Xtream}, playlist_scratch::PlaylistScratch, storage::{ - ensure_input_storage_path, ensure_target_storage_subpath, get_file_path_for_db_index, get_input_storage_path, - get_target_id_mapping_file, get_target_storage_path, XtreamRefreshGenerationGuard, + ensure_input_storage_path, get_input_storage_path, get_target_id_mapping_file, get_target_storage_path, + XtreamRefreshGenerationGuard, }, storage_const, target_id_mapping::VirtualIdRecord, xtream_playlist_iterator::XtreamPlaylistJsonIterator, + LockedReceiverStream, }; use bytes::Bytes; -use fs2::FileExt; use futures::{stream, Stream, StreamExt}; use indexmap::IndexMap; use log::error; @@ -25,8 +25,8 @@ use shared::{ concat_string, error::{string_to_io_error, TuliproxError}, model::{ - xtream_const::XTREAM_CLUSTER, LiveStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemType, - SeriesStreamProperties, StreamProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, + xtream_const::XTREAM_CLUSTER, LiveStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemType, ProviderId, + SeriesStreamProperties, StreamProperties, VideoStreamProperties, VirtualId, XtreamCluster, XtreamPlaylistItem, }, utils::{arc_str_serde, get_u32_from_serde_value, Internable}, }; @@ -38,8 +38,6 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; use tuliprox_core::{ model::{ AppConfig, Config, ConfigInput, ConfigTarget, PlaylistXtreamCategory, ProxyUserCredentials, XtreamCategory, @@ -85,24 +83,30 @@ pub fn get_series_cat_collection_path(path: &Path) -> PathBuf { get_collection_path(path, storage_const::COL_CAT_SERIES) } +#[inline] pub async fn ensure_xtream_storage_path(cfg: &Config, target_name: &str) -> Result { - ensure_target_storage_subpath(cfg, target_name, "xtream", xtream_get_storage_path, TuliproxError::RepositoryXtream) - .await + ensure_storage_path::(cfg, target_name).await } -#[derive(Debug, Copy, Clone)] -enum StorageKey { - VirtualId, - ProviderId, -} - -async fn write_playlists_to_file( +/// Persist `collections`, keyed by whatever `key_of` extracts. +/// +/// The key space is a type parameter rather than a runtime tag. These stores are +/// keyed by [`VirtualId`] on the target path and by [`ProviderId`] on the input +/// path -- the same file layout and value type, two different id spaces -- and a +/// `StorageKey` enum matched per item used to be the only thing recording which. +/// Both keys are `#[serde(transparent)]` over `u32`, so the on-disk encoding is +/// unchanged either way (see the codec test in `backend/btree`). +async fn write_playlists_to_file( app_config: &Arc, storage_path: &Path, with_index: bool, - storage_key: StorageKey, + key_of: F, collections: Vec<(XtreamCluster, Vec)>, -) -> Result<(), TuliproxError> { +) -> Result<(), TuliproxError> +where + K: PlaylistKey, + F: Fn(&XtreamPlaylistItem) -> K + Copy + Send + 'static, +{ for (cluster, playlist) in collections { if playlist.is_empty() { continue; @@ -117,15 +121,10 @@ async fn write_playlists_to_file( let path_clone = xtream_path.clone(); tokio::task::spawn_blocking(move || -> Result<(), std::io::Error> { let _guard = file_lock; - let mut tree = BPlusTree::new(); + let mut tree = BPlusTree::::new(); for item in playlist { - tree.insert( - match storage_key { - StorageKey::VirtualId => item.virtual_id, - StorageKey::ProviderId => item.provider_id, - }, - item, - ); + let key = key_of(&item); + tree.insert(key, item); } if with_index { tree.store_with_index(&path_clone, |pli| pli.source_ordinal)?; @@ -160,8 +159,9 @@ pub async fn write_playlist_item_update( } // Prepare encoded payload before opening the writer lock. - let prepared_items = BPlusTreeUpdate::::prepare_upsert_batch(&[(&pli.virtual_id, pli)]) - .map_err(|e| TuliproxError::RepositoryXtream(format!("Failed to serialize value: {e}")))?; + let prepared_items = + BPlusTreeUpdate::::prepare_upsert_batch(&[(&pli.virtual_id.get(), pli)]) + .map_err(|e| TuliproxError::RepositoryXtream(format!("Failed to serialize value: {e}")))?; // Keep FileLockManager lock for cross-operation coordination (e.g. swap + update). let file_lock = app_config.file_locks.write_lock(&xtream_path).await; @@ -204,7 +204,8 @@ pub async fn write_playlist_batch_item_upsert( } // Prepare encoded payload before opening the writer lock. - let batch_refs: Vec<(&u32, &XtreamPlaylistItem)> = pli_list.iter().map(|pli| (&pli.virtual_id, pli)).collect(); + let virtual_ids: Vec = pli_list.iter().map(|pli| pli.virtual_id.get()).collect(); + let batch_refs: Vec<(&u32, &XtreamPlaylistItem)> = virtual_ids.iter().zip(pli_list.iter()).collect(); let prepared_items = BPlusTreeUpdate::::prepare_upsert_batch(&batch_refs) .map_err(|e| TuliproxError::RepositoryXtream(format!("Failed to serialize value: {e}")))?; @@ -289,9 +290,9 @@ async fn load_old_category_ids(path: &Path) -> (u32, HashMap) .unwrap_or_else(|_| (0, HashMap::new())) } +#[inline] pub fn xtream_get_storage_path(cfg: &Config, target_name: &str) -> Option { - get_target_storage_path(cfg, target_name) - .map(|target_path| target_path.join(PathBuf::from(storage_const::PATH_XTREAM))) + Xtream::storage_path(cfg, target_name) } pub fn xtream_get_epg_file_path_for_target(path: &Path) -> PathBuf { @@ -385,7 +386,7 @@ pub async fn xtream_write_playlist( } let data = col.iter().map(|item| XtreamPlaylistItem::from(&**item)).collect::>(); if let Err(err) = - write_playlists_to_file(app_cfg, &path, true, StorageKey::VirtualId, vec![(cluster, data)]).await + write_playlists_to_file(app_cfg, &path, true, |item| item.virtual_id, vec![(cluster, data)]).await { errors.push(format!("Persisting collection failed:{err}")); } @@ -486,11 +487,14 @@ async fn xtream_read_series_item_for_stream_id( .map_err(|err| Error::other(format!("Query task failed for {stream_id} in series: {err}")))? } -macro_rules! try_cluster { - ($xtream_cluster:expr, $item_type:expr, $virtual_id:expr) => { - $xtream_cluster.or_else(|| XtreamCluster::try_from($item_type).ok()).ok_or_else(|| { - string_to_io_error(format!("Could not determine cluster for xtream item with stream-id {}", $virtual_id)) - }) +/// The stored cluster if the mapping has one, otherwise the item type's own. +/// +/// Was a `try_cluster!` returning a `Result` whose error arm was unreachable: +/// the fallback went through `XtreamCluster::try_from(..).ok()`, which is always +/// `Some`, so `ok_or_else` never fired. +macro_rules! cluster_or_item_type { + ($xtream_cluster:expr, $item_type:expr) => { + $xtream_cluster.unwrap_or_else(|| $item_type.cluster()) }; } @@ -504,7 +508,7 @@ async fn xtream_get_item_for_stream_id_from_memory( return match (playlist.xtream.as_ref(), playlist.id_mapping.as_ref()) { (Some(xtream_storage), Some(id_mapping)) => { let mapping = id_mapping - .query(&virtual_id) + .query(&VirtualId::new(virtual_id)) .ok_or_else(|| { string_to_io_error(format!( "Could not find mapping for target {} and id {}", @@ -515,7 +519,7 @@ async fn xtream_get_item_for_stream_id_from_memory( let result = match mapping.item_type { PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo => Ok(xtream_storage .series - .query(&mapping.virtual_id) + .query(&mapping.virtual_id.get()) .ok_or_else(|| string_to_io_error(format!("Failed to read xtream item for id {virtual_id}")))? .clone()), PlaylistItemType::Series | PlaylistItemType::LocalSeries => { @@ -523,7 +527,7 @@ async fn xtream_get_item_for_stream_id_from_memory( if let Some(item) = xtream_storage.series.query(&virtual_id) { Ok(item.clone()) - } else if let Some(item) = xtream_storage.series.query(&mapping.parent_virtual_id) { + } else if let Some(item) = xtream_storage.series.query(&mapping.parent_virtual_id.get()) { let mut xc_item = item.clone(); xc_item.provider_id = mapping.provider_id; xc_item.item_type = PlaylistItemType::Series; @@ -535,11 +539,11 @@ async fn xtream_get_item_for_stream_id_from_memory( } PlaylistItemType::Catchup => { log::debug!("In-memory catchup item requested. VirtualID: {}, ParentVirtualID: {}, MappingProviderID: {}", virtual_id, mapping.parent_virtual_id, mapping.provider_id); - let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; + let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type); let item = match cluster { - XtreamCluster::Live => xtream_storage.live.query(&mapping.parent_virtual_id), - XtreamCluster::Video => xtream_storage.vod.query(&mapping.parent_virtual_id), - XtreamCluster::Series => xtream_storage.series.query(&mapping.parent_virtual_id), + XtreamCluster::Live => xtream_storage.live.query(&mapping.parent_virtual_id.get()), + XtreamCluster::Video => xtream_storage.vod.query(&mapping.parent_virtual_id.get()), + XtreamCluster::Series => xtream_storage.series.query(&mapping.parent_virtual_id.get()), }; if let Some(pl_item) = item { @@ -553,7 +557,7 @@ async fn xtream_get_item_for_stream_id_from_memory( } } _ => { - let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; + let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type); Ok((match cluster { XtreamCluster::Live => xtream_storage.live.query(&virtual_id), XtreamCluster::Video => xtream_storage.vod.query(&virtual_id), @@ -637,9 +641,12 @@ pub async fn xtream_get_item_for_stream_id( return Ok(episode); } - if let Ok(mut item) = - xtream_read_series_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path) - .await + if let Ok(mut item) = xtream_read_series_item_for_stream_id( + app_config, + mapping.parent_virtual_id.get(), + &storage_path, + ) + .await { item.provider_id = mapping.provider_id; item.item_type = PlaylistItemType::Series; @@ -656,17 +663,21 @@ pub async fn xtream_get_item_for_stream_id( mapping.parent_virtual_id, mapping.provider_id ); - let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; - let mut item = - xtream_read_item_for_stream_id(app_config, mapping.parent_virtual_id, &storage_path, cluster) - .await?; + let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type); + let mut item = xtream_read_item_for_stream_id( + app_config, + mapping.parent_virtual_id.get(), + &storage_path, + cluster, + ) + .await?; item.provider_id = mapping.provider_id; item.item_type = PlaylistItemType::Catchup; item.virtual_id = mapping.virtual_id; Ok(item) } _ => { - let cluster = try_cluster!(xtream_cluster, mapping.item_type, virtual_id)?; + let cluster = cluster_or_item_type!(xtream_cluster, mapping.item_type); xtream_read_item_for_stream_id(app_config, virtual_id, &storage_path, cluster).await } } @@ -690,89 +701,27 @@ pub async fn iter_raw_xtream_target_playlist( app_config: &AppConfig, target: &ConfigTarget, cluster: XtreamCluster, -) -> Option> + Send + Unpin>> { +) -> Option>> { let config = app_config.config.load(); let storage_path = xtream_get_storage_path(&config, target.name.as_str())?; let xtream_path = xtream_get_file_path(&storage_path, cluster); - iter_raw_xtream_playlist(app_config, &xtream_path).await + + // Xtream partitions by cluster at the file level, so every item in this + // database already belongs to `cluster` and no per-item filter is needed. + iter_raw_playlist::(app_config, &xtream_path, |_| true).await } pub async fn iter_raw_xtream_input_playlist( app_config: &AppConfig, input: &ConfigInput, cluster: XtreamCluster, -) -> Option> + Send + Unpin>> { +) -> Option>> { let config = app_config.config.load(); let storage_dir = &config.storage_dir; let storage_path = get_input_storage_path(&input.name, storage_dir).await.ok()?; let xtream_path = xtream_get_file_path(&storage_path, cluster); - iter_raw_xtream_playlist(app_config, &xtream_path).await -} - -async fn iter_raw_xtream_playlist( - app_config: &AppConfig, - xtream_path: &Path, -) -> Option> + Send + Unpin>> { - if !file_exists_async(xtream_path).await { - return None; - } - let bg_lock = app_config.file_locks.read_lock(xtream_path).await; - - let xtream_path = xtream_path.to_path_buf(); - let index_path = get_file_path_for_db_index(&xtream_path); - let (tx, rx) = mpsc::channel::>(256); - - let xtream_path_for_log = xtream_path.clone(); - let index_path_for_log = index_path.clone(); - let join_error_tx = tx.clone(); - let handle = tokio::task::spawn_blocking(move || { - let _guard = bg_lock; - let reader = match open_playlist_reader::(&xtream_path, &index_path, None) { - Ok(reader) => reader, - Err(err) => { - error!( - "Failed to open Xtream playlist reader {} (index {}): {err}", - xtream_path.display(), - index_path.display() - ); - let _ = tx.blocking_send(Err(err)); - return; - } - }; - - for entry in reader { - let item = match entry { - Ok((_, item)) => item, - Err(err) => { - error!("Skipping unreadable Xtream playlist entry: {err}"); - continue; - } - }; - if tx.blocking_send(Ok(item)).is_err() { - break; - } - } - }); - tokio::spawn(async move { - if let Err(err) = handle.await { - error!( - "Xtream playlist producer task failed for {} (index {}): {err}", - xtream_path_for_log.display(), - index_path_for_log.display() - ); - let _ = join_error_tx - .send(Err(TuliproxError::RepositoryXtream(format!( - "Xtream playlist producer task failed for {}: {err}", - xtream_path_for_log.display() - )))) - .await; - } - }); - - let stream: Box> + Send + Unpin> = - Box::new(ReceiverStream::new(rx)); - Some(stream) + iter_raw_playlist::(app_config, &xtream_path, |_| true).await } pub fn playlist_iter_to_stream(channels: Option<(FileReadGuard, I)>) -> impl Stream> @@ -1434,7 +1383,7 @@ struct LockedCategoryFile { impl LockedCategoryFile { fn create(path: &Path) -> io::Result { let file = File::create(path)?; - file.lock_exclusive()?; + file.lock()?; Ok(Self { file, path: path.to_path_buf() }) } @@ -1443,7 +1392,7 @@ impl LockedCategoryFile { impl Drop for LockedCategoryFile { fn drop(&mut self) { - if let Err(error) = fs2::FileExt::unlock(&self.file) { + if let Err(error) = self.file.unlock() { log::warn!( "Failed to unlock staging category file {}: {error}; the OS will release it on close", self.path.display() @@ -1605,7 +1554,7 @@ pub async fn persist_input_xtream_playlist( app_config, storage_path, false, - StorageKey::ProviderId, + |item| ProviderId::new(item.provider_id), vec![(cluster, col.iter().map(Into::into).collect::>())], ) .await @@ -1986,11 +1935,10 @@ mod tests { refresh_generation_guard_path, BPlusTreeQuery, BPlusTreeUpdate, }; use arc_swap::{ArcSwap, ArcSwapOption}; - use fs2::FileExt; use shared::{ model::{ CatchupProperties, ConfigPaths, InputType, LiveStreamProperties, SeriesStreamProperties, StreamProperties, - VideoStreamProperties, XtreamCluster, XtreamPlaylistItem, + VideoStreamProperties, VirtualId, XtreamCluster, XtreamPlaylistItem, }, utils::Internable, }; @@ -2260,7 +2208,7 @@ mod tests { bitrate: u32, ) -> XtreamPlaylistItem { XtreamPlaylistItem { - virtual_id: provider_id, + virtual_id: VirtualId::new(provider_id), provider_id, name: "Live".intern(), logo: "".intern(), @@ -2580,8 +2528,8 @@ mod tests { let between_batch_probe = fs::OpenOptions::new().read(true).write(true).open(&sidecar).expect("open staging sidecar"); - between_batch_probe.try_lock_exclusive().expect("staging sidecar should be unlocked between batches"); - fs2::FileExt::unlock(&between_batch_probe).expect("release between-batch probe"); + between_batch_probe.try_lock().expect("staging sidecar should be unlocked between batches"); + between_batch_probe.unlock().expect("release between-batch probe"); drop(between_batch_probe); cleanup_orphaned_staging_artifacts(dir.path(), Duration::ZERO); diff --git a/backend/session/src/active_user_manager.rs b/backend/session/src/active_user_manager.rs index ca645e55f..4c9675a57 100644 --- a/backend/session/src/active_user_manager.rs +++ b/backend/session/src/active_user_manager.rs @@ -1,6 +1,5 @@ use crate::{ active_provider_manager::ConnectionKind, connection_manager::CleanupEvent, ActiveProviderManager, EventManager, - EventMessage, }; use arc_swap::ArcSwapOption; use jsonwebtoken::get_current_timestamp; @@ -11,7 +10,7 @@ use shared::{ default_grace_period_millis, default_grace_period_timeout_secs, default_hls_session_ttl_secs, DASH_EXT, HLS_EXT, }, model::{ - ActiveUserConnectionChange, CustomVideoStreamType, PlaylistItemType, StreamChannel, StreamInfo, + ActiveUserConnectionChange, CustomVideoStreamType, EventMessage, PlaylistItemType, StreamChannel, StreamInfo, StreamTechnicalInfo, UserConnectionPermission, VirtualId, }, utils::{ @@ -621,6 +620,13 @@ impl ActiveUserManager { } } + /// The bus this manager publishes on. + /// + /// Exposed so the admission path can report a refusal without + /// `AdmissionCtx` growing a second handle to the same manager. + #[must_use] + pub fn events(&self) -> &Arc { &self.event_manager } + pub fn new(config: &Config, geoip: &Arc>, event_manager: &Arc) -> Self { let log_active_user: bool = config.log.as_ref().is_some_and(|l| l.log_active_user); let (grace_period_millis, grace_period_timeout_secs) = get_grace_options(config); @@ -3042,7 +3048,7 @@ impl ActiveUserManager { socket_guard_keys.push(create_socket_reentry_guard_key( &username, &stream.client_ip, - stream.channel.virtual_id, + shared::model::VirtualId::new(stream.channel.virtual_id), )); } } @@ -5546,7 +5552,13 @@ mod tests { manager.mark_recent_eviction_guard_for_addr(&evicted_addr, protected_addr, 10).await; assert_eq!( - manager.recent_socket_reentry_protected_addr("vod-no-token-user", "127.0.0.1", channel.virtual_id).await, + manager + .recent_socket_reentry_protected_addr( + "vod-no-token-user", + "127.0.0.1", + shared::model::VirtualId::new(channel.virtual_id) + ) + .await, Some(protected_addr) ); } diff --git a/backend/session/src/admission.rs b/backend/session/src/admission.rs index 7bfea5256..618450649 100644 --- a/backend/session/src/admission.rs +++ b/backend/session/src/admission.rs @@ -17,7 +17,7 @@ use crate::{ connection_manager::ConnectionManager, }; use log::debug; -use shared::model::{AdmissionStrategy, PlaylistItemType, UserConnectionPermission, VirtualId}; +use shared::model::{AdmissionStrategy, ConnectionDenied, EventMessage, UserConnectionPermission, VirtualId}; use std::sync::Arc; use tuliprox_core::model::{AppConfig, Fingerprint, ProxyUserCredentials}; @@ -42,6 +42,27 @@ pub enum EvictionReentryGuard<'a> { SocketPlayback { virtual_id: VirtualId }, } +/// The request-scoped inputs every admission path threads through unchanged. +/// +/// These used to be ten positional parameters repeated across four functions, +/// three of them bare `bool`s in a row - a shape where transposing two arguments +/// still compiles. Naming them at the call site is the point. +#[derive(Clone, Copy)] +pub struct AdmissionRequest<'a> { + pub username: &'a str, + pub max_connections: u32, + pub soft_connections: u16, + pub client_ip: &'a str, + pub request_addr: &'a std::net::SocketAddr, + /// Whether an existing logical playback session may reopen while the user is + /// already at limit. Intentionally independent from whether the session is + /// socket-bound. + pub use_session_admission: bool, + pub session_token: Option<&'a str>, + pub activate_unbound_session: bool, + pub eviction_reentry_guard: EvictionReentryGuard<'a>, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PlaybackRequestClass { Prepare, @@ -52,7 +73,6 @@ pub enum PlaybackRequestClass { #[derive(Clone, Copy)] pub struct PlaybackRequestFacts<'a> { - pub item_type: PlaylistItemType, pub existing_session: Option<&'a UserSession>, pub prepare_only: bool, pub terminate: bool, @@ -73,7 +93,6 @@ pub fn classify_playback_request(facts: PlaybackRequestFacts<'_>) -> PlaybackReq return PlaybackRequestClass::FollowUp; } } - let _ = facts.item_type; PlaybackRequestClass::Activate } @@ -82,7 +101,6 @@ pub async fn resolve_playback_request_admission( adm: &AdmissionCtx, user: &ProxyUserCredentials, fingerprint: &Fingerprint, - item_type: PlaylistItemType, user_session: Option<&UserSession>, session_token: &str, activate_unbound_session: bool, @@ -90,12 +108,8 @@ pub async fn resolve_playback_request_admission( prepare_only: bool, terminate: bool, ) -> (crate::ConnectionAdmission, Option, PlaybackRequestClass) { - let request_class = classify_playback_request(PlaybackRequestFacts { - item_type, - existing_session: user_session, - prepare_only, - terminate, - }); + let request_class = + classify_playback_request(PlaybackRequestFacts { existing_session: user_session, prepare_only, terminate }); let limits_enabled = (user.max_connections > 0 || user.soft_connections > 0) && adm.app_config.config.load().user_access_control; @@ -141,29 +155,46 @@ pub async fn resolve_playback_request_admission( let result = resolve_admission_with_strategies( adm, - &user.username, - user.max_connections, - user.soft_connections, - &fingerprint.client_ip, - &fingerprint.addr, - true, - Some(session_token), - activate_unbound_session, - eviction_reentry_guard, + AdmissionRequest { + username: &user.username, + max_connections: user.max_connections, + soft_connections: user.soft_connections, + client_ip: &fingerprint.client_ip, + request_addr: &fingerprint.addr, + use_session_admission: true, + session_token: Some(session_token), + activate_unbound_session, + eviction_reentry_guard, + }, ) .await; + // The ladder models this outcome fully - it is what is left after every + // eviction strategy declines - and then returned it to the caller and + // nobody else. `ActiveUser` covers connects and disconnects; a refusal is + // neither, so the one outcome a user actually complains about was the one + // nothing published. + // + // Only the strategy path emits. An explicit `Terminate` also resolves to + // `Exhausted`, but that is a requested teardown, not a denial. + if result.admission.permission == UserConnectionPermission::Exhausted { + adm.active_users.events().send_event(EventMessage::ConnectionDenied(ConnectionDenied::new( + Arc::from(user.username.as_str()), + Arc::from(fingerprint.client_ip.as_str()), + user.max_connections, + user.soft_connections, + ))); + } + (result.admission, result.grace_mode, request_class) } async fn should_suppress_eviction_for_recent_request( adm: &AdmissionCtx, - username: &str, - client_ip: &str, - guard: EvictionReentryGuard<'_>, + request: &AdmissionRequest<'_>, target_addr: &std::net::SocketAddr, ) -> bool { - match guard { + match request.eviction_reentry_guard { EvictionReentryGuard::Session(session_token) => adm .active_users .recently_evicted_session_protected_addr(session_token) @@ -171,39 +202,23 @@ async fn should_suppress_eviction_for_recent_request( .is_some_and(|protected_addr| protected_addr == *target_addr), EvictionReentryGuard::SocketPlayback { virtual_id } => adm .active_users - .recent_socket_reentry_protected_addr(username, client_ip, virtual_id) + .recent_socket_reentry_protected_addr(request.username, request.client_ip, virtual_id) .await .is_some_and(|protected_addr| protected_addr == *target_addr), } } -async fn get_admission_for_request( - adm: &AdmissionCtx, - username: &str, - max_connections: u32, - soft_connections: u16, - is_session_request: bool, - session_token: Option<&str>, - activate_unbound_session: bool, -) -> crate::ConnectionAdmission { - if is_session_request { - if activate_unbound_session { +async fn get_admission_for_request(adm: &AdmissionCtx, request: &AdmissionRequest<'_>) -> crate::ConnectionAdmission { + let AdmissionRequest { username, max_connections, soft_connections, .. } = *request; + if request.use_session_admission { + let session_token = request.session_token.unwrap_or_default(); + if request.activate_unbound_session { adm.active_users - .connection_admission_for_session_activation( - username, - max_connections, - soft_connections, - session_token.unwrap_or_default(), - ) + .connection_admission_for_session_activation(username, max_connections, soft_connections, session_token) .await } else { adm.active_users - .connection_admission_for_session( - username, - max_connections, - soft_connections, - session_token.unwrap_or_default(), - ) + .connection_admission_for_session(username, max_connections, soft_connections, session_token) .await } } else { @@ -227,19 +242,24 @@ pub struct AdmissionStrategyResolution { pub grace_context: Option, } -pub fn get_effective_admission_strategies(adm: &AdmissionCtx) -> Vec { +pub fn get_effective_admission_strategies(adm: &AdmissionCtx) -> Arc<[AdmissionStrategy]> { let config = adm.app_config.config.load(); - let stream_config = config.reverse_proxy.as_ref().and_then(|rp| rp.stream.as_ref()); - match stream_config { - Some(sc) if sc.admission_strategies.is_some() => sc.admission_strategies.clone().unwrap_or_default(), - Some(sc) if sc.grace_period_millis > 0 => { - vec![if sc.grace_period_hold_stream { - AdmissionStrategy::GraceHoldStream - } else { - AdmissionStrategy::GraceInstantStream - }] - } - _ => Vec::new(), + let Some(stream_config) = config.reverse_proxy.as_ref().and_then(|rp| rp.stream.as_ref()) else { + return Arc::from([]); + }; + + // `Some(vec![])` is deliberately not the same as `None`. An explicitly empty + // list means the operator turned admission strategies off, and must not fall + // back to the legacy `grace_period_millis` translation below; only an absent + // list does. + match stream_config.admission_strategies.as_ref() { + Some(strategies) => Arc::from(strategies.as_slice()), + None if stream_config.grace_period_millis > 0 => Arc::from([if stream_config.grace_period_hold_stream { + AdmissionStrategy::GraceHoldStream + } else { + AdmissionStrategy::GraceInstantStream + }]), + None => Arc::from([]), } } @@ -251,22 +271,12 @@ pub fn get_effective_admission_strategies(adm: &AdmissionCtx) -> Vec( +async fn evaluate_admission_strategy_loop( adm: &AdmissionCtx, - username: &'a str, - max_connections: u32, - soft_connections: u16, - client_ip: &'a str, - request_addr: &'a std::net::SocketAddr, - use_session_admission: bool, - session_token: Option<&'a str>, - activate_unbound_session: bool, - eviction_reentry_guard: EvictionReentryGuard<'a>, - strategies: &'a [shared::model::AdmissionStrategy], + request: &AdmissionRequest<'_>, + strategies: &[shared::model::AdmissionStrategy], base_idx: usize, admission: crate::ConnectionAdmission, - _kind_for_exhausted: Option, build_grace_ctx: F, ) -> Option where @@ -274,11 +284,22 @@ where { use crate::{evaluate_strategy, AdmissionDecision, StrategyContext}; use shared::model::UserConnectionPermission; + let AdmissionRequest { username, client_ip, request_addr, .. } = *request; let mut candidates = adm.active_users.get_eviction_candidates(username, client_ip).await; let ctx = StrategyContext { username, client_ip }; - let mut idx = 0usize; + // Set once an eviction has been carried out without reducing the user's + // counted connections. Evicting is destructive and cannot be undone, so a + // kick that frees nothing is taken as evidence that the next one would not + // help either, and later eviction strategies are skipped. + let mut evictions_ineffective = false; - for strategy in strategies { + // `enumerate` rather than a manual counter: the suppressed-eviction arm below + // uses `continue`, which used to skip a trailing `idx += 1` and hand every + // later strategy an index one too low. A grace admitted after a suppressed + // eviction then recorded a `strategy_index` pointing at an earlier strategy, + // so `evaluate_remaining_strategies_after_grace` replayed the grace itself + // instead of resuming past it. + for (idx, strategy) in strategies.iter().enumerate() { match evaluate_strategy(*strategy, &ctx, &candidates) { AdmissionDecision::NoMatch => {} AdmissionDecision::Grace(mode) => { @@ -298,15 +319,13 @@ where debug!("Grace grant rejected for user {username}, continuing with later strategies"); } AdmissionDecision::Evict(target) => { - if should_suppress_eviction_for_recent_request( - adm, - username, - client_ip, - eviction_reentry_guard, - &target.addr, - ) - .await - { + if evictions_ineffective { + debug!( + "Skipping eviction strategy {strategy:?} for user {username}: an earlier eviction freed no slot" + ); + continue; + } + if should_suppress_eviction_for_recent_request(adm, request, &target.addr).await { debug!( "Skipping eviction strategy {strategy:?} for recently evicted request of user {username} targeting {}", target.addr @@ -314,20 +333,12 @@ where continue; } debug!("Evicting connection {} for user {username}", target.addr); + let connections_before = adm.active_users.user_connections(username).await; adm.active_users .mark_recent_eviction_guard_for_addr(&target.addr, *request_addr, RECENT_EVICTION_REENTRY_TTL_SECS) .await; adm.connection_manager.release_connection_as_kicked(&target.addr).await; - let retry_admission = get_admission_for_request( - adm, - username, - max_connections, - soft_connections, - use_session_admission, - session_token, - activate_unbound_session, - ) - .await; + let retry_admission = get_admission_for_request(adm, request).await; if retry_admission.permission == UserConnectionPermission::Allowed { return Some(AdmissionStrategyResolution { admission: retry_admission, @@ -335,44 +346,34 @@ where grace_context: None, }); } - debug!("Admission still denied after eviction for user {username}, continuing with later strategies"); + if adm.active_users.user_connections(username).await >= connections_before { + evictions_ineffective = true; + debug!( + "Eviction of {} freed no counted connection for user {username}, skipping later eviction strategies", + target.addr + ); + } else { + debug!( + "Admission still denied after eviction for user {username}, continuing with later strategies" + ); + } candidates = adm.active_users.get_eviction_candidates(username, client_ip).await; } } - idx += 1; } // All strategies returned NoMatch — caller constructs the final exhausted result. None } -#[allow(clippy::too_many_arguments)] pub async fn resolve_admission_with_strategies( adm: &AdmissionCtx, - username: &str, - max_connections: u32, - soft_connections: u16, - client_ip: &str, - request_addr: &std::net::SocketAddr, - // This controls whether an existing logical playback session may reopen while the user is already at limit. - // It is intentionally independent from whether the session is socket-bound. - use_session_admission: bool, - session_token: Option<&str>, - activate_unbound_session: bool, - eviction_reentry_guard: EvictionReentryGuard<'_>, + request: AdmissionRequest<'_>, ) -> AdmissionStrategyResolution { use shared::model::UserConnectionPermission; - let admission = get_admission_for_request( - adm, - username, - max_connections, - soft_connections, - use_session_admission, - session_token, - activate_unbound_session, - ) - .await; + let username = request.username; + let admission = get_admission_for_request(adm, &request).await; if admission.permission != UserConnectionPermission::Exhausted { return AdmissionStrategyResolution { admission, grace_mode: None, grace_context: None }; @@ -384,32 +385,28 @@ pub async fn resolve_admission_with_strategies( return AdmissionStrategyResolution { admission, grace_mode: None, grace_context: None }; } + let _admission_guard = adm.active_users.acquire_user_admission(username).await; + + // Re-read admission now that the gate is held. The first read above happened + // before we queued on the gate, so a request ahead of us may have released + // the very slot we are about to evict somebody for. Walking the strategies on + // the stale snapshot kicks a live connection to free a slot that is already + // free. + let admission = get_admission_for_request(adm, &request).await; + + if admission.permission != UserConnectionPermission::Exhausted { + debug!("Admission became available while waiting on the admission gate for user {username}"); + return AdmissionStrategyResolution { admission, grace_mode: None, grace_context: None }; + } + let build_grace_ctx = |global_idx: usize| GraceResolutionContext { strategy_index: global_idx, strategies: strategies.clone(), kind: admission.kind, }; - let _admission_guard = adm.active_users.acquire_user_admission(username).await; - - if let Some(resolution) = evaluate_admission_strategy_loop( - adm, - username, - max_connections, - soft_connections, - client_ip, - request_addr, - use_session_admission, - session_token, - activate_unbound_session, - eviction_reentry_guard, - &strategies, - 0, - admission, - admission.kind, - build_grace_ctx, - ) - .await + if let Some(resolution) = + evaluate_admission_strategy_loop(adm, &request, &strategies, 0, admission, build_grace_ctx).await { return resolution; } @@ -426,26 +423,19 @@ pub async fn resolve_admission_with_strategies( /// - Only `grace_context.strategies[(strategy_index + 1)..]` are evaluated /// - `NoMatch` -> continue to next strategy /// - `Evict` -> kick target, retry admission -/// - `Grace` -> technically possible under current config (only one grace allowed), but handled -/// - `Deny` -> final exhausted -/// - Empty remaining slice -> final exhausted -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +/// - `Grace` -> granted again if the user is eligible; the resolution then carries a +/// `GraceResolutionContext` whose `strategy_index` points at this later strategy, so a +/// second failure resumes past it rather than replaying it +/// - Every strategy exhausted, or an empty remaining slice -> final exhausted pub async fn evaluate_remaining_strategies_after_grace( adm: &AdmissionCtx, - username: &str, - max_connections: u32, - soft_connections: u16, - client_ip: &str, - request_addr: &std::net::SocketAddr, - use_session_admission: bool, - session_token: Option<&str>, - activate_unbound_session: bool, - eviction_reentry_guard: EvictionReentryGuard<'_>, + request: AdmissionRequest<'_>, grace_context: &GraceResolutionContext, original_kind: Option, ) -> AdmissionStrategyResolution { use shared::model::UserConnectionPermission; + let username = request.username; let remaining = grace_context.strategy_index + 1; let strategies = &grace_context.strategies; if remaining >= strategies.len() { @@ -460,9 +450,10 @@ pub async fn evaluate_remaining_strategies_after_grace( }; } - // admission.kind is used only inside build_grace_ctx for the Grace case's - // GraceResolutionContext.kind. Both paths (helper early-return and caller - // exhausted construction) use original_kind, so this is safe. + // `admission` only carries `kind` into the loop: the Grace arm copies it onto the + // returned `ConnectionAdmission`, and `build_grace_ctx` copies it onto the + // `GraceResolutionContext`. Seeding it with `original_kind` keeps every exit from this + // function reporting the kind the original admission decided. let admission = crate::ConnectionAdmission { permission: UserConnectionPermission::Exhausted, kind: original_kind }; let build_grace_ctx = |global_idx: usize| GraceResolutionContext { strategy_index: global_idx, @@ -472,24 +463,9 @@ pub async fn evaluate_remaining_strategies_after_grace( let _admission_guard = adm.active_users.acquire_user_admission(username).await; - if let Some(resolution) = evaluate_admission_strategy_loop( - adm, - username, - max_connections, - soft_connections, - client_ip, - request_addr, - use_session_admission, - session_token, - activate_unbound_session, - eviction_reentry_guard, - &strategies[remaining..], - remaining, - admission, - original_kind, - build_grace_ctx, - ) - .await + if let Some(resolution) = + evaluate_admission_strategy_loop(adm, &request, &strategies[remaining..], remaining, admission, build_grace_ctx) + .await { return resolution; } diff --git a/backend/session/src/admission_strategy.rs b/backend/session/src/admission_strategy.rs index b66e8f16b..14dc96dd4 100644 --- a/backend/session/src/admission_strategy.rs +++ b/backend/session/src/admission_strategy.rs @@ -1,6 +1,6 @@ use log::debug; use shared::model::AdmissionStrategy; -use std::net::SocketAddr; +use std::{net::SocketAddr, sync::Arc}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdmissionDecision { @@ -33,7 +33,11 @@ pub struct GraceResolutionContext { /// Index of the grace strategy that was actually used. pub strategy_index: usize, /// Full effective strategy list for stable reconstruction of the remaining slice. - pub strategies: Vec, + /// + /// `Arc` rather than `Vec`: this context is stored on `StreamInfo` and travels with + /// every clone of it, and the list is immutable once the effective strategies have + /// been resolved. + pub strategies: Arc<[AdmissionStrategy]>, /// The original `ConnectionKind` from the admission decision that led to this grace. /// Preserved so that the remaining-strategy fallback can return the correct kind /// (e.g., `Soft`) even when the grace itself hardcoded `Normal`. diff --git a/backend/session/src/connection_manager.rs b/backend/session/src/connection_manager.rs index 64314ef9b..21168ac3f 100644 --- a/backend/session/src/connection_manager.rs +++ b/backend/session/src/connection_manager.rs @@ -1,13 +1,13 @@ use crate::{ uses_direct_body_idle_timeout, ActiveProviderManager, ActiveUserConnectionParams, ActiveUserManager, EventManager, - EventMessage, SharedStreamManager, + SharedStreamManager, }; use arc_swap::ArcSwapOption; use log::{debug, warn}; use shared::{ model::{ - ActiveUserConnectionChange, ConnectFailureReason, CustomVideoStreamType, DisconnectReason, FailureStage, - StreamChannel, StreamInfo, VirtualId, + ActiveUserConnectionChange, ConnectFailureReason, CustomVideoStreamType, DisconnectReason, EventMessage, + FailureStage, StreamChannel, StreamInfo, VirtualId, }, utils::sanitize_sensitive_info, }; @@ -305,7 +305,9 @@ async fn release_connection_parts( } } for stream_info in &removed.removed_streams { - let (bytes_sent, first_byte_latency_ms) = deps.event_manager.read_meter_qos(stream_info.meter_uid).await; + let qos = deps.event_manager.read_meter_qos(stream_info.meter_uid).await; + let bytes_sent = qos.map(|qos| qos.bytes_total); + let first_byte_latency_ms = qos.and_then(|qos| qos.first_byte_latency_ms); deps.event_manager.unregister_meter_client(stream_info.uid).await; emit_disconnect_record( &deps.history_writer, @@ -406,7 +408,11 @@ async fn handle_update_detail_and_release_provider( if let Some(stream_info) = deps.user_manager.update_stream_detail(&addr, video_type).await { if matches!(video_type, CustomVideoStreamType::LowPriorityPreempted) { deps.user_manager - .block_user_for_stream(&addr, stream_info.channel.virtual_id, PREEMPT_REENTRY_BLOCK_SECS) + .block_user_for_stream( + &addr, + shared::model::VirtualId::new(stream_info.channel.virtual_id), + PREEMPT_REENTRY_BLOCK_SECS, + ) .await; } deps.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); @@ -425,7 +431,11 @@ async fn handle_update_detail_and_release_provider_connection( if let Some(stream_info) = deps.user_manager.update_stream_detail(&addr, video_type).await { if matches!(video_type, CustomVideoStreamType::LowPriorityPreempted) { deps.user_manager - .block_user_for_stream(&addr, stream_info.channel.virtual_id, PREEMPT_REENTRY_BLOCK_SECS) + .block_user_for_stream( + &addr, + shared::model::VirtualId::new(stream_info.channel.virtual_id), + PREEMPT_REENTRY_BLOCK_SECS, + ) .await; } deps.event_manager.send_event(EventMessage::ActiveUser(ActiveUserConnectionChange::Updated(stream_info))); @@ -436,7 +446,9 @@ async fn handle_update_detail_and_release_provider_connection( } async fn handle_adaptive_session_expired(deps: &CleanupWorkerDeps, stream_info: Box) { - let (bytes_sent, first_byte_latency_ms) = deps.event_manager.read_meter_qos(stream_info.meter_uid).await; + let qos = deps.event_manager.read_meter_qos(stream_info.meter_uid).await; + let bytes_sent = qos.map(|qos| qos.bytes_total); + let first_byte_latency_ms = qos.and_then(|qos| qos.first_byte_latency_ms); deps.event_manager.unregister_meter_client(stream_info.uid).await; emit_disconnect_record( &deps.history_writer, @@ -475,7 +487,9 @@ async fn release_stream_with_disconnect( ); return None; }; - let (bytes_sent, first_byte_latency_ms) = deps.event_manager.read_meter_qos(stream_info.meter_uid).await; + let qos = deps.event_manager.read_meter_qos(stream_info.meter_uid).await; + let bytes_sent = qos.map(|qos| qos.bytes_total); + let first_byte_latency_ms = qos.and_then(|qos| qos.first_byte_latency_ms); deps.event_manager.unregister_meter_client(stream_info.uid).await; let reason = resolve_disconnect_reason(provider_end_reason, &stream_info); let provider_reconnect_count = (reconnect_count > 0).then_some(reconnect_count); @@ -927,7 +941,9 @@ impl ConnectionManager { self.user_manager.terminate_sessions_for_addr(username, addr).await; } for stream_info in &removed.removed_streams { - let (bytes_sent, first_byte_latency_ms) = self.event_manager.read_meter_qos(stream_info.meter_uid).await; + let qos = self.event_manager.read_meter_qos(stream_info.meter_uid).await; + let bytes_sent = qos.map(|qos| qos.bytes_total); + let first_byte_latency_ms = qos.and_then(|qos| qos.first_byte_latency_ms); self.event_manager.unregister_meter_client(stream_info.uid).await; emit_disconnect_record( &self.history_writer, @@ -951,7 +967,9 @@ impl ConnectionManager { pub async fn release_stream(&self, addr: &SocketAddr) { if let Some(stream_info) = self.user_manager.release_stream(addr).await { - let (bytes_sent, first_byte_latency_ms) = self.event_manager.read_meter_qos(stream_info.meter_uid).await; + let qos = self.event_manager.read_meter_qos(stream_info.meter_uid).await; + let bytes_sent = qos.map(|qos| qos.bytes_total); + let first_byte_latency_ms = qos.and_then(|qos| qos.first_byte_latency_ms); self.event_manager.unregister_meter_client(stream_info.uid).await; emit_disconnect_record( &self.history_writer, @@ -1010,7 +1028,9 @@ impl ConnectionManager { pub async fn shutdown(&self) { let active_streams = self.user_manager.get_all_active_streams().await; for stream_info in active_streams { - let (bytes_sent, first_byte_latency_ms) = self.event_manager.read_meter_qos(stream_info.meter_uid).await; + let qos = self.event_manager.read_meter_qos(stream_info.meter_uid).await; + let bytes_sent = qos.map(|qos| qos.bytes_total); + let first_byte_latency_ms = qos.and_then(|qos| qos.first_byte_latency_ms); emit_disconnect_record( &self.history_writer, &stream_info, @@ -1419,7 +1439,7 @@ mod tests { let mut rx = manager.get_close_connection_channel(); let addr: SocketAddr = "127.0.0.1:1234".parse().unwrap_or_else(|_| unreachable!()); - assert!(manager.kick_connection(&addr, 1, 0).await); + assert!(manager.kick_connection(&addr, shared::model::VirtualId::new(1), 0).await); assert_eq!(rx.recv().await.ok(), Some(CloseConnectionSignal::WithReason(addr, DisconnectReason::ClientKicked))); } @@ -1452,7 +1472,16 @@ mod tests { let mut rx = manager.get_close_connection_channel(); let addr: SocketAddr = "127.0.0.1:1234".parse().unwrap_or_else(|_| unreachable!()); - assert!(manager.close_connection_with_reason_and_block(&addr, 7, 0, DisconnectReason::Provisioning).await); + assert!( + manager + .close_connection_with_reason_and_block( + &addr, + shared::model::VirtualId::new(7), + 0, + DisconnectReason::Provisioning + ) + .await + ); assert_eq!(rx.recv().await.ok(), Some(CloseConnectionSignal::WithReason(addr, DisconnectReason::Provisioning))); } @@ -1514,7 +1543,10 @@ mod tests { handle_update_detail_and_release_provider(&deps, addr, CustomVideoStreamType::LowPriorityPreempted, None).await; assert!( - manager.user_manager.is_user_blocked_for_stream(&user.username, channel.virtual_id).await, + manager + .user_manager + .is_user_blocked_for_stream(&user.username, shared::model::VirtualId::new(channel.virtual_id)) + .await, "preempted playback should be blocked briefly to prevent immediate reconnect ping-pong" ); } @@ -1577,7 +1609,10 @@ mod tests { handle_update_detail_and_release_provider(&deps, addr, CustomVideoStreamType::LowPriorityPreempted, None).await; assert!( - manager.user_manager.is_user_blocked_for_stream(&user.username, channel.virtual_id).await, + manager + .user_manager + .is_user_blocked_for_stream(&user.username, shared::model::VirtualId::new(channel.virtual_id)) + .await, "preempted HLS playback should be blocked briefly to prevent immediate reconnect ping-pong" ); } diff --git a/backend/session/src/event_manager.rs b/backend/session/src/event_manager.rs index 2ac57c05f..9fe19b43c 100644 --- a/backend/session/src/event_manager.rs +++ b/backend/session/src/event_manager.rs @@ -1,329 +1,390 @@ -use crate::{MeterReading, StreamMeterHandle}; -use log::trace; -use shared::model::{ - ActiveUserConnectionChange, ConfigType, DownloadsDelta, DownloadsResponse, LibraryScanProgressEvent, - PlaylistUpdateProgressEvent, PlaylistUpdateState, StreamMeterEntry, SystemInfo, +//! The in-process event bus. +//! +//! A `broadcast::Sender` and the subscription helpers around +//! it. Stream metering used to live in this struct too - a second broadcast +//! channel, a registry, a subscriber counter and a background sampler, none +//! of which any event subscriber ever touched. That is +//! [`StreamMeterRegistry`] now; `EventManager` owns one so the composition +//! root still constructs a single handle. + +use crate::{ + meter_registry::{MeterQos, StreamMeterRegistry}, + StreamMeterHandle, }; +use log::trace; +use shared::model::{EventKind, EventKindMask, EventMessage, EventSink, StreamMeterEntry}; use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, + atomic::{AtomicU64, Ordering}, + Arc, Mutex, }, time::Duration, }; -use tokio::sync::RwLock; -use tokio_util::sync::CancellationToken; +use tokio::time::Instant; -const STREAM_METER_INTERVAL: Duration = Duration::from_secs(3); -const STREAM_METER_INTERVAL_SECS: u64 = STREAM_METER_INTERVAL.as_secs(); +/// Buffer depth used when no configuration is available - tests, and the +/// composition root before the config is resolved. +pub const DEFAULT_EVENT_CHANNEL_CAPACITY: usize = 256; -#[allow(clippy::large_enum_variant)] -#[derive(Clone, Debug, PartialEq)] -pub enum EventMessage { - ServerError(String), - ActiveUser(ActiveUserConnectionChange), - ActiveProvider(Arc, usize), - ConfigChange(ConfigType), - PlaylistUpdate(PlaylistUpdateState), - PlaylistUpdateProgress(PlaylistUpdateProgressEvent), - SystemInfoUpdate(SystemInfo), - LibraryScanProgress(LibraryScanProgressEvent), - DownloadsUpdate(DownloadsResponse), - DownloadsDeltaUpdate(DownloadsDelta), - RecordingChanged, - RecordingRulesChanged, - InputMetadataUpdatesCompleted(Arc), - InputMetadataUpdatesStarted(Arc), +/// How many recent events the bus keeps for inspection. +/// +/// Small and fixed: this is a debugging aid answering "did that event +/// actually fire", not an audit log. +pub const RECENT_EVENT_CAPACITY: usize = 256; + +/// How long a coalescable nudge suppresses an identical one. +/// +/// Short enough that a user action always produces a visible refresh, long +/// enough to absorb the back-to-back pairs the recording routes emit. +pub const COALESCE_WINDOW: Duration = Duration::from_millis(250); + +/// What became of one `emit`. +/// +/// `send_event` used to return a `bool`, which conflated "nothing was +/// listening" with "this was deliberately suppressed" and told a caller +/// nothing either way - so ~80 call sites discarded it. Modelled on the +/// messaging crate's `Delivery`, which drew the same distinction for +/// notification channels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EmitOutcome { + /// Buffered for every current subscriber. + Delivered { receivers: usize }, + /// Published with nothing subscribed. Normal - the Web UI is usually + /// closed - and not an error. + NoSubscribers, + /// An identical coalescable nudge is already in flight; see + /// [`EventKind::is_coalescable`]. + Coalesced, +} + +impl EmitOutcome { + /// Did this reach at least one subscriber? + #[must_use] + pub const fn is_delivered(self) -> bool { matches!(self, Self::Delivered { .. }) } +} + +/// What the bus has carried, and what it has dropped. +/// +/// `send_event` returned a `bool` that ~80 call sites discarded with +/// `let _ =`, so an event nobody received and an event nobody was listening +/// for were equally invisible. Counting here rather than at the call sites +/// means one place to read, and the plugin system's promised drop counters +/// have somewhere to come from. +#[derive(Debug)] +pub struct EventBusStats { + emitted: [AtomicU64; EventKind::ALL.len()], + no_subscribers: AtomicU64, + lagged: AtomicU64, + coalesced: AtomicU64, +} + +/// Hand-written because `Default` for arrays stops at 32 elements, and the +/// taxonomy passed that. `from_fn` has no such limit and keeps the counter +/// array indexed by `EventKind` rather than boxed or `Vec`-backed. +impl Default for EventBusStats { + fn default() -> Self { + Self { + emitted: std::array::from_fn(|_| AtomicU64::new(0)), + no_subscribers: AtomicU64::new(0), + lagged: AtomicU64::new(0), + coalesced: AtomicU64::new(0), + } + } +} + +impl EventBusStats { + fn record_emit(&self, kind: EventKind, delivered: bool) { + self.emitted[kind as usize].fetch_add(1, Ordering::Relaxed); + if !delivered { + self.no_subscribers.fetch_add(1, Ordering::Relaxed); + } + } + + fn record_coalesced(&self, _kind: EventKind) { self.coalesced.fetch_add(1, Ordering::Relaxed); } + + /// Nudges suppressed because an identical one had just gone out. + #[must_use] + pub fn coalesced(&self) -> u64 { self.coalesced.load(Ordering::Relaxed) } + + /// A subscriber reporting the gap it was told about. + /// + /// Subscribers call this from their `Lagged` arm; the send side cannot + /// see a lag, because a broadcast channel drops for the slow receiver + /// and not for the sender. + pub fn record_lag(&self, skipped: u64) { self.lagged.fetch_add(skipped, Ordering::Relaxed); } + + /// Events published, per kind, since start. + #[must_use] + pub fn emitted(&self) -> Vec<(EventKind, u64)> { + EventKind::ALL.into_iter().map(|kind| (kind, self.emitted[kind as usize].load(Ordering::Relaxed))).collect() + } + + /// Events published while nothing was subscribed. Not a fault - the Web + /// UI is usually closed - but a non-zero count beside "I never saw that + /// notification" is the answer. + #[must_use] + pub fn no_subscribers(&self) -> u64 { self.no_subscribers.load(Ordering::Relaxed) } + + /// Events a subscriber was told it had missed. Non-zero means the buffer + /// is too shallow for the emit rate, or a subscriber is too slow. + #[must_use] + pub fn lagged(&self) -> u64 { self.lagged.load(Ordering::Relaxed) } + + /// Total across all kinds. + #[must_use] + pub fn total_emitted(&self) -> u64 { self.emitted.iter().map(|c| c.load(Ordering::Relaxed)).sum() } +} + +/// Take a bus lock, ignoring poisoning. +/// +/// Every critical section here is a map or deque operation that cannot +/// panic, so a poisoned lock can only mean a panic elsewhere in the process. +/// Refusing to publish events after that would turn one unrelated panic into +/// a silently dead event bus. +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } pub struct EventManager { channel_tx: tokio::sync::broadcast::Sender, - meter_channel_tx: tokio::sync::broadcast::Sender>, - meter_registry: Arc>, - stream_meter_subscriber_count: Arc, - meter_sampler_cancel: CancellationToken, + meters: StreamMeterRegistry, + stats: Arc, + /// Last time each coalescable kind was published. A blocking mutex + /// rather than an async one: `emit` is synchronous by contract, and the + /// critical section is a map lookup with no await inside it. + last_nudge: Mutex>, + /// The newest message of each latched kind, for subscribers that connect + /// after it was published. + latched: Mutex>, + /// The last [`RECENT_EVENT_CAPACITY`] events, newest last. + recent: Mutex>, + started: Instant, } -#[derive(Debug, Default)] -struct MeterRegistry { - meters: HashMap>, - meter_to_clients: HashMap>, - client_to_meter: HashMap, +/// One entry in the bus's recent-event ring. +#[derive(Debug, Clone)] +pub struct RecordedEvent { + pub kind: EventKind, + /// Milliseconds since the process started. Monotonic, so it stays + /// ordered across a wall-clock adjustment. + pub uptime_millis: u64, + pub outcome: EmitOutcome, } impl EventManager { - pub fn new() -> Self { - let (channel_tx, _channel_rx) = tokio::sync::broadcast::channel(10); - let (meter_channel_tx, _meter_channel_rx) = tokio::sync::broadcast::channel(10); - let meter_registry = Arc::new(RwLock::new(MeterRegistry::default())); - let stream_meter_subscriber_count = Arc::new(AtomicUsize::new(0)); - let meter_sampler_cancel = CancellationToken::new(); + #[must_use] + pub fn new() -> Self { Self::with_capacity(DEFAULT_EVENT_CHANNEL_CAPACITY) } - Self::spawn_meter_sampler( - meter_channel_tx.clone(), - Arc::clone(&meter_registry), - Arc::clone(&stream_meter_subscriber_count), - meter_sampler_cancel.clone(), - ); - - Self { channel_tx, meter_channel_tx, meter_registry, stream_meter_subscriber_count, meter_sampler_cancel } - } - - fn spawn_meter_sampler( - meter_channel_tx: tokio::sync::broadcast::Sender>, - meter_registry: Arc>, - stream_meter_subscriber_count: Arc, - cancel_token: CancellationToken, - ) { - if tokio::runtime::Handle::try_current().is_err() { - return; + /// A bus buffering `capacity` events per subscriber. + /// + /// Capacity 10 - the old fixed value - was routinely outrun: a playlist + /// refresh emits progress ticks in a loop, and any subscriber that + /// awaits I/O per event falls behind within one target. Both the + /// notification bridge's `Lagged` arm and the websocket's resync path + /// exist because of it. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + let (channel_tx, _channel_rx) = tokio::sync::broadcast::channel(capacity.max(1)); + Self { + channel_tx, + meters: StreamMeterRegistry::new(), + stats: Arc::new(EventBusStats::default()), + last_nudge: Mutex::new(HashMap::new()), + latched: Mutex::new(HashMap::new()), + recent: Mutex::new(VecDeque::with_capacity(RECENT_EVENT_CAPACITY)), + started: Instant::now(), } - tokio::spawn(async move { - let mut interval = tokio::time::interval(STREAM_METER_INTERVAL); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - loop { - tokio::select! { - () = cancel_token.cancelled() => break, - _ = interval.tick() => {} - } - - if stream_meter_subscriber_count.load(Ordering::Relaxed) == 0 { - continue; - } - - if meter_channel_tx.receiver_count() == 0 { - continue; - } - - let entries = sample_meter_entries(&meter_registry).await; - if !entries.is_empty() && meter_channel_tx.send(entries).is_err() { - trace!("Failed to send stream meter batch"); - } - } - }); } + /// Counters for what this bus has carried. + #[must_use] + pub fn stats(&self) -> &Arc { &self.stats } + + /// The metering subsystem. + #[must_use] + pub fn meters(&self) -> &StreamMeterRegistry { &self.meters } + pub fn get_event_channel(&self) -> tokio::sync::broadcast::Receiver { self.channel_tx.subscribe() } - pub fn get_meter_channel(&self) -> tokio::sync::broadcast::Receiver> { - self.meter_channel_tx.subscribe() + /// Subscribe to `mask` only. + /// + /// The filtering still happens receiver-side - a broadcast channel has + /// one buffer for everyone, so there is nowhere else to put it - but it + /// happens before the subscriber's own work, and a subscriber that wants + /// two kinds no longer wakes for the other twelve. `Lagged` is still + /// reported: a gap the subscriber did not want to know about is still a + /// gap in the ones it did. + pub fn subscribe_filtered(&self, mask: EventKindMask) -> FilteredEventReceiver { + FilteredEventReceiver { inner: self.channel_tx.subscribe(), mask } } - pub fn send_event(&self, event: EventMessage) -> bool { - if let Err(err) = self.channel_tx.send(event) { - trace!("Failed to send event: {err}"); - false - } else { - true - } - } - - pub fn send_provider_event(&self, provider: &Arc, connection_count: usize) { - if !self.send_event(EventMessage::ActiveProvider(Arc::clone(provider), connection_count)) { - trace!("Failed to send connection change: {provider}: {connection_count}"); - } - } - - pub fn send_system_info(&self, system_info: SystemInfo) { - if !self.send_event(EventMessage::SystemInfoUpdate(system_info)) { - trace!("Failed to send system info"); + /// Publish. + /// + /// Never blocks and never fails from the emitter's point of view: every + /// outcome, including "suppressed" and "nobody listening", is a normal + /// result the caller may ignore. + pub fn send_event(&self, event: EventMessage) -> EmitOutcome { + let kind = event.kind(); + + if kind.is_coalescable() && !self.admit_nudge(kind) { + self.stats.record_coalesced(kind); + // Recorded, not skipped: "it fired but was suppressed" is + // precisely what the recent-event ring is consulted to find out. + self.record_recent(kind, EmitOutcome::Coalesced); + return EmitOutcome::Coalesced; + } + + if kind.is_latched() { + lock(&self.latched).insert(kind, event.clone()); + } + + let outcome = match self.channel_tx.send(event) { + Ok(receivers) => EmitOutcome::Delivered { receivers }, + Err(err) => { + trace!("Failed to send event: {err}"); + EmitOutcome::NoSubscribers + } + }; + self.stats.record_emit(kind, outcome.is_delivered()); + self.record_recent(kind, outcome); + outcome + } + + fn record_recent(&self, kind: EventKind, outcome: EmitOutcome) { + let uptime_millis = u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX); + let mut recent = lock(&self.recent); + if recent.len() == RECENT_EVENT_CAPACITY { + recent.pop_front(); + } + recent.push_back(RecordedEvent { kind, uptime_millis, outcome }); + } + + /// The newest message of each latched kind. + /// + /// A session that connects between two `SystemInfo` samples used to show + /// empty panels until the next one arrived, and the websocket's + /// resync-on-lag path re-requested a status snapshot for the same + /// reason. Both are answered by handing over what the bus already has. + #[must_use] + pub fn snapshot(&self) -> Vec { lock(&self.latched).values().cloned().collect() } + + /// The last [`RECENT_EVENT_CAPACITY`] events, oldest first. + /// + /// "Why did my notification not fire?" is otherwise unanswerable without + /// a debug build: this shows whether the event was published at all, and + /// whether anything was subscribed when it was. + #[must_use] + pub fn recent_events(&self) -> Vec { lock(&self.recent).iter().cloned().collect() } + + /// Should this nudge go out, or has an identical one just gone? + /// + /// Records the send time on admission, so a steady stream of nudges is + /// throttled to one per window rather than one per burst. + fn admit_nudge(&self, kind: EventKind) -> bool { + let now = Instant::now(); + let mut last = lock(&self.last_nudge); + match last.get(&kind) { + Some(previous) if now.duration_since(*previous) < COALESCE_WINDOW => false, + _ => { + last.insert(kind, now); + true + } } } + #[must_use] pub fn has_event_receivers(&self) -> bool { self.channel_tx.receiver_count() > 0 } - pub fn has_meter_event_receivers(&self) -> bool { self.meter_channel_tx.receiver_count() > 0 } + // --- metering, forwarded ------------------------------------------------ + // + // Kept as inherent methods because the call sites reach the bus through + // `AppState.event_manager` and a stream has no reason to know that + // metering is a separate component. `meters()` is there for anything that + // wants the registry itself. - pub fn stream_meter_subscriber_connected(&self) { - self.stream_meter_subscriber_count.fetch_add(1, Ordering::Relaxed); + #[must_use] + pub fn get_meter_channel(&self) -> tokio::sync::broadcast::Receiver> { + self.meters.subscribe() } - pub fn stream_meter_subscriber_disconnected(&self) { - let _ = self - .stream_meter_subscriber_count - .try_update(Ordering::AcqRel, Ordering::Relaxed, |count| count.checked_sub(1)); - } + #[must_use] + pub fn has_meter_event_receivers(&self) -> bool { self.meters.has_receivers() } - pub fn has_stream_meter_subscribers(&self) -> bool { - self.stream_meter_subscriber_count.load(Ordering::Relaxed) > 0 - } + pub fn stream_meter_subscriber_connected(&self) { self.meters.subscriber_connected(); } - pub async fn register_meter(&self, meter: Arc) { - let meter_uid = meter.meter_uid(); - if meter_uid == 0 { - return; - } + pub fn stream_meter_subscriber_disconnected(&self) { self.meters.subscriber_disconnected(); } - self.meter_registry.write().await.meters.insert(meter_uid, meter); - } + #[must_use] + pub fn has_stream_meter_subscribers(&self) -> bool { self.meters.has_subscribers() } - pub async fn unregister_meter(&self, meter_uid: u32) { - if meter_uid == 0 { - return; - } + pub async fn register_meter(&self, meter: Arc) { self.meters.register_meter(meter).await; } - let mut registry = self.meter_registry.write().await; - registry.meters.remove(&meter_uid); - if let Some(client_uids) = registry.meter_to_clients.remove(&meter_uid) { - for client_uid in client_uids { - registry.client_to_meter.remove(&client_uid); - } - } - } + pub async fn unregister_meter(&self, meter_uid: u32) { self.meters.unregister_meter(meter_uid).await; } pub async fn flush_and_unregister_meter(&self, meter_uid: u32) { - if meter_uid == 0 { - return; - } - - let final_entry = { - let mut registry = self.meter_registry.write().await; - let meter = registry.meters.remove(&meter_uid); - let client_uids = registry.meter_to_clients.remove(&meter_uid).unwrap_or_default(); - - for client_uid in &client_uids { - registry.client_to_meter.remove(client_uid); - } - - meter.and_then(|meter| build_meter_entry(meter.snapshot(), client_uids)) - }; - - if let Some(entry) = final_entry { - self.send_meter_batch(vec![entry]); - } + self.meters.flush_and_unregister_meter(meter_uid).await; } pub async fn register_meter_client(&self, client_uid: u32, meter_uid: u32) { - if client_uid == 0 || meter_uid == 0 { - return; - } - - let carried_entry = { - let mut registry = self.meter_registry.write().await; - let mut carried_entry = None; - - if let Some(old_meter_uid) = registry.client_to_meter.insert(client_uid, meter_uid) { - if old_meter_uid != meter_uid { - let previous_client_uids = - registry.meter_to_clients.get(&old_meter_uid).cloned().unwrap_or_default(); - if previous_client_uids.len() == 1 && previous_client_uids[0] == client_uid { - carried_entry = registry - .meters - .get(&old_meter_uid) - .and_then(|meter| build_meter_entry(meter.snapshot(), vec![client_uid])); - } - } - - if let Some(client_uids) = registry.meter_to_clients.get_mut(&old_meter_uid) { - client_uids.retain(|uid| *uid != client_uid); - if client_uids.is_empty() { - registry.meter_to_clients.remove(&old_meter_uid); - } - } - } - - let client_uids = registry.meter_to_clients.entry(meter_uid).or_default(); - if !client_uids.contains(&client_uid) { - client_uids.push(client_uid); - } - - carried_entry - }; - - if let Some(entry) = carried_entry { - self.send_meter_batch(vec![entry]); - } - } - - /// Read `QoS` metrics (`bytes_total`, `first_byte_latency`) for a meter without removing it. - /// Returns `(None, None)` when the meter is shared by multiple clients, because the - /// meter-wide totals would be incorrect for any individual session. - pub async fn read_meter_qos(&self, meter_uid: u32) -> (Option, Option) { - if meter_uid == 0 { - return (None, None); - } - let registry = self.meter_registry.read().await; - // Shared meters serve multiple clients — their totals are not per-session. - if registry.meter_to_clients.get(&meter_uid).is_some_and(|clients| clients.len() > 1) { - return (None, None); - } - match registry.meters.get(&meter_uid) { - Some(m) => (Some(m.bytes_total()), m.first_byte_latency_ms()), - None => (None, None), - } + self.meters.register_meter_client(client_uid, meter_uid).await; } pub async fn unregister_meter_client(&self, client_uid: u32) { - if client_uid == 0 { - return; - } + self.meters.unregister_meter_client(client_uid).await; + } - let mut registry = self.meter_registry.write().await; - if let Some(meter_uid) = registry.client_to_meter.remove(&client_uid) { - if let Some(client_uids) = registry.meter_to_clients.get_mut(&meter_uid) { - client_uids.retain(|uid| *uid != client_uid); - if client_uids.is_empty() { - registry.meter_to_clients.remove(&meter_uid); - } + pub async fn read_meter_qos(&self, meter_uid: u32) -> Option { self.meters.read_qos(meter_uid).await } + + pub fn send_meter_batch(&self, entries: Vec) { self.meters.send_batch(entries); } +} + +/// An [`EventMessage`] subscription narrowed to some [`EventKindMask`]. +/// +/// A concrete type rather than a `Stream` adaptor: the call site is a +/// `tokio::select!` arm awaiting `recv()`, which is what this offers, and a +/// boxed stream would put a virtual call on every event. +pub struct FilteredEventReceiver { + inner: tokio::sync::broadcast::Receiver, + mask: EventKindMask, +} + +impl FilteredEventReceiver { + /// The next event this subscription asked for. + /// + /// Errors pass through unchanged, so `Lagged` and `Closed` are handled + /// exactly as they are on a raw receiver. + pub async fn recv(&mut self) -> Result { + loop { + let event = self.inner.recv().await?; + if self.mask.contains(event.kind()) { + return Ok(event); } } } - pub fn send_meter_batch(&self, entries: Vec) { - if !entries.is_empty() { - let _ = self.meter_channel_tx.send(entries); - } - } + /// The mask this subscription was opened with. + #[must_use] + pub fn mask(&self) -> EventKindMask { self.mask } } -async fn sample_meter_entries(meter_registry: &RwLock) -> Vec { - let samples = { - let registry = meter_registry.read().await; - if registry.meters.is_empty() || registry.meter_to_clients.is_empty() { - return Vec::new(); - } - - registry - .meters - .iter() - .filter_map(|(meter_uid, meter)| { - registry.meter_to_clients.get(meter_uid).filter(|uids| !uids.is_empty()).map(|uids| { - let reading = meter.snapshot(); - (*meter_uid, reading, uids.clone()) - }) - }) - .collect::>() - }; - - if samples.is_empty() { - return Vec::new(); - } - - samples.into_iter().filter_map(|(_meter_uid, reading, uids)| build_meter_entry(reading, uids)).collect() +impl EventManager { + /// Stop background work, flushing what is pending. + /// + /// The event channel needs nothing: closing the sender is what tells + /// subscribers to stop, and `Drop` does that. The meter registry does - + /// see [`StreamMeterRegistry::shutdown`]. + pub async fn shutdown(&self) { self.meters.shutdown().await; } } -fn build_meter_entry(reading: MeterReading, uids: Vec) -> Option { - if uids.is_empty() { - return None; - } - - let rate_kbps_u64 = reading.bytes_window / 1024 / STREAM_METER_INTERVAL_SECS; - let rate_kbps = u32::try_from(rate_kbps_u64).unwrap_or(u32::MAX); - let total_kb = u32::try_from(reading.bytes_total / 1024).unwrap_or(u32::MAX); - - Some(StreamMeterEntry { meter_uid: reading.meter_uid, uids, rate_kbps, total_kb }) +impl EventSink for EventManager { + fn emit(&self, event: EventMessage) { self.send_event(event); } } impl Default for EventManager { fn default() -> Self { Self::new() } } -impl Drop for EventManager { - fn drop(&mut self) { self.meter_sampler_cancel.cancel(); } -} - #[cfg(test)] mod tests { use super::EventManager; @@ -454,3 +515,213 @@ mod tests { assert!(meter_events.try_recv().is_err(), "shared meters must not emit carried totals during reassignment"); } } + +#[cfg(test)] +mod bus_stats_tests { + use super::{EventManager, DEFAULT_EVENT_CHANNEL_CAPACITY}; + use shared::model::{ConfigType, EventKind, EventMessage}; + + #[test] + fn emitting_with_no_subscribers_is_counted_not_silent() { + let manager = EventManager::new(); + manager.send_event(EventMessage::RecordingChanged); + manager.send_event(EventMessage::ConfigChange(ConfigType::Config)); + + let stats = manager.stats(); + assert_eq!(stats.total_emitted(), 2); + assert_eq!(stats.no_subscribers(), 2, "nothing was listening, and that is now visible"); + } + + #[tokio::test] + async fn a_delivered_event_does_not_count_as_dropped() { + let manager = EventManager::new(); + let _rx = manager.get_event_channel(); + manager.send_event(EventMessage::RecordingChanged); + + assert_eq!(manager.stats().no_subscribers(), 0); + let per_kind = manager.stats().emitted(); + let recording = per_kind.iter().find(|(kind, _)| *kind == EventKind::RecordingChanged).map(|(_, n)| *n); + assert_eq!(recording, Some(1)); + } + + #[tokio::test] + async fn capacity_is_deep_enough_that_a_refresh_burst_does_not_lag_a_subscriber() { + // The old fixed capacity was 10; a playlist refresh emits progress + // ticks in a loop and outran it within one target. + let manager = EventManager::new(); + let mut rx = manager.get_event_channel(); + for _ in 0..DEFAULT_EVENT_CHANNEL_CAPACITY { + manager.send_event(EventMessage::RecordingChanged); + } + assert!(rx.try_recv().is_ok(), "a full buffer must not have already dropped the oldest event"); + } + + #[test] + fn zero_capacity_is_clamped_rather_than_panicking() { + let manager = EventManager::with_capacity(0); + manager.send_event(EventMessage::RecordingChanged); + assert_eq!(manager.stats().total_emitted(), 1); + } +} + +#[cfg(test)] +mod coalescing_tests { + use super::{EmitOutcome, EventManager, COALESCE_WINDOW}; + use shared::model::{EventKind, EventMessage, PlaylistUpdateProgressEvent}; + + #[tokio::test(start_paused = true)] + async fn a_back_to_back_recording_nudge_is_suppressed() { + // Deleting a recording emits `RecordingChanged` and then + // `RecordingRulesChanged`; a bulk delete emits one pair per item. + // Every one of them makes the Web UI re-fetch the same snapshot. + let manager = EventManager::new(); + let _rx = manager.get_event_channel(); + + assert!(manager.send_event(EventMessage::RecordingChanged).is_delivered()); + assert_eq!(manager.send_event(EventMessage::RecordingChanged), EmitOutcome::Coalesced); + assert_eq!(manager.send_event(EventMessage::RecordingChanged), EmitOutcome::Coalesced); + + assert_eq!(manager.stats().coalesced(), 2); + } + + #[tokio::test(start_paused = true)] + async fn the_two_recording_nudges_do_not_suppress_each_other() { + let manager = EventManager::new(); + let _rx = manager.get_event_channel(); + + assert!(manager.send_event(EventMessage::RecordingChanged).is_delivered()); + assert!( + manager.send_event(EventMessage::RecordingRulesChanged).is_delivered(), + "different kinds are different news" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_nudge_after_the_window_gets_through() { + let manager = EventManager::new(); + let _rx = manager.get_event_channel(); + + assert!(manager.send_event(EventMessage::RecordingChanged).is_delivered()); + tokio::time::advance(COALESCE_WINDOW + std::time::Duration::from_millis(1)).await; + assert!( + manager.send_event(EventMessage::RecordingChanged).is_delivered(), + "a later user action must always produce a visible refresh" + ); + } + + #[tokio::test(start_paused = true)] + async fn events_carrying_a_payload_are_never_coalesced() { + let manager = EventManager::new(); + let _rx = manager.get_event_channel(); + + for _ in 0..5 { + let outcome = manager.send_event(EventMessage::PlaylistUpdateProgress(PlaylistUpdateProgressEvent { + target: "t".to_string(), + message: "m".to_string(), + })); + assert!(outcome.is_delivered(), "dropping a progress tick would lose the message it carried"); + } + assert_eq!(manager.stats().coalesced(), 0); + } + + #[test] + fn only_payload_free_nudges_are_coalescable() { + for kind in EventKind::ALL { + let expected = matches!(kind, EventKind::RecordingChanged | EventKind::RecordingRulesChanged); + assert_eq!(kind.is_coalescable(), expected, "{kind:?}"); + } + } +} + +#[cfg(test)] +mod snapshot_and_ring_tests { + use super::{EmitOutcome, EventManager, RECENT_EVENT_CAPACITY}; + use shared::model::{ConfigType, EventKind, EventMessage, SystemInfo}; + use std::sync::Arc; + + fn system_info() -> Arc { + Arc::new(SystemInfo { + cpu_usage: 1.0, + memory_usage: 2, + memory_total: 3, + net_rx_bytes_per_sec: 0.0, + net_tx_bytes_per_sec: 0.0, + net_rx_bytes_total: 0, + net_tx_bytes_total: 0, + disk_total_bytes: 0, + disk_free_bytes: 0, + }) + } + + #[test] + fn a_late_subscriber_can_be_handed_the_latest_state() { + // The Web UI used to show empty panels until the next three-second + // sample arrived. + let manager = EventManager::new(); + manager.send_event(EventMessage::SystemInfoUpdate(system_info())); + manager.send_event(EventMessage::ActiveProvider("p".into(), 4)); + + let snapshot = manager.snapshot(); + assert_eq!(snapshot.len(), 2); + assert!(snapshot.iter().any(|event| event.kind() == EventKind::SystemInfoUpdate)); + assert!(snapshot.iter().any(|event| matches!(event, EventMessage::ActiveProvider(_, 4)))); + } + + #[test] + fn only_the_newest_message_of_a_latched_kind_is_kept() { + let manager = EventManager::new(); + manager.send_event(EventMessage::ActiveProvider("p".into(), 1)); + manager.send_event(EventMessage::ActiveProvider("p".into(), 9)); + + let snapshot = manager.snapshot(); + assert_eq!(snapshot.len(), 1, "a latched kind is state, not a log"); + assert!(matches!(snapshot[0], EventMessage::ActiveProvider(_, 9))); + } + + #[test] + fn occurrences_are_never_latched() { + // Replaying "a playlist update finished" to a session that was not + // there when it happened would be a lie. + let manager = EventManager::new(); + manager.send_event(EventMessage::ConfigChange(ConfigType::Config)); + manager.send_event(EventMessage::RecordingChanged); + + assert!(manager.snapshot().is_empty()); + } + + #[test] + fn the_recent_ring_records_outcomes_and_stays_bounded() { + let manager = EventManager::new(); + for _ in 0..(RECENT_EVENT_CAPACITY + 10) { + manager.send_event(EventMessage::ConfigChange(ConfigType::Config)); + } + + let recent = manager.recent_events(); + assert_eq!(recent.len(), RECENT_EVENT_CAPACITY, "the ring must not grow for the process lifetime"); + assert!(recent.iter().all(|entry| entry.kind == EventKind::ConfigChange)); + assert!( + recent.iter().all(|entry| entry.outcome == EmitOutcome::NoSubscribers), + "nothing was subscribed, and the ring says so" + ); + } + + #[tokio::test(start_paused = true)] + async fn the_ring_shows_a_suppressed_nudge_as_coalesced() { + let manager = EventManager::new(); + manager.send_event(EventMessage::RecordingChanged); + manager.send_event(EventMessage::RecordingChanged); + + let recent = manager.recent_events(); + assert_eq!(recent.len(), 2, "a suppressed event is still worth showing - that is the question being asked"); + assert_eq!(recent[1].outcome, EmitOutcome::Coalesced); + } + + #[test] + fn only_state_describing_kinds_are_latched() { + for kind in EventKind::ALL { + let expected = + matches!(kind, EventKind::SystemInfoUpdate | EventKind::DownloadsUpdate | EventKind::ActiveProvider); + assert_eq!(kind.is_latched(), expected, "{kind:?}"); + } + } +} diff --git a/backend/session/src/lib.rs b/backend/session/src/lib.rs index 427f2dddf..7eb9f7808 100644 --- a/backend/session/src/lib.rs +++ b/backend/session/src/lib.rs @@ -24,6 +24,7 @@ pub mod admission_strategy; pub mod connection_manager; pub mod event_manager; pub mod meter; +pub mod meter_registry; pub mod provider_dns_manager; pub mod provider_lineup_manager; pub mod qos_aggregation_manager; @@ -35,5 +36,5 @@ pub mod streams; pub use self::{ active_provider_manager::*, active_user_manager::*, admission::*, admission_strategy::*, connection_manager::*, - event_manager::*, meter::*, provider_lineup_manager::*, stream::*, streams::*, + event_manager::*, meter::*, meter_registry::*, provider_lineup_manager::*, stream::*, streams::*, }; diff --git a/backend/session/src/meter_registry.rs b/backend/session/src/meter_registry.rs new file mode 100644 index 000000000..5674397be --- /dev/null +++ b/backend/session/src/meter_registry.rs @@ -0,0 +1,327 @@ +//! Per-stream throughput metering: the registry, its sampler, and the batch +//! channel the Web UI reads. +//! +//! This used to live inside `EventManager`, which meant one struct owned two +//! unrelated things: a pub/sub bus for fourteen event kinds, and a metering +//! subsystem with its own broadcast channel, its own subscriber counter, its +//! own background sampler task and three mutually-dependent maps. Nothing +//! outside metering ever touched the second half. + +use crate::{MeterReading, StreamMeterHandle}; +use log::trace; +use shared::model::StreamMeterEntry; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; +use tokio::sync::RwLock; +use tokio_util::sync::CancellationToken; + +pub const STREAM_METER_INTERVAL: Duration = Duration::from_secs(3); +const STREAM_METER_INTERVAL_SECS: u64 = STREAM_METER_INTERVAL.as_secs(); + +/// What is known about one meter uid. +/// +/// The two halves arrive independently and in either order - a client can be +/// assigned to a meter before the stream that owns it has registered - which +/// is why the handle is optional rather than the slot being created with one. +/// Previously this was three maps (`meters`, `meter_to_clients`, +/// `client_to_meter`) whose agreement was re-established by hand in four +/// separate methods. +#[derive(Debug, Default)] +struct MeterSlot { + handle: Option>, + clients: Vec, +} + +impl MeterSlot { + /// A slot with neither a meter nor a viewer is not tracking anything and + /// must not be left behind, or the map grows for the process lifetime. + fn is_vacant(&self) -> bool { self.handle.is_none() && self.clients.is_empty() } +} + +#[derive(Debug, Default)] +struct MeterSlots { + by_meter: HashMap, + client_to_meter: HashMap, +} + +impl MeterSlots { + /// Detach `client_uid` from whatever meter holds it, returning that meter. + /// + /// The one place the client index and the slot list are kept in step, so + /// that "remove from the vec, drop the slot if it emptied, drop the index + /// entry" cannot be spelled four subtly different ways. + fn detach_client(&mut self, client_uid: u32) -> Option { + let meter_uid = self.client_to_meter.remove(&client_uid)?; + if let Some(slot) = self.by_meter.get_mut(&meter_uid) { + slot.clients.retain(|uid| *uid != client_uid); + if slot.is_vacant() { + self.by_meter.remove(&meter_uid); + } + } + Some(meter_uid) + } + + /// Drop a meter and every client index pointing at it. + fn remove_meter(&mut self, meter_uid: u32) -> Option { + let slot = self.by_meter.remove(&meter_uid)?; + for client_uid in &slot.clients { + self.client_to_meter.remove(client_uid); + } + Some(slot) + } +} + +/// The live meters, who is watching each of them, and the sampler that turns +/// that into a batch every [`STREAM_METER_INTERVAL`]. +pub struct StreamMeterRegistry { + channel_tx: tokio::sync::broadcast::Sender>, + slots: Arc>, + subscriber_count: Arc, + sampler_cancel: CancellationToken, +} + +impl StreamMeterRegistry { + #[must_use] + pub fn new() -> Self { + let (channel_tx, _rx) = tokio::sync::broadcast::channel(10); + let slots = Arc::new(RwLock::new(MeterSlots::default())); + let subscriber_count = Arc::new(AtomicUsize::new(0)); + let sampler_cancel = CancellationToken::new(); + + Self::spawn_sampler( + channel_tx.clone(), + Arc::clone(&slots), + Arc::clone(&subscriber_count), + sampler_cancel.clone(), + ); + + Self { channel_tx, slots, subscriber_count, sampler_cancel } + } + + fn spawn_sampler( + channel_tx: tokio::sync::broadcast::Sender>, + slots: Arc>, + subscriber_count: Arc, + cancel_token: CancellationToken, + ) { + if tokio::runtime::Handle::try_current().is_err() { + // Constructed outside a runtime - a unit test, or the composition + // root before the runtime starts. Metering is inert rather than + // broken, but silence here reads as "metering does not work", so + // say so once. + log::debug!("Stream meter sampler not started: no tokio runtime on the constructing thread"); + return; + } + tokio::spawn(async move { + let mut interval = tokio::time::interval(STREAM_METER_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + () = cancel_token.cancelled() => break, + _ = interval.tick() => {} + } + + if subscriber_count.load(Ordering::Relaxed) == 0 || channel_tx.receiver_count() == 0 { + continue; + } + + let entries = sample_entries(&slots).await; + if !entries.is_empty() && channel_tx.send(entries).is_err() { + trace!("Failed to send stream meter batch"); + } + } + }); + } + + #[must_use] + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver> { self.channel_tx.subscribe() } + + #[must_use] + pub fn has_receivers(&self) -> bool { self.channel_tx.receiver_count() > 0 } + + pub fn subscriber_connected(&self) { self.subscriber_count.fetch_add(1, Ordering::Relaxed); } + + pub fn subscriber_disconnected(&self) { + let _ = self.subscriber_count.try_update(Ordering::AcqRel, Ordering::Relaxed, |count| count.checked_sub(1)); + } + + #[must_use] + pub fn has_subscribers(&self) -> bool { self.subscriber_count.load(Ordering::Relaxed) > 0 } + + pub fn send_batch(&self, entries: Vec) { + if !entries.is_empty() { + let _ = self.channel_tx.send(entries); + } + } + + pub async fn register_meter(&self, meter: Arc) { + let meter_uid = meter.meter_uid(); + if meter_uid == 0 { + return; + } + self.slots.write().await.by_meter.entry(meter_uid).or_default().handle = Some(meter); + } + + pub async fn unregister_meter(&self, meter_uid: u32) { + if meter_uid == 0 { + return; + } + self.slots.write().await.remove_meter(meter_uid); + } + + /// Remove a meter, emitting its final totals first. + /// + /// A stream that ends between two sampler ticks would otherwise have its + /// last bytes never reported. + pub async fn flush_and_unregister_meter(&self, meter_uid: u32) { + if meter_uid == 0 { + return; + } + + let final_entry = { + let mut slots = self.slots.write().await; + slots + .remove_meter(meter_uid) + .and_then(|slot| slot.handle.map(|handle| (handle.snapshot(), slot.clients))) + .and_then(|(reading, clients)| build_meter_entry(reading, clients)) + }; + + if let Some(entry) = final_entry { + self.send_batch(vec![entry]); + } + } + + pub async fn register_meter_client(&self, client_uid: u32, meter_uid: u32) { + if client_uid == 0 || meter_uid == 0 { + return; + } + + let carried_entry = { + let mut slots = self.slots.write().await; + + // Moving a client off a meter it was the only viewer of ends that + // meter's reportable life: emit its totals before the move, or + // they are lost. A meter with other viewers keeps reporting, and + // its totals are not this client's to carry. + let carried = slots.client_to_meter.get(&client_uid).copied().filter(|old| *old != meter_uid).and_then( + |old_meter_uid| { + let slot = slots.by_meter.get(&old_meter_uid)?; + if slot.clients.len() != 1 || slot.clients[0] != client_uid { + return None; + } + let reading = slot.handle.as_ref()?.snapshot(); + build_meter_entry(reading, vec![client_uid]) + }, + ); + + slots.detach_client(client_uid); + slots.client_to_meter.insert(client_uid, meter_uid); + let slot = slots.by_meter.entry(meter_uid).or_default(); + if !slot.clients.contains(&client_uid) { + slot.clients.push(client_uid); + } + + carried + }; + + if let Some(entry) = carried_entry { + self.send_batch(vec![entry]); + } + } + + pub async fn unregister_meter_client(&self, client_uid: u32) { + if client_uid == 0 { + return; + } + self.slots.write().await.detach_client(client_uid); + } + + /// Read a meter's `QoS` counters without removing it. + /// + /// `None` for a meter shared by several clients: the totals are + /// meter-wide, so attributing them to one session would overstate it. + pub async fn read_qos(&self, meter_uid: u32) -> Option { + if meter_uid == 0 { + return None; + } + let slots = self.slots.read().await; + let slot = slots.by_meter.get(&meter_uid)?; + if slot.clients.len() > 1 { + return None; + } + let handle = slot.handle.as_ref()?; + Some(MeterQos { bytes_total: handle.bytes_total(), first_byte_latency_ms: handle.first_byte_latency_ms() }) + } + + /// Stop the sampler, emitting one last batch first. + /// + /// `Drop` cancels the sampler but cannot await, so a stream still running + /// at shutdown had its final bytes reported only if it happened to end + /// between ticks - which is what `flush_and_unregister_meter` exists to + /// fix for the one-stream case. This is that, for the whole registry. + /// + /// Idempotent: a second call finds no meters and cancels an + /// already-cancelled token. + pub async fn shutdown(&self) { + let entries = sample_entries(&self.slots).await; + self.send_batch(entries); + self.slots.write().await.by_meter.clear(); + self.sampler_cancel.cancel(); + } +} + +/// What one meter can say about the session it served. +/// +/// Was `(Option, Option)`, where "both `None`" doubled as "this +/// meter is shared, ask no further" - a convention that lived only in a doc +/// comment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MeterQos { + pub bytes_total: u64, + /// `None` when the stream never delivered a first byte. + pub first_byte_latency_ms: Option, +} + +impl Default for StreamMeterRegistry { + fn default() -> Self { Self::new() } +} + +impl Drop for StreamMeterRegistry { + fn drop(&mut self) { self.sampler_cancel.cancel(); } +} + +async fn sample_entries(slots: &RwLock) -> Vec { + let samples = { + let slots = slots.read().await; + if slots.by_meter.is_empty() { + return Vec::new(); + } + slots + .by_meter + .values() + .filter(|slot| !slot.clients.is_empty()) + .filter_map(|slot| slot.handle.as_ref().map(|handle| (handle.snapshot(), slot.clients.clone()))) + .collect::>() + }; + + samples.into_iter().filter_map(|(reading, clients)| build_meter_entry(reading, clients)).collect() +} + +fn build_meter_entry(reading: MeterReading, uids: Vec) -> Option { + if uids.is_empty() { + return None; + } + + let rate_kbps_u64 = reading.bytes_window / 1024 / STREAM_METER_INTERVAL_SECS; + let rate_kbps = u32::try_from(rate_kbps_u64).unwrap_or(u32::MAX); + let total_kb = u32::try_from(reading.bytes_total / 1024).unwrap_or(u32::MAX); + + Some(StreamMeterEntry { meter_uid: reading.meter_uid, uids, rate_kbps, total_kb }) +} diff --git a/backend/session/src/provider_lineup_manager.rs b/backend/session/src/provider_lineup_manager.rs index fda0d2342..1f2d13a4b 100644 --- a/backend/session/src/provider_lineup_manager.rs +++ b/backend/session/src/provider_lineup_manager.rs @@ -2,7 +2,10 @@ use crate::EventManager; use arc_swap::ArcSwap; use dashmap::DashMap; use log::{debug, log_enabled}; -use shared::utils::{display_vec, sanitize_sensitive_info}; +use shared::{ + model::{EventMessage, ProviderPoolEntry, ProviderPoolExhausted, ProviderPriorityFallback}, + utils::{display_vec, sanitize_sensitive_info}, +}; use std::{ collections::HashMap, fmt, @@ -97,6 +100,30 @@ impl fmt::Display for ProviderLineup { } impl ProviderLineup { + /// Which priority group holds `provider_name`, and how many groups there + /// are. `None` when the lineup does not contain it. + /// + /// A `Single` lineup has exactly one group, so it can never report a + /// fallback. + fn priority_group_of(&self, provider_name: &Arc) -> Option<(usize, usize)> { + match self { + Self::Single(single) => (&single.provider.name == provider_name).then_some((0, 1)), + Self::Multi(multi) => { + let count = multi.providers.len(); + multi + .providers + .iter() + .position(|group| match group { + ProviderPriorityGroup::SingleProviderGroup(cfg) => &cfg.name == provider_name, + ProviderPriorityGroup::MultiProviderGroup(_, cfgs) => { + cfgs.iter().any(|cfg| &cfg.name == provider_name) + } + }) + .map(|index| (index, count)) + } + } + } + fn provider_names(&self) -> Vec> { match self { ProviderLineup::Single(lineup) => vec![lineup.provider.name.clone()], @@ -474,6 +501,9 @@ pub struct ProviderLineupManager { snapshot: Arc>, provider_connections: DashMap, Arc>>, event_manager: Arc, + /// Which priority group last served each input, so a fallback is reported + /// on transition rather than once per allocation. + served_priority_group: DashMap, usize>, } #[derive(Debug)] @@ -496,6 +526,7 @@ impl ProviderLineupManager { snapshot: Arc::new(ArcSwap::from_pointee(LineupSnapshot { inputs, providers: lineups })), provider_connections, event_manager: Arc::clone(event_manager), + served_priority_group: DashMap::new(), } } @@ -507,7 +538,7 @@ impl ProviderLineupManager { let event_manager = Arc::clone(event_manager); let on_connection_change: ProviderConnectionChangeCallback = Arc::new(move |name: &Arc, connections: usize| { - event_manager.send_provider_event(name, connections); + event_manager.send_event(EventMessage::ActiveProvider(Arc::clone(name), connections)); }); if cfg_input.has_enabled_aliases() { @@ -636,7 +667,7 @@ impl ProviderLineupManager { for (name, conn_lock) in snapshot { let count = conn_lock.read().await.current_connections; - self.event_manager.send_provider_event(&name, count); + self.event_manager.send_event(EventMessage::ActiveProvider(name, count)); } } @@ -740,32 +771,41 @@ impl ProviderLineupManager { }; if matches!(allocation, ProviderAllocation::Exhausted) { if let Some((lineup, _cfg)) = lineup_opt { - Self::log_exhausted_pool_snapshot(input_name, lineup).await; + self.report_exhausted_pool(input_name, lineup).await; + } + } else if let Some((lineup, _cfg)) = lineup_opt { + if let Some(provider) = allocation.provider() { + if let Some((index, count)) = lineup.priority_group_of(&provider.name) { + self.report_priority_group(input_name, &provider.name, index, count); + } } } Self::log_allocation(&allocation); allocation } - async fn log_exhausted_pool_snapshot(input_name: &str, lineup: &ProviderLineup) { - if !log_enabled!(log::Level::Debug) { - return; - } - - let mut entries: Vec = Vec::new(); + /// Report that every provider behind an input is at capacity. + /// + /// The snapshot used to be built only when debug logging was on and + /// discarded immediately afterwards, so the one moment that explains a + /// refused stream left nothing behind. It is built unconditionally now - + /// this runs only on the exhausted path, which is already the slow one - + /// and the debug line renders from the same data the event carries. + async fn report_exhausted_pool(&self, input_name: &Arc, lineup: &ProviderLineup) { + let mut entries: Vec = Vec::new(); match lineup { ProviderLineup::Single(single) => { - entries.push(Self::format_provider_snapshot_entry(&single.provider).await); + entries.push(Self::provider_snapshot_entry(&single.provider).await); } ProviderLineup::Multi(multi) => { for group in &multi.providers { match group { ProviderPriorityGroup::SingleProviderGroup(cfg) => { - entries.push(Self::format_provider_snapshot_entry(cfg).await); + entries.push(Self::provider_snapshot_entry(cfg).await); } ProviderPriorityGroup::MultiProviderGroup(_, cfgs) => { for cfg in cfgs { - entries.push(Self::format_provider_snapshot_entry(cfg).await); + entries.push(Self::provider_snapshot_entry(cfg).await); } } } @@ -773,18 +813,56 @@ impl ProviderLineupManager { } } - debug_if_enabled!( - "Provider pool exhausted for input {} (pool_snapshot=[{}])", - sanitize_sensitive_info(input_name), - sanitize_sensitive_info(&entries.join(", ")) - ); + if log_enabled!(log::Level::Debug) { + let rendered = entries + .iter() + .map(|entry| { + format!( + "{}:{}/{} expired={}", + entry.name, entry.current_connections, entry.max_connections, entry.expired + ) + }) + .collect::>() + .join(", "); + debug_if_enabled!( + "Provider pool exhausted for input {} (pool_snapshot=[{}])", + sanitize_sensitive_info(input_name), + sanitize_sensitive_info(&rendered) + ); + } + + self.event_manager.send_event(EventMessage::ProviderPoolExhausted(ProviderPoolExhausted::new( + sanitize_sensitive_info(input_name).into_owned().into(), + entries, + ))); } - async fn format_provider_snapshot_entry(cfg: &ProviderConfigWrapper) -> String { - let current = cfg.get_current_connections().await; - let max = cfg.max_connections(); - let expired = is_input_expired(cfg.exp_date()); - format!("{}:{}/{} expired={expired}", cfg.name, current, max) + async fn provider_snapshot_entry(cfg: &ProviderConfigWrapper) -> ProviderPoolEntry { + ProviderPoolEntry { + name: sanitize_sensitive_info(&cfg.name).into_owned().into(), + current_connections: cfg.get_current_connections().await, + max_connections: cfg.max_connections(), + expired: is_input_expired(cfg.exp_date()), + } + } + + /// Report a change in which priority group is serving an input. + /// + /// On transition only. The fallthrough in `acquire` happens on every + /// request while the preferred group is full, so one event per allocation + /// would bury the thing worth hearing: that the input moved. + fn report_priority_group(&self, input_name: &Arc, provider: &Arc, index: usize, count: usize) { + let previous = self.served_priority_group.insert(Arc::clone(input_name), index); + if previous == Some(index) { + return; + } + self.event_manager.send_event(EventMessage::ProviderPriorityFallback(ProviderPriorityFallback::new( + sanitize_sensitive_info(input_name).into_owned().into(), + sanitize_sensitive_info(provider).into_owned().into(), + index, + count, + previous, + ))); } // This method is used for redirects to cycle through provider diff --git a/bin/check-workspace-deps.sh b/bin/check-workspace-deps.sh index 984475880..33e4a4897 100755 --- a/bin/check-workspace-deps.sh +++ b/bin/check-workspace-deps.sh @@ -50,7 +50,6 @@ normal tuliprox -> tuliprox-library normal tuliprox -> tuliprox-iptv normal tuliprox-iptv -> shared normal tuliprox-iptv -> tuliprox-core -normal tuliprox-iptv -> tuliprox-messaging normal tuliprox-iptv -> tuliprox-parser normal tuliprox-iptv -> tuliprox-repository normal tuliprox -> tuliprox-messaging @@ -77,7 +76,6 @@ normal tuliprox-processing -> tuliprox-core normal tuliprox-processing -> tuliprox-iptv normal tuliprox-processing -> tuliprox-library normal tuliprox-processing -> tuliprox-media-server -normal tuliprox-processing -> tuliprox-messaging normal tuliprox-processing -> tuliprox-parser normal tuliprox-processing -> tuliprox-repository normal tuliprox-processing -> tuliprox-session @@ -89,14 +87,14 @@ normal tuliprox-hls -> tuliprox-core normal tuliprox-hls -> tuliprox-mpegts normal tuliprox-hls -> tuliprox-parser normal tuliprox-hls -> tuliprox-session -# The DVR. Reads the running server through `RecordingCtx`; needs -# `session` for the event bus it publishes recording changes on. +# The DVR. Reads the running server through `RecordingCtx`. It publishes +# recording changes through `shared`'s `EventSink`, so it does not depend on +# the streaming-session runtime that happens to implement that trait. normal tuliprox-dvr -> shared normal tuliprox-dvr -> tuliprox-auth normal tuliprox-dvr -> tuliprox-core normal tuliprox-dvr -> tuliprox-messaging normal tuliprox-dvr -> tuliprox-repository -normal tuliprox-dvr -> tuliprox-session # Provider allocation and the streaming-session runtime. Reaches # `repository` to persist stream history and resolve GeoIP, and `mpegts` # for the transport-stream buffer it hands to preempted clients. diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md index 4ca6d62dc..4e0c3175d 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -61,6 +61,7 @@ metadata_update: | `connect_timeout_secs` | Int | No | `10` | Maximum time (in seconds) Tuliprox waits to establish the initial TCP connection to a provider. `0` disables the timeout and the connection attempt continues until the provider closes it or a network timeout occurs (Warning: risk of hanging threads!). | | `interner_gc_interval_secs` | Int | No | `180` | Interval in seconds between background string interner garbage-collection checks. Lower values reclaim unused interned strings sooner, but run the GC gate more often. Higher values reduce GC overhead, but can retain more memory for longer. Must be greater than `0`. | | `interner_gc_min_pool_size` | Int | No | `100` | Minimum number of interned strings required before the background interner GC runs. This avoids paying the interner write-lock cost when the pool is very small. Lower values favor aggressive cleanup; higher values favor throughput and lower lock contention at the cost of extra retained memory. | +| `event_channel_capacity` | Int | No | `256` | Buffer depth of the in-process event broadcast channel, per subscriber. Every runtime event — playlist-update progress, config reloads, download deltas, recording changes — passes through it on the way to the Web UI websocket and the notification pipeline. A subscriber that falls this many events behind is told it lagged and resumes from the newest event, having missed the gap; the Web UI recovers by re-requesting a status snapshot. Raise it if the bus reports lag under load. `0` is clamped to `1`. | | `user_access_control` | Bool | No | `false` | **Security:** If `true`, Tuliprox actively enforces `status` (Active/Banned), `exp_date`, and `max_connections` constraints for users defined in `api-proxy.yml`. If false, those fields are ignored. | | `custom_stream_response_path` | String | No | `null` | Directory path where Tuliprox looks for custom fallback `.ts` files. See section [Custom Stream Response](#custom-stream-responses-fallback-videos) for exact filenames. | | `custom_stream_response_timeout_secs` | Int | No | `0` | Hard timeout (in seconds) that forces the fallback video stream to terminate to prevent infinite bandwidth usage. `0` means endless loop. | @@ -418,22 +419,86 @@ know when new movies are added to your playlist. ### 5.1 Configuration & Opt-In -Messaging is strictly **opt-in**. You must explicitly define which event types should trigger a notification using the -`notify_on` list. +Messaging is strictly **opt-in**. Nothing is sent until `notify_on` asks for it. -**Available Event Types:** +`notify_on` is a list of **glob patterns** over event ids. An event id is a +dotted `domain.event` string: -* `info`: General operational information. -* `stats`: Summary of processed items and performance metrics after a run. -* `error`: Alerts when processing or source fetching fails. -* `watch`: Triggered by changes in monitored groups/targets. -* `recording_started`: DVR recording entered the active recording worker. -* `recording_completed`: DVR recording finished and the final file was committed. -* `recording_failed`: DVR recording reached a terminal failure. +| Pattern | Matches | +|:------------------------|:-------------------------------------------| +| `*` | every event | +| `recording.*` | every event in the `recording` domain | +| `recording.completed` | that event only | +| `provider.*.expired` | one wildcard segment | +| `!system.info` | **excludes**, whatever else matched | + +A subscription matches when at least one positive pattern matches and no +negative pattern does, so `["*", "!system.info"]` reads the way it looks. + +> **Upgrading:** the old event names (`info`, `stats`, `error`, `watch`, +> `disk_alert`, `recording_started`, `recording_completed`, +> `recording_failed`) are still accepted and are rewritten to their +> canonical ids the next time the config is saved. Existing configs and +> existing template files keep working untouched. + +#### Available Events + + + +| Event id | Default severity | Description | +| :------- | :--------------- | :---------- | +| `system.info` | info | A general informational message. | +| `system.error` | error | A general error message. | +| `system.disk.alert` | warn | Disk usage crossed the warn or critical threshold. | +| `system.started` | info | The server finished starting up. | +| `system.shutdown` | info | The server is shutting down cleanly. | +| `playlist.update.completed` | info | A playlist update finished; carries per-source statistics. | +| `playlist.update.failed` | error | A playlist update failed. | +| `playlist.watch.changed` | info | Channels were added to or removed from a watched group. | +| `playlist.groups.changed` | info | Groups were added to or removed from a target. | +| `playlist.watch.disabled` | warn | A target's watch configuration is set but not working. | +| `playlist.watch.unmatched` | warn | Watch patterns matched no group in the refreshed playlist. | +| `recording.started` | info | A recording started. | +| `recording.completed` | info | A recording completed. | +| `recording.failed` | error | A recording failed. | +| `provider.account.status_changed` | warn | A provider reported a changed account status. | +| `provider.account.expiring` | warn | A provider account is approaching its expiry date. | +| `provider.account.expired` | error | A provider account has expired. | +| `provider.fetch.failed` | error | An input's playlist could not be fetched. | +| `provider.pool.exhausted` | warn | Every provider behind an input was at capacity. | +| `provider.priority.fallback` | warn | An input started being served from a different provider priority group. | +| `config.changed` | info | A configuration file was changed and reloaded. | +| `config.reload_failed` | error | A configuration file changed but could not be reloaded. | +| `library.scan.completed` | info | A local library scan finished. | +| `library.scan.failed` | error | A local library scan could not complete. | +| `metadata.update.started` | info | A metadata update started for an input. | +| `metadata.update.completed` | info | A metadata update finished for an input. | +| `metadata.update.failed` | error | A metadata update cycle ended with tasks it could not finish. | +| `user.connection.changed` | info | A user connected or disconnected. High frequency - subscribe deliberately. | +| `provider.connections.changed` | info | A provider's active connection count changed. High frequency - subscribe deliberately. | +| `user.connection.denied` | warn | A user was refused a connection because their limits were full. | +| `recording.queue.changed` | info | The recording queue changed. | +| `recording.rules.changed` | info | The recording rule set changed. | +| `scheduled_task.failed` | error | A scheduled task could not complete. | +| `notification.dead_lettered` | error | A notification was permanently undeliverable and has been dropped. | +| `user.created` | info | An API-proxy user account was created. | +| `user.updated` | info | An API-proxy user account was changed. | +| `user.deleted` | warn | An API-proxy user account was deleted. | +| `auth.sign_in.succeeded` | info | A principal signed in and was issued a token. | +| `auth.sign_in.failed` | warn | A sign-in was rejected. Deduplicated per principal and address, so a password-guessing run notifies once rather than per attempt. | +| `auth.sign_in.throttled` | warn | A sign-in was refused without checking credentials because the caller is backing off after repeated failures. | +| `auth.permission.denied` | warn | An authenticated principal asked for something its permissions do not cover. | +| `stream.probe.failed` | warn | A stream probe returned no metadata; the stream may be dead. Deduplicated per input, so a provider outage notifies once rather than per channel. | + + + +Severity is one of `info`, `warn`, `error`, `critical`, in that order. + +#### Example ```yaml messaging: - notify_on: [ "info", "stats", "error", "watch", "recording_started", "recording_completed", "recording_failed" ] + notify_on: [ "playlist.update.failed", "recording.*", "provider.account.*", "system.disk.alert" ] # Telegram: Supports Markdown and Group Topics telegram: @@ -441,37 +506,150 @@ messaging: bot_token: "" chat_ids: - "" - - ":" # Use colon to target specific Discord-like topics/threads + - ":" # Use colon to target a specific topic/thread templates: - stats: 'file:///config/messaging_templates/telegram_stats.templ' - recording_completed: 'file:///config/messaging_templates/telegram_recording_completed.templ' + playlist.update.completed: 'file:///config/messaging_templates/telegram_playlist_update_completed.templ' + recording.completed: 'file:///config/messaging_templates/telegram_recording_completed.templ' # Discord: Webhook integration discord: url: "" templates: - info: '{"content": "🚀 Tuliprox Info: {{message}}"}' + system.info: '{"content": "🚀 Tuliprox Info: {{message}}"}' # Pushover: Simple mobile push alerts pushover: token: "" user: "" - # url: "https://api.pushover.net/1/messages.json" # Optional default - # REST: Generic Webhook/API support + # ntfy: Self-hosted push, no account needed + ntfy: + url: "https://ntfy.sh" + topic: "my-tuliprox" + # token: "" # only for a protected topic + + # Gotify + gotify: + url: "https://gotify.local" + token: "" + + # Slack incoming webhook + slack: + url: "" + + # REST: Generic webhook/API support rest: url: "https://my-api.local/alert" - method: "POST" # Optional, defaults to POST + method: "POST" headers: - "Content-Type: application/json" + signing_secret: "" # optional, see 5.4 templates: - error: '{"text": "Alert: {{message}}", "type": "{{kind}}"}' + system.error: '{"text": "Alert: {{message}}", "type": "{{kind}}"}' + + # command: run a local program with the event JSON on stdin + command: + program: "/config/scripts/notify.sh" + args: [ "--from-tuliprox" ] + timeout_secs: 30 ``` +### 5.1.1 Per-Channel Routing + +Every channel takes an optional `routing` block. Without one it inherits the +global `notify_on`, so existing configs are unaffected. + +```yaml +messaging: + notify_on: [ "*" ] + + pushover: + token: "" + user: "" + routing: + min_severity: critical # only wake me for real problems + + discord: + url: "" + routing: + notify_on: [ "playlist.*", "recording.*" ] + quiet_hours: "23:00-07:00" # deferred, never dropped + max_per_hour: 20 # circuit breaker + dedup_window_secs: 3600 # suppress a repeated event +``` + +| Field | Meaning | +| :---- | :------ | +| `notify_on` | Overrides the global subscription for this channel. Same glob grammar. | +| `min_severity` | Drop anything below `info` \| `warn` \| `error` \| `critical`. | +| `quiet_hours` | `HH:MM-HH:MM` local time. Wrapping windows (`23:00-07:00`) work. Notifications are **deferred until the window closes, never dropped** - an overnight outage must not be silently invisible. | +| `max_per_hour` | Circuit breaker. On tripping, the channel emits one "further notifications suppressed" audit line and then goes quiet for the rest of the hour, so the silence is distinguishable from a broken notifier. | +| `dedup_window_secs` | Suppress a repeated event with the same dedup key for this long. Generalizes what used to be `disk_alert.repeat_interval_secs`. | + +### 5.1.2 Delivery, Retry and the Outbox + +Every notification goes through a durable outbox before it is sent. Entries +are persisted to `{storage_dir}/notification_outbox.json` before the first +attempt, retried per channel with capped exponential backoff, and +dead-lettered after `max_attempts`. + +Retry is at-most-once **per channel**: a message that reached Telegram but +not Discord is retried only against Discord, so a retry never duplicates a +delivered message. + +* A `429` or `503` is retried, and a provider's `Retry-After` is honoured + rather than being retried straight back into the same rate limit. +* A `404` or `401` is **permanent** - retrying a malformed webhook URL + cannot help, so it is dead-lettered immediately instead of consuming + every attempt. +* Each channel has a 30 second request timeout, and channels are attempted + concurrently, so one unresponsive host cannot stall the others. + +Tuning lives under `video.download.recording.notifications` +(`outbox_buffer`, `max_attempts`, `backoff_initial_secs`, +`backoff_max_secs`). + +### 5.1.3 Testing Your Configuration + +`POST /api/v1/config/messaging/test` renders and optionally sends a chosen +event, and returns the exact rendered body per channel. + +```json +{ "event": "recording.completed", "channel": "telegram", "preview": true } +``` + +* `event` - any event id. Defaults to `system.info`. +* `channel` - restrict to one channel id. Omit for all configured channels. +* `preview` - render only, send nothing. Use this to iterate on a template + without spamming a channel. + +The test deliberately bypasses `notify_on` and the suppression window: you +asked for this one explicitly. + ### 5.2 Templating (Handlebars) -For **Telegram**, **Discord**, and **REST**, Tuliprox uses [Handlebars](https://handlebarsjs.com/) to format message -bodies. This allows for rich, structured notifications (e.g., Discord Embeds or Markdown tables). +**Every** channel supports templates - Telegram, Discord, REST, Pushover, ntfy, Gotify, Slack and `command`. +Tuliprox uses [Handlebars](https://handlebarsjs.com/) to format message bodies, which allows rich, structured +notifications (e.g. Discord embeds or Markdown tables). + +Templates are keyed by event id: + +```yaml +telegram: + templates: + recording.completed: 'file:///config/messaging_templates/telegram_recording_completed.templ' +``` + +A `{prefix}_{event}.templ` file dropped into `/config/messaging_templates/` is picked up automatically - +`telegram_recording_completed.templ`, `discord_playlist_update_failed.templ`, and so on. Dots in the event id become +underscores in the filename. + +Templates are resolved and compiled **once** and then cached: local files are re-read when their mtime changes, +remote templates revalidate on a 5 minute TTL, and a remote template that cannot be refreshed serves the cached copy +rather than silently falling back to the built-in text. + +If no template applies, the channel sends the event's built-in `title` and `body`, which are always populated and +always human-readable. #### Loading Methods @@ -486,9 +664,21 @@ bodies. This allows for rich, structured notifications (e.g., Discord Embeds or The Handlebars engine provides a rich context object. Depending on the `kind` of notification, different variables are populated: +Every template sees a uniform `event` object, plus the legacy top-level keys documented below: + +* `{{event.id}}`: The canonical event id, e.g. `recording.completed`. +* `{{event.severity}}`: `info`, `warn`, `error` or `critical`. +* `{{event.title}}`: One-line summary. Always present. +* `{{event.body}}`: Plain-text body. Always present. +* `{{event.fields}}`: The typed payload for this event. +* `{{event.timestamp}}`: Unix seconds. Use `{{timestamp}}` for the RFC 3339 form. + +The legacy keys below continue to work exactly as before, so existing templates need no change: + * `{{message}}`: The primary text payload. Used for human-readable `info` messages or the summary of an `error`. -* `{{kind}}`: The event category (`info`, `stats`, `error`, `watch`). Use this in Handlebars helpers (e.g., +* `{{kind}}`: The event category (`Info`, `Stats`, `Error`, `Watch`, ...). Use this in Handlebars helpers (e.g., `{{#if (eq kind "error")}}`) to create conditional layouts. +* `{{severity}}`: The event severity. * `{{timestamp}}`: The event occurrence time in UTC (ISO 8601 / RFC3339 format). * `{{stats}}`: **Execution Metrics & Performance.** A comprehensive list of statistics for the last update cycle, covering both ingestion and generation phases. @@ -500,6 +690,9 @@ populated: * **Error Tracking:** `errors` provides a count of failed items or connection issues during that specific phase. * **Filtering Delta:** Compare `raw` counts (total items received from the provider) vs. `processed` counts ( items that survived your Mapping DSL and filters) to monitor your "Red Thread" efficiency. + * **Target Processing:** A target may include a `processing` object with exact filter inspected/retained/removed + counts, renamed item/field counts, matched mapping rules, emitted virtual items, and mapping diagnostics. The + object is omitted when no processing work was recorded, so existing templates remain compatible. * `{{processing}}`: **Engine State & Telemetry.** Provides insight into the *internal execution environment* during the task. It includes data on memory allocation peaks, active worker threads, and non-blocking diagnostic warnings. * **Access:** Access properties directly via dot-notation (e.g., `{{processing.memory_peak_mb}}`). Use this to @@ -513,6 +706,9 @@ populated: `recording_failed`. Common fields are `programme_title`, `channel`, `effective_start`, `effective_end`, `visibility`, `output_filename`, and `failure_reason` for failed recordings. +* `{{disk}}`: **Disk Alert Data.** Available for `system.disk.alert`: `level`, `percent`, `total_bytes`, + `used_bytes`, `free_bytes`. + Recording lifecycle notifications are global-channel notifications. Tuliprox sends them for shared recordings, legacy administrator recordings, and built-in administrator private recordings. Private recordings owned by regular users are suppressed. @@ -635,6 +831,45 @@ default line, e.g.: --- +### 5.4 Webhook Signing + +Set `rest.signing_secret` and every request carries: + +```text +X-Tuliprox-Timestamp: 1700000000 +X-Tuliprox-Signature: sha256= +``` + +The signature is HMAC-SHA256 over `{timestamp}.{body}` using the shared secret. The timestamp is inside the signed +payload, so a captured request cannot be replayed later with a fresh timestamp header. Verify it by recomputing the +HMAC and comparing in constant time, and reject requests whose timestamp is too far from your clock. + +### 5.5 Running a Local Program + +The `command` channel runs a local program with the event JSON on stdin, plus `TULIPROX_EVENT_ID`, +`TULIPROX_EVENT_SEVERITY` and `TULIPROX_EVENT_TITLE` in the environment. + +```yaml +messaging: + command: + program: "/config/scripts/notify.sh" + args: [ "--from-tuliprox" ] + timeout_secs: 30 +``` + +The program is executed **directly, never through a shell**, so there are no quoting rules to get wrong and event +content cannot be interpreted as shell syntax. + +> **Security:** this runs arbitrary code as the tuliprox process user. It is opt-in and never configured by default. +> Treat the script as part of your trusted computing base. + +### 5.6 Secrets in the Web UI + +Channel secrets - the Telegram bot token, Pushover token and user key, ntfy and Gotify tokens, the REST signing +secret, and any `Authorization`-style REST header - are masked as `********` when the configuration is read back +through the API. Leaving a masked value untouched when you save keeps the stored secret; replacing it writes the new +one through. + ## 6. Video & Web Search (`video`) Optional video-related behaviors, mostly utilized by the Web UI. diff --git a/docs/src/configuration/mapping-dsl.md b/docs/src/configuration/mapping-dsl.md index 806b0b709..ea6bfd564 100644 --- a/docs/src/configuration/mapping-dsl.md +++ b/docs/src/configuration/mapping-dsl.md @@ -64,7 +64,8 @@ mappings: - id: map_sports_clean match_as_ascii: true mapper: - - filter: 'Group ~ ".*Sports.*"' + - name: normalize sports groups + filter: 'Group ~ ".*Sports.*"' script: | @Group = "Live Sports HD" counter: @@ -114,6 +115,11 @@ Merge groups -> Sort -> Assign channel numbers -> Mapping counters -> Watches -> `after_epg` is a separate stage outside that configurable pipe. Mapping counters are also outside the pipe and always run on the consolidated target playlist after merging, sorting, and initial channel-number assignment. +The configured order and mapping-stage indexes are compiled when configuration is loaded. During a refresh, the F/R/M +pipe materializes each input into one item buffer, preserves the historical grouping order between stages, and rebuilds +playlist groups once when the pipe completes. Resolved series episodes use the same runner and therefore the same stage +semantics. + | Parameter | Type | Description | |:---------------------|:-------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **`id`** | String | **Mandatory.** The unique identifier of the mapping. | @@ -122,6 +128,14 @@ run on the consolidated target playlist after merging, sorting, and initial chan | **`mapper`** | List | A list of scripts (executed sequentially) containing the DSL logic. The list can optionally be gated by a `filter`. [See Mapper DSL](#21-the-mapper-dsl-mapper). | | **`counter`** | List | Logic for assigning channel numbers sequentially. [See Counters](#22-counters-sequential-numbering-counter). | +Each mapper entry may include an optional `name`. Names do not affect matching or execution order; they make startup +errors and runtime diagnostics easier to identify. Existing unnamed entries remain valid and use their one-based list +position in diagnostics. + +Mappings are parsed and compiled when configuration is loaded. A processing run reads an immutable shared snapshot, so +hot reloads cannot partially replace a mapping while a target is being processed. Runtime diagnostics are summarized per +mapping stage; at most ten detailed failures are logged per stage, followed by a suppressed-count summary. + > The `after_epg` stage sees fields populated by EPG enrichment (for example `epg_channel_id` and `logo`). Inspect these > fields inside the mapper script; the outer `filter` continues to support only the fields listed below. EPG enrichment > is not re-run after the late mapping, so changed EPG channel IDs and virtual channels skip another matching round. The @@ -356,7 +370,7 @@ We want to group the channels inside NEWS, NATIONAL, SATELLITE by their resoluti The other groups should get a numerical prefix for ordering. ```yaml -* filter: 'Group ~ ".*"' +- filter: 'Group ~ ".*"' script: | group = @Group ~ "(EU|SATELLITE|NATIONAL|NEWS|MUSIC|SPORT|RELIGION|FILM|KIDS|DOCU)" quality = @Caption ~ "\b([F]?HD[i]?)\b" @@ -489,7 +503,7 @@ The result should look like: * `FR | Movies 2021` ```yaml -* filter: 'Group ~ "^FR" AND Caption ~ "\(?\d{4}\)?$"' +- filter: 'Group ~ "^FR" AND Caption ~ "\(?\d{4}\)?$"' script: | year_text = @Caption ~ "(\d{4})\)?$" year = number(year_text) @@ -634,11 +648,11 @@ always use **padded numbering**: 2. `02_additional_mappings.yml` 3. `10_custom_overrides.yml` -> **Why this matters:** Tuliprox processes these files sequentially. -> If a later file contains a mapping ID that was already defined in an earlier file, -> the later definition may overwrite or merge with the previous one, -> depending on the specific logic. Proper numbering ensures your global "Base" rules -> are established before your specific "Overrides" are applied. +> **Why this matters:** Tuliprox processes these files sequentially. If a later file contains a mapping ID that was +> already defined in an earlier file, its `mapper` and `counter` entries are appended to the existing mapping in file +> order. The first definition supplies `stage` and `match_as_ascii`. Conflicting `stage` values stop configuration +> loading; conflicting `match_as_ascii` values produce a warning and retain the first value. Proper numbering therefore +> gives rule execution and mapping options a predictable order. #### CLI Overrides diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index fecb639a6..0c37b3f7a 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -1458,8 +1458,8 @@ targets: | `ignore_logo` | Bool | No | `false` | Ignores `tvg-logo` and `tvg-logo-small` attributes. This reduces downstream device-side logo caching and can keep generated M3U playlists leaner for clients with limited storage or poor cache invalidation behavior. | | `share_live_streams.hls` | Bool | No | `false` | Enables HLS live sharing for the new HLS cache proxy path. This is a configuration switch for the HLS cache feature and is independent from MPEG-TS stream sharing. | | `share_live_streams.mpeg_ts` | Bool | No | `false` | Allows Tuliprox to share MPEG-TS live stream connections in reverse proxy mode. This can reduce upstream provider connection usage when multiple clients watch the same channel, but it increases memory usage per shared channel. | -| `remove_duplicates` | Bool | No | `false` | Attempts to remove duplicate entries by `url`. This improves playlist cleanliness and reduces confusing duplicates in the client-facing output. | -| `deduplicate` | Map | No | - | Quality-aware duplicate removal. Channels whose match value is identical after stripping quality tokens (`4K`, `UHD`, `2160p`, `QHD`, `1440p`, `FHD`, `1080p`, `HD`, `720p`, `SD`, `480p`, `576p`) collapse to a single entry. Sub-keys: `match_by` (`caption` (default), `name`, `title`), `keep` (`best_quality` (default) keeps the highest quality tier, `first` keeps the first occurrence) and `match_as_ascii` (default `false`, normalizes accented characters in match keys so `Café HD` matches `Cafe FHD`). Matching is per cluster across all groups; ties keep the first occurrence. | +| `remove_duplicates` | Bool | No | `false` | Legacy pre-transform identity deduplication. It runs independently for each input before the F/R/M pipe and removes repeated source identities before mapping can emit additional items. The field remains supported for backward compatibility. | +| `deduplicate` | Map | No | - | Post-merge, quality-aware content deduplication. It runs after all inputs have been transformed and merged. Channels whose match value is identical after stripping quality tokens (`4K`, `UHD`, `2160p`, `QHD`, `1440p`, `FHD`, `1080p`, `HD`, `720p`, `SD`, `480p`, `576p`) collapse to one entry. Sub-keys: `match_by` (`caption` (default), `name`, `title`), `keep` (`best_quality` (default), `first`) and `match_as_ascii` (default `false`). Matching is per cluster across all groups; ties keep the first occurrence. This lets mappings affect the final duplicate comparison results. | | `epg_output.lowercase_ids` | Bool | No | `false` | Canonicalizes visible technical EPG IDs with ASCII lowercase across M3U `tvg-id`, Xtream `epg_channel_id`, XMLTV channel/programme references, and EPG API responses. Changing this option requires a full target refresh. | | `epg_output.lowercase_xmltv_display_names` | Bool | No | `false` | Applies Unicode lowercase exclusively to XMLTV `` values during serialization. Playlist names, Xtream names, programme titles, and programme descriptions remain unchanged; persisted target data does not require rebuilding. | | `force_redirect` | Bool | No | `false` | Optional redirect-related behavior switch. This influences how Tuliprox serves final stream delivery where redirect-style output handling is required by the deployment model. | diff --git a/docs/src/rest-api-cookbook.md b/docs/src/rest-api-cookbook.md index d0c3cbe43..08ea1d001 100644 --- a/docs/src/rest-api-cookbook.md +++ b/docs/src/rest-api-cookbook.md @@ -136,7 +136,44 @@ Typical use: - inspect disconnect patterns and provider churn - verify that stream history collection is working -## Example 5: Query QoS snapshots +## Example 5: Diagnose the event bus + +```bash +#!/bin/bash + +BASE_URL="http://localhost:8901" +TOKEN="PUT_YOUR_TOKEN_HERE" + +curl -s -X GET "$BASE_URL/api/v1/events/stats" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" | jq . +``` + +Every runtime event — playlist updates, config reloads, recording changes, +metadata refreshes — passes through one in-process broadcast bus on its way to +the Web UI websocket and the notification pipeline. This endpoint reports what +that bus has carried: + +- `emitted` — a count per event, using the same stable names plugins and + notification subscriptions use. +- `no_subscribers` — events published while nothing was listening. Normal when + the Web UI is closed and notifications are off; a large number beside "I never + got that notification" is the answer. +- `coalesced` — payload-free nudges suppressed because an identical one had just + gone out. Deleting a recording naturally produces a few. +- `lagged` — events a subscriber was told it had missed. Should be `0`. If it is + not, a subscriber cannot keep up: raise + [`event_channel_capacity`](configuration/config.md). +- `recent` — the last 256 events with the outcome of each, so you can see + whether a specific event fired at all. + +Typical use: + +- confirm an event fired before hunting for a bug in whatever consumes it +- tell "no notification was sent" apart from "no notification was configured" +- size `event_channel_capacity` from observed lag rather than by guessing + +## Example 6: Query QoS snapshots ```bash #!/bin/bash @@ -155,7 +192,7 @@ Typical use: - compare `24h`, `7d`, and `30d` quality windows - prepare later failover and ranking analysis -## Example 6: Inspect a download target before queueing +## Example 7: Inspect a download target before queueing ```bash #!/bin/bash @@ -176,7 +213,7 @@ Typical use: - verify that the remote file is reachable - test `download.read` access -## Example 7: Queue a file download +## Example 8: Queue a file download ```bash #!/bin/bash @@ -202,7 +239,7 @@ Typical use: - confirm that duplicate queue requests return the existing task instead of creating a second one - test `download.write` access -## Example 8: Trigger a playlist update +## Example 9: Trigger a playlist update ```bash #!/bin/bash @@ -223,7 +260,7 @@ Typical use: - verify `playlist.write` permission - integrate Tuliprox into external automation -## Example 9: Preview a Stalker live input in the Web UI playlist API +## Example 10: Preview a Stalker live input in the Web UI playlist API ```bash #!/bin/bash @@ -256,7 +293,7 @@ Notes: - Runtime refresh only produces `http`/`https` playback URLs; Stalker `rtmp://` / `rtsp://` commands are rejected explicitly rather than proxied half-supported. -## Example 10: Dry-run a filter expression against a target +## Example 11: Dry-run a filter expression against a target ```bash #!/bin/bash @@ -298,6 +335,7 @@ This is a compact operator-oriented overview of the `/api/v1` REST API groups cu | `GET` | `/api/v1/status` | Server status, version, active users, provider connections, cache state | | `GET` | `/api/v1/streams` | Current active streams | | `GET` | `/api/v1/ipinfo` | External IPv4/IPv6 check if configured | +| `GET` | `/api/v1/events/stats` | Event-bus counters and the last 256 events, with the outcome of each | | `GET` | `/api/v1/stream-history` | Query raw persisted stream history | | `GET` | `/api/v1/stream-history/summary` | Aggregated stream history summary | | `GET` | `/api/v1/qos-snapshots` | List QoS snapshots | diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 5d67b6325..7559ec07f 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -19,7 +19,7 @@ serde.workspace = true wasm-bindgen = {version = "0.2.127", features = ["serde-serialize"]} #anyhow = "1.0.102" thiserror = "2.0.20" -reqwasm = {version = "0.5", features = ["json"]} +gloo-net = { version = "0.5.0", default-features = false, features = ["http", "json"] } log.workspace = true wasm-logger = "0.2.0" gloo-storage = "0.3.0" diff --git a/frontend/src/app/components/config/config_page.rs b/frontend/src/app/components/config/config_page.rs index eb300bd87..c0494c531 100644 --- a/frontend/src/app/components/config/config_page.rs +++ b/frontend/src/app/components/config/config_page.rs @@ -103,6 +103,11 @@ impl fmt::Display for ConfigPage { } #[derive(Debug, Clone, PartialEq)] +// `MessagingConfigDto` is the largest variant: eight channel configs, each +// with its own templates map and routing block. Boxing it would shrink the +// enum, but this type is moved once per form submit and never in a hot +// path, so the indirection buys nothing real. +#[allow(clippy::large_enum_variant)] pub enum ConfigForm { Main(bool, MainConfigDto), Api(bool, ConfigApiDto), diff --git a/frontend/src/app/components/config/config_update.rs b/frontend/src/app/components/config/config_update.rs index 82791247f..bedce9b25 100644 --- a/frontend/src/app/components/config/config_update.rs +++ b/frontend/src/app/components/config/config_update.rs @@ -159,7 +159,7 @@ mod tests { use shared::model::{ view_type::ViewType, ConfigDto, ContentSecurityPolicyConfigDto, DiskAlertConfigDto, HdHomeRunConfigDto, HdHomeRunDeviceConfigDto, LibraryConfigDto, LibraryScanDirectoryDto, MessagingConfigDto, - MetadataUpdateConfigDto, MsgKind, ProxyConfigDto, RecordingConfigDto, RecordingDiskConfigDto, + MetadataUpdateConfigDto, ProxyConfigDto, RecordingConfigDto, RecordingDiskConfigDto, RecordingNotificationConfigDto, RecordingQuotaConfigDto, RecordingRetentionConfigDto, StreamInfoConfigDto, VideoConfigDto, VideoDownloadConfigDto, WebAuthConfigDto, WebUiConfigDto, }; @@ -540,7 +540,7 @@ mod tests { vec![ConfigForm::Messaging( true, MessagingConfigDto { - notify_on: vec![MsgKind::DiskAlert], + notify_on: vec!["system.disk.alert".to_string()], disk_alert: Some(DiskAlertConfigDto::default()), ..Default::default() }, @@ -563,7 +563,7 @@ mod tests { vec![ConfigForm::Messaging( true, MessagingConfigDto { - notify_on: vec![MsgKind::DiskAlert], + notify_on: vec!["system.disk.alert".to_string()], disk_alert: Some(disk_alert.clone()), ..Default::default() }, diff --git a/frontend/src/app/components/config/messaging_config_view.rs b/frontend/src/app/components/config/messaging_config_view.rs index b551ef298..c83066274 100644 --- a/frontend/src/app/components/config/messaging_config_view.rs +++ b/frontend/src/app/components/config/messaging_config_view.rs @@ -16,10 +16,10 @@ use crate::{ i18n::use_translation, }; use shared::model::{ - DiscordMessagingConfigDto, DiskAlertConfigDto, MessagingConfigDto, MsgKind, PushoverMessagingConfigDto, + DiscordMessagingConfigDto, DiskAlertConfigDto, MessagingConfigDto, PushoverMessagingConfigDto, RestMessagingConfigDto, TelegramMessagingConfigDto, }; -use std::{rc::Rc, str::FromStr}; +use std::rc::Rc; use yew::prelude::*; const LABEL_NOTIFY_ON: &str = "LABEL.NOTIFY_ON"; @@ -49,7 +49,7 @@ generate_form_reducer!( BotToken => bot_token: String, ChatIds => chat_ids: Vec, Markdown => markdown: bool, - Templates => templates: std::collections::HashMap, + Templates => templates: std::collections::HashMap, } ); @@ -60,7 +60,7 @@ generate_form_reducer!( Url => url: String, Method => method: Option, Headers => headers: Vec, - Templates => templates: std::collections::HashMap, + Templates => templates: std::collections::HashMap, } ); @@ -69,7 +69,7 @@ generate_form_reducer!( action_name: DiscordMessagingConfigFormAction, fields { Url => url: String, - Templates => templates: std::collections::HashMap, + Templates => templates: std::collections::HashMap, } ); @@ -97,10 +97,17 @@ generate_form_reducer!( state: MessagingConfigFormState { form: MessagingConfigDto }, action_name: MessagingConfigFormAction, fields { - NotifyOn => notify_on: Vec, + NotifyOn => notify_on: Vec, } ); +/// Uppercased, underscore-separated form of an event id, for i18n keys and +/// field ids. `recording.completed` becomes `RECORDING_COMPLETED`, which is +/// also the legacy `MsgKind` suffix - so existing translations still resolve. +fn event_label_suffix(event: &str) -> String { event.replace('.', "_").to_uppercase() } + +fn event_label_key(event: &str) -> String { format!("LABEL.MSG_KIND_{}", event_label_suffix(event)) } + #[component] pub fn MessagingConfigView() -> Html { let translate = use_translation(); @@ -128,13 +135,17 @@ pub fn MessagingConfigView() -> Html { let messaging_state = use_reducer(|| MessagingConfigFormState { form: MessagingConfigDto::default(), modified: false }); - let notify_on_options = - use_memo((), |()| vec![MsgKind::Info, MsgKind::Stats, MsgKind::Error, MsgKind::Watch, MsgKind::DiskAlert]); - - let notify_on_options_text = use_memo((*notify_on_options).clone(), |options: &Vec| { - options.iter().map(ToString::to_string).collect::>() + // Driven by the event registry rather than a hardcoded list, so an + // event added in the backend shows up here without a frontend change. + let notify_on_options = use_memo((), |()| { + shared::model::notification::registry::ALL + .iter() + .map(|descriptor| descriptor.id.as_str().to_string()) + .collect::>() }); + let notify_on_options_text = notify_on_options.clone(); + { let dependencies = ( messaging_state.form.clone(), @@ -165,7 +176,9 @@ pub fn MessagingConfigView() -> Html { // exists. Keep both in sync so checking the chip enables // alerts (default thresholds when untouched) and unchecking // removes the block. - form.disk_alert = if form.notify_on.contains(&MsgKind::DiskAlert) { Some(da) } else { None }; + let subscribed = shared::model::notification::EventSubscription::parse(form.notify_on.iter()) + .matches(shared::model::notification::registry::SYSTEM_DISK_ALERT); + form.disk_alert = if subscribed { Some(da) } else { None }; let modified = mm || tm || rm || pm || dm || dam; ConfigForm::Messaging(modified, form) @@ -227,18 +240,13 @@ pub fn MessagingConfigView() -> Html { }); } - let render_templates_view = |templates: &std::collections::HashMap| { + let render_templates_view = |templates: &std::collections::HashMap| { if templates.is_empty() { html! {} } else { let template_fields = templates .iter() - .map(|(kind, template)| { - config_field_custom!( - translate.t(&format!("LABEL.MSG_KIND_{}", kind.wire_name().to_uppercase())), - template.clone() - ) - }) + .map(|(kind, template)| config_field_custom!(translate.t(&event_label_key(kind)), template.clone())) .collect::(); html! {

@@ -365,7 +373,7 @@ pub fn MessagingConfigView() -> Html { .map(|t| { let is_selected = msg_state.form.notify_on.contains(t); let class = if is_selected { "tp__text-button primary" } else { "tp__text-button" }; - html! { } + html! { } }) .collect::(); html! { @@ -391,18 +399,18 @@ pub fn MessagingConfigView() -> Html { let render_edit_mode = || { let msg_state = messaging_state.clone(); - let notify_on_selections = Rc::new(msg_state.form.notify_on.iter().map(ToString::to_string).collect()); + let notify_on_selections = Rc::new(msg_state.form.notify_on.clone()); let telegram_template_fields = notify_on_options .iter() .map(|kind| { - let kind_str = translate.t(&format!("LABEL.MSG_KIND_{}", kind.wire_name().to_uppercase())); + let kind_str = translate.t(&event_label_key(kind)); let current_val = telegram_state.form.templates.get(kind).cloned().unwrap_or_default(); let telegram_state = telegram_state.clone(); - let kind = *kind; + let kind = kind.clone(); html! {