mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-16 06:02:27 +02:00
develop
915
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c2a636282c | ci: bump version v3.3.113 | ||
|
|
97779c54d5 | bumped version to 3.3.112 | ||
|
|
37d2732cf9 | ci: bump version v3.3.111 | ||
|
|
2833fba738 | ci: bump version v3.3.110 | ||
|
|
33ed942c16 |
feat(update): add per-cluster quality safeguards (#855)
* feat(update): add per-input actions and pipeline transparency Squash feature/cluster-update-quality onto upstream/develop. Include request-local update policies, persisted input and cluster status, library rescan and target rebuild integration, and the update-card view. Align the M3U request regression with the existing Force capability. * del(build): temporary dev changes * fix(update): address playlist review findings * fix(update): rename Shows to Series * Playlist update refactor * Playlist update refactor --------- Co-authored-by: euzu <euzu@proton.me> |
||
|
|
da9c03a688 | ci: bump version v3.3.109 | ||
|
|
019fb7fde2 | ci: bump version v3.3.108 | ||
|
|
079c5c7a10 |
fix: require a user-supplied Trakt client ID (#859)
remove the bundled Trakt Client ID and every fallback to it while keeping the compatible trakt.api.api_key field trim supplied Client IDs, reject blank/header-invalid values before request construction, and skip only optional Trakt curation with one target-scoped warning translate Trakt 401/403/404/429 responses explicitly, distinguish list/chart 404s, and preserve partial success across configured sources disable the checked-in Trakt example until an explicit Client ID is configured and update docs and Source Editor terminology |
||
|
|
89d45c5657 | ci: bump version v3.3.107 | ||
|
|
806dd574d3 |
User Agent (#861)
New Features
Added an option to append a stable, numeric stream index to upstream User-Agent headers.
Stream indexes remain consistent throughout a playback session, including manifests, segments, reconnects, seeks, and provider failover.
Added a configuration checkbox for enabling the feature.
Documentation
Documented the new configuration option, including session behavior and restart handling.
Added English, Arabic, and Russian translations for the setting.
Co-authored-by: euzu <euzu@proton.me>
|
||
|
|
3a3b178807 | ci: bump version v3.3.106 | ||
|
|
1970796de9 | ci: bump version v3.3.105 | ||
|
|
5b27080f82 | ci: bump version v3.3.104 | ||
|
|
c592411409 |
Fix/provider priority (#856)
Bug Fixes
Improved provider capacity management and reservation handling for shared connections.
Added support for excluding providers during connection allocation.
Improved grace-period expiration and concurrent access handling.
Preserved reserved capacity during fallback and preemptive allocations.
Chores
Improved unused-dependency checks and command configuration.
Removed unused dependencies across multiple components.
Added exceptions for dependencies used indirectly through macros or renamed packages.
Tests
Added coverage for provider reservations, fallback allocation, grace-period expiry, and concurrent grace access.
|
||
|
|
d673b47c88 | ci: bump version v3.3.103 | ||
|
|
d116b7d51e |
Feature/bouquet editor (#854)
New Features
Added target-specific bouquet filtering with whitelist and blacklist modes for Live, VOD, and Series groups.
Added an in-context bouquet editor with search, selection controls, status indicators, reset, and streaming previews.
Targets are managed by name, with saved selections available through the Source Editor.
Improved stream alias resolution and provider failover behavior.
Bug Fixes
Existing playlists are preserved when refreshes produce no usable items.
Empty playlists are no longer published.
Configuration changes now recover more safely from persistence failures.
Documentation
Updated feature and configuration documentation and added translations.
|
||
|
|
bbacdc7f75 | ci: bump version v3.3.102 | ||
|
|
a3c97c7754 | ci: bump version v3.3.101 | ||
|
|
696060bca6 |
Fix M3U alias credential rewriting on provider fallback (#851)
* Fix M3U alias credential rewriting on provider fallback Rewrite opaque authentication query parameters when stream allocation switches from the primary M3U provider to an alias. Support unambiguous cross-key mappings such as token to api_key while preserving unrelated query parameters and rejecting ambiguous mappings. Add regression coverage for URL rewriting and provider allocation. |
||
|
|
8d1ea0737b | ci: bump version v3.3.100 | ||
|
|
3aaa5744eb |
Fix/webui (#850)
New Features
Added translated labels for notification events in English, Arabic, and Russian.
Added plan summaries, inheritance and override indicators, filter notices, and unlimited-value labels to proxy user forms.
Radio button groups can now wrap responsively on smaller screens.
Bug Fixes
Creating a new user list no longer retains the previously selected user.
Improved plan defaults, trial status handling, and form stability when switching plans or refreshing server lists.
UI Improvements
Improved responsive sizing and overflow behavior across tables, panels, lists, and user-management views.
|
||
|
|
1a7d46ac7d | ci: bump version v3.3.99 | ||
|
|
df649bc1f1 | ci: bump version v3.3.98 | ||
|
|
99e5044599 |
Feature/refactor (#848)
* **New Features** * Added HLS live and catch-up playback for archived streams, manifests, segments, and provider failover. * Improved playlist processing with filtering, renaming, mapping, EPG handling, favourites, watch groups, staged overlays, and disk-based processing. * **Documentation** * Added guidance for environment variables, credentials, password hashes, and authentication secrets. * **Changes** * Removed target editing from the target menu; refresh and delete remain available. * **Bug Fixes** * Improved recovery when cached playlist data is unreadable and strengthened archive stream URL handling. |
||
|
|
769e3cbad1 | ci: bump version v3.3.97 | ||
|
|
5ecfd68148 |
Staged filter and safe yaml update (#846)
New Features
Added separate processing and persistence filters, with empty/non-empty checks and expanded filter fields.
Added persistence-stage filter controls in target settings.
Added clear_invalid_epg_ids, preserving playlist entries while clearing unresolved EPG IDs; the legacy setting remains supported.
Added safer, atomic source configuration updates that preserve comments and formatting.
Bug Fixes
Null-like Xtream and Stalker values are now handled correctly.
Duplicate credential warnings provide clearer details.
|
||
|
|
e7b5fb2748 | ci: bump version v3.3.96 | ||
|
|
cfacd9b349 | ci: bump version v3.3.95 | ||
|
|
a683927ece |
new attribute for target, required_epg to remove all live channels without epg, if at least one epg source is defined. (#841)
New Features
Added a target option to require valid EPG programme data for live entries.
Entries without matching EPG data are filtered out when enabled; non-live entries remain unaffected.
Added support for configuring this option in the target editor, with localized labels and explanations.
Documentation
Documented the new setting, including its default behavior and handling when no EPG source is available.
|
||
|
|
ed89117633 | ci: bump version v3.3.94 | ||
|
|
d1d5c71190 | ci: bump version v3.3.93 | ||
|
|
84f66a07b8 |
Static dispatch refactor (#840)
* refactor(repository): return concrete playlist streams instead of Box<dyn Stream>
The three raw-playlist iterators each wrapped an already-concrete type in a
trait object: m3u produced a LockedReceiverStream and xtream/stalker produced a
ReceiverStream, then boxed it behind `dyn Stream + Send + Unpin`. Nothing in the
workspace stored these heterogeneously or needed object safety, so the box was
one allocation and one vtable per call for no benefit.
Name the concrete types in the signatures instead:
iter_raw_m3u_{target,input}_playlist -> LockedReceiverStream<Result<M3uPlaylistItem, _>>
iter_raw_xtream_{target,input}_playlist -> ReceiverStream<Result<XtreamPlaylistItem, _>>
iter_stalker_{items,series_roots} -> ReceiverStream<StalkerPlaylistItem>
Both concrete types are Stream + Unpin, so every caller keeps compiling
unchanged; the `futures::Stream` / `tokio_stream::Stream` imports are now unused
in two of the three files and are dropped.
Verified with cargo check on tuliprox-repository and tuliprox plus nightly fmt.
Not verified by tests.
* refactor(repository): extract PlaylistBackend so M3U and Xtream share one iterator
iter_raw_m3u_playlist and iter_raw_xtream_playlist were two transcriptions of
one design: the same two read locks, the same spawn_blocking producer feeding a
bounded channel, the same sorted-index reader with per-entry error logging,
differing only in the item type, the storage subdirectory and which
TuliproxError variant wrapped a failure. The storage-path pair
(m3u|xtream)_get_storage_path and ensure_(m3u|xtream)_storage_path were
identical modulo one const and one error constructor.
New `playlist_backend` module names those differences as associated types and
consts, so the shared body is written once:
trait PlaylistBackend { type Item; type SortKey; SUBDIR; LABEL;
HOLD_ITER_LOCK; repo_error(); storage_path() }
struct M3u; struct Xtream; // zero-sized markers
iter_raw_playlist<B, K, F>(..) -> Option<LockedReceiverStream<..>>
Dispatch is entirely static: the markers are ZSTs, the item filter is an
`impl Fn` monomorphized per call site, and the B+Tree key stays a function
parameter because M3U keys its target store by u32 and its input store by
Arc<str> — so the key is not a property of the backend.
Two deliberate non-unifications:
* Stalker is not a PlaylistBackend. Its store is keyed by input rather than
target, has no sorted index, and yields bare items rather than Results.
Folding it in would add methods that exist only to return None for one of
three implementors.
* HOLD_ITER_LOCK preserves an existing behavioural difference rather than
normalising it. M3U holds a read lock for the consumer's lifetime; Xtream
never did. Changing when that lock is released would change what a concurrent
writer can do mid-iteration, which is not this commit's business.
The only behaviour change is that M3U now also logs a reader-open failure
before forwarding it, which Xtream already did.
Net: 151 lines of duplicated logic in the two repositories replaced by one
shared implementation. Verified with cargo check --workspace and nightly fmt.
Not verified by tests.
* refactor(repository): replace boxed playlist iterators with concrete enums
PlaylistSourceOps is a private trait that is never used as a trait object --
PlaylistSource has always dispatched through the PlaylistSourceKind enum, and no
Box<dyn PlaylistSourceOps> exists anywhere in the workspace. Its three iterator
methods nevertheless returned Box<dyn Iterator<..> + Send + '_>, so the hottest
traversal in the repository paid one allocation per call plus one indirect,
uninlinable call per playlist item. The skip-set filter boxed a second time on
top of that.
The erasure could not simply be deleted: the bodies were composed adapter chains
(flat_map(..).map(..)) whose closure types cannot be named. New `playlist_items`
module replaces them with hand-rolled state machines whose types *are* nameable:
BTreeValues<'a, K, V> one B+Tree store, logging and skipping
unreadable entries (was filter_map(..))
BTreeStores<'a, K, V, const N> N stores back to back (was chain(..)):
N=1 single-file, 3 Xtream, 4 Stalker
MemoryDrain / MemoryItemsMut / MemoryItems the in-memory flat_map shapes
SourceItems / SourceCowItems / SourceItemsMut one variant per source kind
ClusterFiltered<I> skip-set filter, folded in rather than
wrapped -- a filtered traversal now
allocates nothing at all
PlaylistSource::into_items() -> ClusterFiltered<SourceItems<'_>>
Also in this commit, since all of it is the same erasure:
* update_playlist and obtain_resources drop BoxFuture for `async fn` (AFIT,
stable since 1.75). Static dispatch means auto-traits still propagate.
* The two Vec<(XtreamCluster, Box<dyn Iterator<..>>)> locals in take_groups
become Vec<(XtreamCluster, BTreeValues<..>)> -- every reader in those vecs
already had the same concrete type.
* A `dispatch!`/`dispatch_await!` macro pair replaces the hand-written
forwarding arms, and doubles as the conformance check the trait used to
provide: a variant missing a method fails to compile at the macro.
playlist_source.rs now contains zero `Box<dyn` and zero BoxFuture. Behaviour is
preserved, including the read-only warning on disk sources (now emitted where
the empty iterator is built) and the verbatim B+Tree skip-entry log message.
FetchedPlaylist forwards the concrete types rather than re-boxing them.
Verified with cargo check --workspace and nightly fmt, plus a structural check
that all five PlaylistSourceOps impls still carry their original method sets.
Not verified by tests.
* refactor(shared): key field access on a typed enum instead of a string
PlaylistItemHeader had two field-access paths and only one of them was cheap.
The filter engine in foundation/value_provider.rs matched on the typed
ItemField enum and returned borrowed &str. The mapper, sort and counter paths
went through FieldGetAccessor::get_field(&str), which walked a chain of ~20
eq_ignore_ascii_case comparisons and returned an owned Arc<str> -- so `chno`
and `type` did `.to_string().intern()`, a heap allocation *and* an interner
write lock, on every read, per item, per rule.
Adds the typed accessor the enum path deserved:
HeaderField every field addressable by name, with parse()/as_str()
FieldRef<'a> Shared(&Arc<str>) | Str(&str) | Num(u32) -- a read
never forces an allocation
FieldGet / FieldSet typed traits; the impl is a match on a discriminant
genre_ref! borrowing sibling of get_genre!
HeaderField is deliberately *wider* than ItemField rather than an extension of
it. ItemField is the config-facing vocabulary, and widening it would widen what
a user may write in a filter or sort rule; the accessor's domain is genuinely
larger (logo_small, audio_track and friends are addressable by name by the M3U
resource endpoint without being valid config fields). ItemField::header_field()
maps between them, returning None for Quality, which is derived from the caption
rather than stored.
FieldGetAccessor/FieldSetAccessor are kept as #[inline] shims over the typed
traits, so callers whose field name genuinely arrives as a string keep working
and keep their exact previous behaviour, interning of numbers included.
Migrated every call site that already held type information:
* sort.rs held an ItemField and called provider.get(field.as_ref()) -- turning
a typed enum into a string to do a linear string lookup. Now get_typed().
* MappingCounter.field becomes HeaderField, parsed once in prepare() instead of
re-parsed per channel inside the counter loop.
* trakt.rs's "caption" literals become HeaderField::Caption.
Left on the &str path deliberately: M3uPlaylistItem and XtreamPlaylistItem keep
their own string accessors. Their only caller is the M3U resource endpoint,
where the field name arrives in the URL and the lookup happens once per request,
not per item.
Behaviour is preserved throughout, including which fields are settable (input,
type and provider_id still return false) and which are absent per type (`id` is
a header field, `provider_id` an M3U one).
Verified with cargo check --workspace --all-targets and nightly fmt.
Not verified by tests.
* refactor(config): give HLS timing config units in the type system
Roughly 245 config and state fields across the workspace encode their unit as a
naming convention, and several sit adjacent inside the same struct.
HlsCacheConfigDto is the clearest case: origin_manifest_timeout_ms and
origin_segment_timeout_ms sit two lines above initial_manifest_wait_timeout_secs,
all four u64, nothing stopping a millisecond value reaching a seconds parameter.
Worse, cache_duration and session_idle_timeout do not carry the unit in their
names at all -- both are seconds, which is only discoverable from their defaults.
Adds Millis and Secs in shared/src/model/config/time_units.rs:
#[repr(transparent)] same layout as the u64 they replace -- no size cost,
no indirection, no allocation
#[serde(transparent)] same serialized form, so the YAML/JSON shape is
byte-identical and this lands with no config migration
and nothing user-visible
Conversions are methods, not From impls: `Secs -> Millis` is a multiplication
that can overflow, and forcing it to be spelled out at the call site is the
point of having the types. `as_duration_at_least_1ms` collects the `.max(1)`
that several origin deadlines applied individually, because they treat a zero
timeout as "do not wait" rather than "wait forever".
Applied to the six HLS timing fields on HlsCacheConfigDto and
HlsSegmentRepairConfigDto and, importantly, to their runtime counterparts in
tuliprox-core's HlsCacheConfig, so the type survives the Dto -> runtime hop
rather than being unwrapped at the boundary. The validation helper
ensure_min_u64 splits into ensure_min_millis / ensure_min_secs so a bound cannot
be compared against the wrong unit either.
Two manual secs->ms multiplications in backend/hls (gc.rs and manager.rs's
transient_resource_ttl_ms) become `.as_millis()`, which is where the conversion
was silently open-coded before.
Deliberate boundary: the structs inside backend/hls that do arithmetic on raw
millisecond counts keep u64 and take `.get()` at the edge. Pushing the newtypes
through those is the next slice; doing it here would have made this commit touch
most of the crate for no additional safety at the config surface.
Includes tests asserting the serialized form is a bare number, that layout is
transparent over u64 (Option<Millis> included), and that Secs::as_millis
saturates.
Verified with cargo check --workspace --all-targets and nightly fmt.
Not verified by tests.
* chore(lint): satisfy the nightly clippy gate after the static-dispatch work
Three findings from `cargo +nightly-2026-05-01 clippy --workspace -- -D warnings`:
* doc_markdown on a macro doc comment in shared/src/model/playlist.rs.
* clone_on_copy in the frontend's mapper counter view, now that
MappingCounter::field is a Copy HeaderField rather than a String.
* large_enum_variant on SourceItems and SourceCowItems, allowed with a
justification rather than fixed. The B+Tree disk iterators carry sizeable
cursor state, so holding three or four inline makes the enum ~1.9KB, and
clippy's suggested fix -- boxing the large variants -- would reintroduce
exactly the per-traversal heap allocation the type exists to remove. The cost
is stack space for one value per traversal, not per item: the enum is built
once and then driven through &mut, so it is never copied per element.
Gates now green: cargo build --workspace, clippy --workspace and
clippy --workspace --all-targets at -D warnings, fmt --all --check, and
bin/check-workspace-deps.sh (78 edges).
* refactor(shared): split TuliproxError into a Copy kind and a message
All 50 variants carried exactly one String and nothing else, so this was never
an enum of errors -- it was a category tag beside a message. Encoding it as an
enum cost a 50-arm `message()` whose only job was to return the payload, plus a
second 50-name list in `is_notify()` that had to be kept in sync by hand, and
made the category impossible to compare, store or return on its own.
enum ErrorKind { Config, ConfigApp, ... } // fieldless, Copy, Eq, Hash
struct TuliproxError { kind: ErrorKind, message: Box<str> }
An `error_kinds!` macro declares each category once -- variant, display label
and constructor -- so the label table and the notify set can no longer drift
apart. `message()` is now one field access, `is_notify()` delegates to the kind,
and `kind()` exposes the category directly.
The 755 construction sites are untouched. A tuple-variant constructor and an
associated function are invoked with identical syntax, so `TuliproxError::
Config(msg)` still compiles once `Config` is an `#[allow(non_snake_case)]`
associated fn. The non-snake-case names are the deliberate price of splitting
the type without rewriting every call site in one commit; renaming them to
`config()` style is a separate, mechanical follow-up.
Only the 10 sites that pattern-matched on a variant needed changing, and they
read better for it -- `err.kind() == ErrorKind::ApiXtream` instead of
`matches!(err, TuliproxError::ApiXtream(_))`, and for the two that also
inspected the payload, an explicit `kind()` plus `message().contains(..)`.
Two incidental improvements: the constructors take `impl Into<Box<str>>`, so
call sites holding a `&str` no longer need `.to_string()` (two test sites drop a
now-ambiguous `.into()`); and the error is one word smaller than the enum was.
Tests pin the behaviour that had to be preserved: the `"label: message"` Display
shape for four categories, that `message()` excludes the label, that both &str
and String are accepted, and the exact set of notifying categories.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt, and the new unit tests.
* refactor(core): add a Clock seam and collapse 14 copies of current_time_millis
`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. New tuliprox-core::utils::clock holds
the single copy, plus the trait that makes "what time is it" injectable:
trait Clock { fn now_ms(&self) -> Millis }
struct SystemClock; // ZST: no field, no vtable, no allocation
struct ManualClock(Arc<AtomicU64>); // deterministic; the Arc is confined here
Clock is meant to be held as a generic parameter defaulted to the ZST --
`struct Deadlines<C: Clock = SystemClock>` -- never as Arc<dyn Clock>. A test
asserts SystemClock is zero-sized and that owning one leaves a struct's layout
unchanged, which is the property that makes the seam free.
Scope is smaller than it first looked, and the reason is worth recording: 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, and passing the instant in
is a better pattern than reaching for a clock -- so they are left alone. The
trait is for the callers that have to *produce* the instant.
HlsTerminalCommitClock is deliberately left alone too. It fakes time with an
AtomicU64 sentinel, costing an atomic load per production read, and would be a
natural fit -- but it is owned by HlsProxy, so making it generic would push a
type parameter onto HlsProxy and from there onto AppState. A type parameter that
reaches the root state is exactly the case where the status quo wins; the module
docs say so, so the next person does not have to rediscover it.
The 14 call sites now import the shared function, preserving each one's original
visibility (two were pub(super) and re-export as such).
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt, and 4 new unit tests.
* refactor(shared): give config preparation one shape via a Prepare trait
Config types are deserialized first and resolved second -- templates expanded,
regexes compiled, filters parsed, derived fields computed. That second phase was
spread across ~98 inherent `prepare`/`validate` methods with no agreed
signature: some took nothing, some `Option<&[PatternTemplate]>`, some a storage
dir, a device number, a port or an `include_computed: bool`, returning variously
`()`, `Result<(), TuliproxError>`, `Result<(), &'static str>` or `bool`.
Because the shape was invisible, the recursive walk had to be hand-written at
every level, and a new config struct that forgot to call its children's prepare
failed silently at runtime rather than at compile time.
trait Prepare { type Ctx<'a>: Copy; fn prepare(&mut self, Self::Ctx<'_>) -> Result<(), TuliproxError> }
The context is an associated type, so a node needing pattern templates and a
node needing a port are both implementors without a lowest-common-denominator
argument list, and dispatch stays static -- the GAT is monomorphized per
implementor, with no trait object anywhere.
Migrated the 14 methods that already shared the exact signature
`prepare(&mut self, Option<&[PatternTemplate]>) -> Result<(), TuliproxError>`:
MapperOperation, MapperDto, MappingDto, MappingDefinitionDto, MappingsDto,
ConfigRenameDto, ConfigSortRuleDto, ConfigSortDto, ConfigFavouritesDto,
ConfigInputOptionsDto, and the four target-output types. `Ctx<'a>` is
`Option<&'a [PatternTemplate]>` for all of them, so they now visibly share a
contract instead of coincidentally sharing an argument list.
Blanket impls for Vec, slices, Option and Box are the payoff: two hand-written
child walks are gone, including a nested `Option<Vec<MapperDto>>` that took an
`if let` plus a `for` and is now one line.
Two things deliberately left alone:
* The `handle_tuliprox_error_result_list!` call sites in sort.rs and target.rs
aggregate every child's error rather than stopping at the first. Switching
them to the blanket impl would silently reduce a config report listing all
problems to one naming only the first. Aggregation is the better behaviour for
config validation, so those walks stay until Prepare can express it.
* The loop in ConfigTargetDto that prepares each output also counts output kinds
as it goes, so the prepare call is incidental to a loop doing more.
Includes 5 unit tests covering the blanket impls: same context to every child,
short-circuit on first failure matching the previous `?` behaviour, absent
Option as a no-op, nesting, and Box forwarding.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt, and 970 tests across shared, core and config-loader.
* refactor(shared): add PrepareAll so config errors aggregate again
Prepare's collection impls short-circuit on the first failing child, which is
right for a nested walk but wrong for config validation: a user with three bad
sort rules should hear about all three in one pass, not fix them one round-trip
at a time. That is why the two remaining hand-written walks in sort.rs and
target.rs still used handle_tuliprox_error_result_list! after the Prepare
migration -- switching them to the blanket impl would have silently degraded a
config report to its first line. This closes that gap.
trait PrepareAll: Prepare { fn prepare_all(&mut self, Self::Ctx<'_>) -> Result<(), TuliproxError> }
Aggregation is byte-identical to the macro's: collect each failure's rendered
message, join with newlines, wrap in TuliproxError::Errors. Impls for Vec, [T],
Option and Box, so `Option<Vec<ConfigRenameDto>>` nests without a hand-written
`if let` plus loop -- target.rs's three-line walk becomes one line.
With both call sites converted the macro is dead, so it is removed along with
its re-export. `get_errors_notify_message!` next to it is still used by the
playlist processor and stays.
Three tests cover what the macro guaranteed: every failing child reported (not
just the first), every child still run despite earlier failures, and nesting
through Option.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt, and 973 tests across shared, core and config-loader.
* refactor(shared): drop the field-accessor macros for explicit typed impls
Three macros generated the by-name field accessors. My earlier read of them as
"near-identical" was wrong in one important way, and the correction is the most
valuable part of this commit.
XtreamPlaylistItem's accessor is not a variant of the other two: it carries a
~80-line prefix-matched lookup into additional_properties for Xtream cover and
backdrop_path resources. It also has **no callers**. FieldGetAccessor is never a
generic bound and never a trait object, and the only call site in the workspace
is the M3U resource endpoint calling M3uPlaylistItem::get_field. So the whole
impl was unreachable; it is deleted rather than ported. The XC_PROP_* constants
it referenced are still used elsewhere, and the logic is in git history if the
Xtream resource endpoint ever needs cover-by-name.
The remaining two are collapsed onto the typed HeaderField/FieldGet/FieldRef
path from the earlier field-access work, written out explicitly instead of
macro-generated. A macro that exists to share ten repetitive match arms between
two types is not paying for itself: the explicit arms are the same length and
you can read them without expanding anything. M3uPlaylistItem also stops walking
a chain of eq_ignore_ascii_case comparisons and stops interning `chno` on every
read.
One behaviour is deliberately preserved rather than "fixed": M3uPlaylistItem
carries input_name, item_type and additional_properties, but none of them were
ever addressable by name, and the M3U resource endpoint resolves a URL path
segment through get_field -- so making them resolvable would turn a 404 into a
response. The explicit `Id | Input | Type | Genre => None` arm says so, and a
test asserts it.
Net 104 lines removed from playlist.rs, and zero accessor macros remain.
Two new tests: every HeaderField variant round-trips through parse/as_str
case-insensitively (with the epg_id alias), and the M3U item resolves
provider_id, name, chno and caption while refusing the header-only names.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt, and the shared test suite.
* refactor(config): resolve ByteSize into a Bytes newtype once
`ByteSize(String)` was parsed with `parse_bytes() -> Result<u64, String>` at 15
sites, and the parsed value was typed as a bare u64 wherever it landed -- so a
resolved size was indistinguishable from any other u64, and a runtime struct
could hold either the string or the number with nothing marking which.
Adds `Bytes(u64)` beside `ByteSize`: #[repr(transparent)], #[serde(transparent)],
Copy. `parse_bytes()` now returns it, and the runtime structs hold it:
HlsCacheConfig::{cache_bytes, cache_bytes_per_session} u64 -> Bytes
FfprobeConfig::{probe_size_bytes, live_probe_size_bytes} u64 -> Bytes
`Bytes::at_least_1()` collects the `.max(1)` that probe sizes applied
individually, because they treat 0 as "unset" rather than "no bytes".
One correction to how I pitched this. I described FfprobeConfig's parallel
`probe_size: ByteSize` / `probe_size_bytes: u64` fields as redundant. They are
not: `From<&FfprobeConfig> for FfprobeConfigDto` reads the string form to send
the user's own spelling back to the web UI, so someone who wrote `10MB` sees
`10MB` rather than 10485760. Both fields stay, and a test records why so the
string one does not get "cleaned up" later.
Nor is this a performance change -- the parses happened at config load, not per
request. The win is that the resolved value now has a type: `Bytes` cannot be
passed where some unrelated u64 is wanted, and a runtime struct can no longer
hold an unparsed size by accident. Boundaries that genuinely need the number
(the ffmpeg CLI arg, the HLS cache-limit setter, the GC policy) take `.get()`
explicitly, as with Millis/Secs.
Two tests: transparent layout over u64 including Option, at_least_1 flooring,
and the ByteSize/Bytes division of labour.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt.
* perf(parser): drop redundant Arc clones in the Xtream per-item parse loop
Inside `for stream in xtream_streams`, four fields wrapped a clone around a
method that already returns an owned value:
name: Arc::clone(&stream.get_name()) -> get_name() -> Arc<str>
logo: Arc::clone(&stream.get_stream_icon()) -> Arc<str>
title: Arc::clone(&stream.get_name()) -> Arc<str>
epg_channel_id: stream.get_epg_channel_id().clone() -> Option<Arc<str>>
Each one bumped the refcount to two and then dropped the temporary back to one:
four redundant atomic pairs per parsed Xtream stream, on the playlist parse path.
The same file already builds a header correctly without the outer clones 230
lines further down, so this was drift rather than intent.
The workspace now has zero `Arc::clone(&x.y())` sites. The three remaining
`.get_*().clone()` calls are correct -- those accessors return references
(`&Arc<str>`, `&Url`) and the clone is what makes the value owned.
Scope note: this is the substance of what I proposed as "borrowing accessors on
PlaylistEntry", but not the mechanism, because the mechanism did not survive
contact with the call sites. Of 23 `get_input_stream_id` calls, 19 are test
assertions; `get_provider_url` and `get_group` have one production caller each,
and the per-item `get_name` callers need an owned Arc to store. Adding four
borrowing methods to an already 13-method trait to save a refcount bump at ~4
non-hot call sites would grow the API surface for no measurable gain. Where a
caller genuinely only reads, borrowed access already exists via
`FieldGet`/`FieldRef::Shared` from the typed field-access work.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt.
* refactor(shared): type the mapper and counter field allow-lists
COUNTER_FIELDS and MAPPER_FIELDS were `&[&str]` checked by a case-sensitive
string `contains` behind a `valid_property!` macro that added nothing over
`.contains()`. Both are now `&[HeaderField]`.
Three things fall out.
MAPPER_FIELDS loses an entry. The string list spelled the EPG channel id twice,
`epg_channel_id` and `epg_id`, to cover both accepted names. Aliases resolve in
HeaderField::parse, so the typed list names the field once and each spelling is
handled in exactly one place.
The counter path stops doing the same work twice. Since MappingCounter::field
became a HeaderField, prepare() ran a string `contains` and then parsed the same
name again; it is now one resolve-and-check.
`valid_property!` is deleted along with its export, replaced by
`is_allowed_field(name, allowed)`.
One deliberate behaviour change, called out because it loosens validation: the
allow-list was case-sensitive while set_field compared case-insensitively, so a
mapper naming `NAME` was rejected at config load even though writing it would
have worked. Resolving through parse makes the two agree. Every previously valid
config stays valid; some previously rejected ones are now accepted and behave
correctly.
Three tests: both EPG spellings resolve through the single entry, valid-but-
unlisted fields (`input`, `type`) are still rejected and COUNTER_FIELDS is a
strict subset of MAPPER_FIELDS, and casing now agrees with the accessor.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt.
* refactor(shared): put genre on StreamProperties instead of in three macros
Video keeps its genre under `details`, Series keeps it inline, and Live and
Episode do not have one. That four-arm match was written out five times: in
`get_genre!`, in `genre_ref!`, in the Some-branch of `set_genre!`, inline in
`get_filter_value`, and inline in the header's typed field accessor.
Two methods on StreamProperties replace all of it:
fn genre(&self) -> Option<&Arc<str>>
fn set_genre(&mut self, value: &str) -> bool
`get_genre!` and `genre_ref!` are deleted outright -- one call site each, and
both are now `.additional_properties.as_ref().and_then(StreamProperties::genre)`
with an `Arc::clone` only where the caller actually needs to own it.
`set_genre!` stays a macro but loses its four-arm match, dropping from 79 lines
to 64. Its remaining bulk is the None-branch, which constructs a whole
StreamProperties::Video or ::Series *from the header* -- that needs the header,
not just the properties, so it does not belong on StreamProperties and stays
with the caller.
Left alone: ui_playlist_item.rs has the same Video-here/Series-there shape for
`rating`, but with one call site there is no repetition for an accessor to
remove, and adding API for a single caller is the trade this refactor exists to
avoid.
Two tests: genre round-trips through both storage shapes (including that setting
twice reuses existing Video details rather than replacing them), and Live and
Episode report no genre and refuse to take one.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt, and the shared test suite.
* refactor(shared): single-source the item-type to cluster relation
The relation was written down twice and wrapped once:
* `PlaylistItemType::is_cluster(cluster)` matched every variant against a
cluster by hand.
* `impl TryFrom<PlaylistItemType> for XtreamCluster` matched every variant to a
cluster, independently, with nothing keeping the two in agreement.
* `cluster_from_item_type` in the repository wrapped the second with
`.unwrap_or(Live)`.
The TryFrom was total -- every arm returned `Ok` -- so its `Result` was a lie,
and the phantom error had spread defensive noise to 17 call sites across four
crates: `.unwrap_or(XtreamCluster::Live)` eleven times, plus
`.unwrap_or_default()`, `.ok()`, `.is_ok_and(..)`, `.unwrap_or(existing_cluster)`
and one `.map_err(..)?` building an error message that could never be produced.
Now there is one encoding:
PlaylistItemType::cluster(self) -> XtreamCluster // const, infallible
impl From<PlaylistItemType> for XtreamCluster // delegates
is_cluster(cluster) // delegates
All 17 sites drop their fallback. `cluster_from_item_type` is gone. The
`try_cluster!` macro in xtream_repository, whose `ok_or_else` could never fire,
becomes `cluster_or_item_type!` and its four call sites lose a `?`. The STRM
resolve path loses a five-line unreachable error branch.
Note this is source-compatible rather than a breaking change: std's blanket
`TryFrom for U where U: Into<T>` means `XtreamCluster::try_from(item_type)` still
compiles, now with `Error = Infallible`. The old call sites would have kept
working; they are cleaned up because the fallbacks are provably dead, not
because the compiler demanded it.
A test iterates every PlaylistItemType variant and asserts `From` agrees with
`cluster()`, that `is_cluster` accepts its own cluster, and that it rejects the
other two -- so the arms cannot drift apart again.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt.
* refactor(shared): make VirtualId a real newtype and add ProviderId
`pub type VirtualId = u32` gave the reader a name and the compiler nothing. That
matters more than usual here: the same `BPlusTree<u32, XtreamPlaylistItem>` store
is keyed by a *virtual* id on the target path and a *provider* id on the input
path, chosen by a runtime `StorageKey` tag, and nothing stopped a lookup in one
key space using an id from the other.
#[repr(transparent)] #[serde(transparent)]
pub struct VirtualId(pub u32);
pub struct ProviderId(pub u32);
No `From<u32>`/`Into<u32>` on purpose: an implicit conversion would let a
provider id become a virtual id by inference, which is the confusion the types
exist to prevent. Crossing to a raw integer is spelled `new` and `get`.
On-disk compatibility was the gating question, since these are persisted B+Tree
keys and serialized struct fields. `backend/btree/src/codec.rs` gets a test
asserting a transparent newtype over u32 encodes byte-for-byte identically under
rmp_serde and cross-reads in both directions -- bytes written before the newtype
existed decode into it, and bytes it writes decode as a plain u32. Existing
databases are unaffected and there is no migration.
The type now flows through the playlist items (PlaylistItemHeader, M3u, Xtream,
Common), VirtualIdRecord and the whole TargetIdMapping id-index cluster
(disk_by_virtual_id, mem_by_uuid, mem_by_virtual_id, pending upserts,
find_virtual_ids, get_virtual_id_by_uuid, get_and_update_virtual_id), and the
metadata manager's provider->ids and uuid->id caches.
The compiler immediately found what the alias was hiding: the two `StorageKey`
arms in xtream_repository now have *incompatible types*, and
playlist_repository referred to one id-mapping store as both `BPlusTree<u32, _>`
and `BPlusTree<VirtualId, _>`.
Deliberate boundary: cross-boundary DTOs keep their u32 wire shape and unwrap
explicitly with `.get()` -- the Xtream-API-shaped documents, StreamInfo,
UiPlaylistItem, stream history, and the ffprobe/session interfaces. The B+Tree
stores themselves also stay u32-keyed for now: splitting them into VirtualId- and
ProviderId-keyed stores is item 23, and it needs per-site judgement about which
key space each of 24 call sites belongs to. Getting one wrong is a silent lookup
miss, so it is a focused follow-up rather than a rider on this commit.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt, and 1,149 tests across btree, shared, repository and
metadata.
* refactor(repository): make the Xtream store key space a type, not a runtime tag
The Xtream playlist stores use one file layout and one value type for two
different id spaces: the target-side store is keyed by virtual id, the input-side
store by provider id. The only thing recording which was a `StorageKey` enum
matched *per item* inside the insert loop:
tree.insert(match storage_key {
StorageKey::VirtualId => item.virtual_id,
StorageKey::ProviderId => item.provider_id,
}, item);
`write_playlists_to_file` is now generic over the key with a `key_of` extractor,
so each call site names its own key space and the tree's type carries it:
write_playlists_to_file(.., |item| item.virtual_id, ..) // target
write_playlists_to_file(.., |item| ProviderId::new(item.provider_id), ..) // input
`StorageKey` is deleted. The per-item branch is gone -- the extractor is
monomorphized per call site -- and the two key spaces can no longer be swapped
by passing the wrong enum variant. Both keys are `#[serde(transparent)]` over
u32, so the on-disk encoding is identical either way, which the codec test in
backend/btree pins.
This is the write path only. The read-side store types are still
`BPlusTree<u32, XtreamPlaylistItem>`: typing those means classifying 24 call
sites as virtual- or provider-keyed, and misclassifying one is a silent lookup
miss rather than a compile error, so it wants its own focused pass with the
staging/publish paths read end to end.
Verified with cargo check --workspace --all-targets, clippy --all-targets at
-D warnings, nightly fmt, and 422 tests across repository and btree.
* feat(shared): open-world event ids for notifications
`MsgKind` is a closed enum, so adding one notification event kind meant
editing eight sites across three crates - and two of those sites failed
silently rather than at compile time: `discover_templates` hardcodes the
variant list, and Pushover has no template map at all.
Replace it as the extension point. An event is now identified by a dotted
`domain.event` string with a severity, subscriptions are glob patterns,
and adding an event is one `EventId` const plus one emit call.
The pattern grammar stays small enough to explain in a config comment:
`*` for everything, `recording.*` for a domain, `provider.*.expired` for a
single wildcard segment, and a leading `!` to exclude - so `["*",
"!system.info"]` reads the way it looks.
Every legacy `MsgKind` wire name stays a valid `notify_on` entry via
`LEGACY_ALIASES`, and the canonical recording ids produce exactly the
legacy template filenames (`recording.completed` ->
`telegram_recording_completed.templ`), so existing config and template
files keep working untouched.
`EventId` deserializes an unrecognised id to `registry::UNKNOWN` rather
than failing, so an outbox written by a newer build round-trips through an
older one instead of poisoning the whole file.
No behaviour change: nothing reads these types yet.
* feat(core): one notification envelope instead of a per-kind context
`TemplateContext` carries one `Option` field per message kind - `message`,
`stats`, `watch`, `processing`, `disk`, `recording`, `flat_stats` - which
is why adding a kind had to touch the renderer.
`NotificationEvent` is the shape every event fits: id, severity, timestamp,
instance, dedup key, title, body, and the typed payload serialized into
`fields`. The existing payload structs are unchanged and now travel inside
`fields`, so nothing about them had to move.
`title` and `body` are always populated. That is what lets Pushover, a
syslog channel or an email subject line render any event without a per-kind
match - the gap that had Pushover pushing raw `serde_json` dumps of watch
changes and playlist stats to phones. `body_for` gives watch changes and
processing stats a readable plain-text rendering for the first time, while
keeping the string and disk-alert output byte-identical to the old
`default_text_for`.
`from_content` lifts a legacy `MessageContent` into the envelope, so all six
existing emitters keep working untouched.
Timestamp is unix seconds rather than `DateTime<Utc>`: it matches the
outbox's existing `enqueued_at` representation and avoids pulling chrono's
serde feature into the workspace. `timestamp_rfc3339` renders it for
templates.
No behaviour change: nothing reads these types yet.
* feat(messaging): a NotificationChannel trait to open the channel set
Adding a channel meant 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` fn, the hardcoded `tokio::join!`, a config field, a DTO
field, both `From` impls, `prepare`, and the template-discovery prefix.
This adds the abstraction that collapses those: one trait with a stable
`id()`, the operator's template lookup, a `wants()` routing hook, and
`send()`. The dispatcher never learns the channel's name.
`Delivery` replaces the old `Option<bool>`, which could not distinguish
"retry me" from "this URL is malformed and will fail identically forever".
That cost real attempts: a typo'd webhook burned all `max_attempts` with
exponential backoff before dead-lettering, and a `429` was retried straight
back into the rate limit it had just hit. `delivery_for_status` now
classifies once for every HTTP channel - `408`/`429`/`5xx` transient,
other `4xx` permanent - and `parse_retry_after` reads both legal header
forms so a provider-supplied delay is honoured.
A `Retry-After` HTTP-date already in the past clamps to zero ("retry now")
rather than being discarded, since the server is telling us the wait is
over.
Dispatch is dynamic on purpose: the channel set is an open world resolved
at config load and a send is bounded by a network round trip, so the
vtable hop is not measurable and static dispatch would reinstate the
closed-world match. Uses the boxed-future alias convention from
`tuliprox-processing`'s `SinkFuture` rather than adding `async-trait`.
No behaviour change: nothing implements the trait yet.
* perf(messaging): cache and precompile notification templates
`resolve_template` wrapped every template value in an `InputSource` and
called `download_text_content` - 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. Worse, nothing distinguished the three
cases, so an *inline* Handlebars string paid for a full download attempt
too, on every send.
Classify the source once (`Inline` / `File` / `Url`), then cache what is
actually resolved: local files revalidate on mtime so an edit applies
immediately, remote documents on a 5 minute TTL. Compiled templates are
kept in a registry keyed by the config value, 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 - the operator
configured a template for a reason, and a template-host blip should not
change what the notification looks like.
`validate` compile-checks a template body without sending, so config
validation can surface a malformed template at load instead of leaving a
per-send `error!` and a fallback that looks plausible.
`context_for` carries the new uniform `event.*` shape alongside every
legacy top-level key - `kind`, `message`, `stats`, `processing`, `disk`,
`watch` and the flattened first-input fields - so templates written against
the documented examples render identically. `kind` keeps its CamelCase
labels for the legacy ids for the same reason.
* refactor(messaging): dispatch through channels, promote the outbox
Replaces the closed-world dispatch with the trait and envelope added in the
previous commits, and moves the outbox out of the recording supervisor so
every notification gets durable retry.
Channels
--------
The four hand-written `send_*_message` functions, the `is_some()` chain in
`configured_channels`, the match in `send_message_to_channel` and the
hardcoded `tokio::join!` are gone. `channels::build` constructs whatever
the config declares and the dispatcher fans out over the trait, so adding
a channel is an impl plus a config field.
Pushover gains template support. It had none, 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 also now sends `title` separately,
and maps event severity onto Pushover's own priority scale.
Sends are concurrent per channel and carry a 30s request timeout. The
shared client sets no request timeout at all, and the outbox awaited
channels in sequence, 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.
Outbox
------
Moved to `tuliprox-messaging`, no longer gated on the recording config, and
started unconditionally at bind. Playlist stats, watch changes, disk alerts
and provider warnings previously called `send_message`, which fanned out
and discarded every outcome with `let _ =`; a transient 502 lost them
permanently. `send_event` is now a thin enqueue with a direct-send
fallback.
Entries key pending channels by stable string id rather than an enum
variant, so an outbox written by a build that knows a newer channel no
longer fails to deserialize and take the whole file - and with it every
pending notification - down with it. `notification_outbox.json` adopts any
entries left in `recording_notification_outbox.json` exactly once.
`Delivery::Permanent` dead-letters immediately instead of burning every
attempt on a request that will fail identically forever, and a provider's
`Retry-After` now wins over our own backoff rather than retrying straight
back into the rate limit.
Config
------
`notify_on` is a list of glob patterns; template maps are keyed by event id
wire name. Legacy `MsgKind` names still parse and are normalized to
canonical ids on load. Template discovery iterates the event registry
instead of a hardcoded eight-variant array - the site that silently made a
newly added kind undiscoverable - and still finds legacy filenames.
The frontend event picker is driven by the registry too, so an event added
in the backend appears in the UI without a frontend change.
The six pre-existing template tests are kept and now render through the new
pipeline, which is what proves the documented Discord and Telegram
templates still produce identical output.
* chore: lockfile for tokio-util and futures in tuliprox-messaging
* feat(messaging): per-channel routing, suppression and rate limiting
Routing was a single global `notify_on`: every enabled event went to every
configured channel, so "critical to Pushover, everything to Discord" could
not be expressed at all. Each channel now takes an optional `routing`
block:
telegram:
routing:
notify_on: ["provider.*", "system.disk.alert"]
min_severity: warn
quiet_hours: "23:00-07:00"
max_per_hour: 20
dedup_window_secs: 3600
An absent block inherits the global subscription, so existing configs are
unaffected.
Suppression by `dedup_key` generalizes the disk alert's
`repeat_interval_secs`, which lived in `sys_usage.rs` and was available to
nothing else - `provider.offline` needs exactly the same logic. The hourly
ceiling emits one "further notifications suppressed" audit line when it
trips and then goes quiet, so the silence is distinguishable from a
notifier that has died.
Quiet hours defer rather than drop. The outbox holds the entry until the
window closes, because an overnight outage nobody hears about afterwards is
worse than one that arrives late. An entry is only held while *every*
still-pending channel is asleep.
Two supporting fixes:
`channels::build` ran on every send, constructing a fresh `reqwest::Client`
each time - added in the previous commit and wrong. The channel set is now
cached and invalidated on reload, which is also what lets per-channel
suppression and rate-limit state survive between notifications.
Routing is boxed inside the channel DTOs. Four inline routing blocks made
`MessagingConfigDto` the largest `ConfigForm` variant at 857 bytes; the
`Option<Box<_>>` is niche-optimized and only allocates when routing is
actually configured.
Rate-limit windows are pure in `now`, so expiry is tested without sleeping
and without tests racing each other over shared global state.
* feat(app): bridge the 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. The notification side had six emitters of
its own, and nothing connected the two.
One subscriber closes that gap, and every future `EventMessage` variant
comes along with it.
Three things keep it from being a firehose:
* The high-frequency variants map to `None`: progress ticks, download
deltas and periodic system info fire many times per operation and carry
nothing worth pushing to a phone. Their terminal counterparts are what
get through. The match is exhaustive, so a new variant is a compile
error rather than a silent firehose.
* Everything defaults to unsubscribed. An upgrade does not start messaging
anyone until `notify_on` asks for it.
* The broadcast channel has capacity 10, so `Lagged` is reported and
skipped rather than killing the bridge.
A partial playlist update maps to `completed` at `warn` rather than `info`,
so it does not read as a clean success. Config changes carry a per-file
dedup key, so a watcher that fires several times for one save produces one
notification.
The two genuinely chatty events - `user.connection.changed` and
`provider.connections.changed` - are registered and documented as high
frequency so subscribing to them is a deliberate act.
* feat(messaging): specific provider events, a test endpoint, and secret redaction
Three changes that together make the messaging config verifiable and safe to
expose.
Provider account events
-----------------------
The Xtream account-status, expiry-warning and expired messages all landed in
`MsgKind::Info`/`Error`, so subscribing to "my account is about to expire"
meant also receiving every processing error. They now emit
`provider.account.status_changed`, `.expiring` and `.expired` with a typed
payload.
All three carry a dedup key. They are re-evaluated on every playlist
refresh, so without one an account inside its final three days would notify
on every single update.
Test endpoint
-------------
`POST /api/v1/config/messaging/test` renders and optionally sends a chosen
event to a chosen channel, returning 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, and a test that silently does
nothing because of a dedup window would be worse than useless.
Secret redaction
----------------
The config GET 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 on the REST channel. Those are now
masked on the way out.
`save_config_main` restores any secret the client echoes back still masked,
so a UI round-trip cannot overwrite a real token with the mask and silently
break the channel. A genuinely changed secret still writes through, an empty
secret is not replaced by a mask (an unconfigured channel must not look
configured), and non-credential REST headers keep their values.
* feat(messaging): five new channels, and HMAC signing for webhooks
The test of whether the channel trait actually opened the set. Each of
these is one impl plus one config field plus one line in the builder -
no dispatcher, outbox, renderer or template-discovery change.
* **ntfy** - self-hosted push, no account, no bot token. The natural
default for a homelab operator already running tuliprox in Docker.
ntfy headers must be ASCII and titles routinely carry emoji, so the
title is stripped rather than failing the send, with a fallback so it
never goes out empty.
* **Gotify** - same audience, same shape.
* **Slack** - not a Discord clone. Block Kit differs enough from Discord
embeds that reusing the Discord payload produces bad output, so it
builds header/section/context blocks. Slack has no severity field, so
non-info severities are marked in the header text where a human sees
them.
* **command** - runs a local program with the event JSON on stdin. The
escape hatch that means nobody waits for a channel to be added
upstream. Executed directly rather than through a shell, so there are
no quoting rules and no shell-injection surface from event content. A
missing binary is `Permanent` (retrying cannot help); a non-zero exit
or a timeout is `Retry`.
* **REST signing** - optional HMAC-SHA256 over `{timestamp}.{body}`,
sent 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.
Each severity maps onto the target's own priority scale rather than
being dropped, and the new secrets (ntfy token, Gotify token, REST
signing secret) join the existing ones in the redact/restore path.
`ConfigForm::Messaging` is boxed: eight channel configs made it dominate
the size of every other variant of that enum.
* docs(messaging): document the open-world event and channel model
Rewrites section 5 for what the messaging layer actually does now.
* The glob grammar for `notify_on`, with a note that every legacy event
name still works and is rewritten on the next save.
* A table of all 24 registered events with default severities.
* Per-channel `routing`: `notify_on`, `min_severity`, `quiet_hours`,
`max_per_hour`, `dedup_window_secs` - including that quiet hours defer
rather than drop, and that the hourly ceiling reports itself once so the
silence is not mistaken for a broken notifier.
* Delivery semantics: the outbox, per-channel retry, which statuses are
transient vs. permanent, and `Retry-After`.
* The four new channels (ntfy, Gotify, Slack, command), webhook HMAC
signing with the replay note, and secret masking in the Web UI.
* Templates: now supported on every channel including Pushover, keyed by
event id, resolved and compiled once rather than re-fetched per send,
with the uniform `event.*` context documented alongside the legacy keys.
* The test endpoint, so there is a feedback loop shorter than "save it and
wait for something to break".
The event table sits between generated-block markers and a test checks it
against the registry in both directions - a registered event missing from
the table, and a table row for an event that no longer exists. Verified
the test fails for both before restoring.
* fix(messaging): invalidate channel and template caches on config reload
The channel set and compiled templates are cached so a notification does
not rebuild every channel - and a fresh `reqwest::Client` with them - on
every send. Nothing invalidated those caches, so an edited bot token,
webhook URL or template would not take effect until a restart.
Also emits `config.reload_failed` alongside the existing `ServerError`, so
an operator can subscribe to "my config stopped loading" without taking
every server error with it.
* refactor(events): move EventMessage into shared
`EventMessage` lived in `tuliprox-session`, the crate that owns provider
allocation and the streaming-session runtime. That meant `metadata`, `dvr`
and `processing` all had to depend on the streaming runtime just to name an
event - a metadata refresh completing has nothing to do with which provider
a stream came from.
Every payload the enum carries was already a `shared::model` type, so the
move is mechanical: the taxonomy goes to `shared::model::event`, the bus
implementation stays in `session` next to the stream-meter registry it also
feeds.
`api::model` re-exports it from `shared` so the ~80 `crate::api::model::
EventMessage` call sites keep their path.
* refactor(events): emit through a static EventSink bound
There was no seam anywhere in the event path. `Arc<EventManager>` - the
concrete streaming-runtime bus - was baked into `MetadataUpdateCtx`,
`RecordingCtx`, and the playlist pipeline, which carried it as
`Option<Arc<EventManager>>` purely because tests have no bus to hand it.
That `Option` was then unwrapped at every one of its emit sites.
`shared::model::EventSink` is the seam: one method, `emit`, documented as
non-blocking because the bus is reached from the streaming data path.
It is a bound, never a trait object. The three context structs are generic
over their sink and monomorphise against the one they were built with, so
emitting stays a direct call. `NoopSink` is the absent case, and its `emit`
is an empty function - the pipeline's six `if let Some(events)` branches
collapse to unconditional calls that compile to nothing when that is the
instantiation, and `create_broadcast_callback` loses its noop arm.
`MetadataUpdateManager` stores its context and is itself held by `AppState`,
so it pins one instantiation (`BoundMetadataUpdateCtx`) rather than going
generic and dragging `AppState` with it. Functions that only read a context
stay generic.
`tuliprox-dvr` drops its dependency on `tuliprox-session` entirely - the
event bus was the only thing it wanted from the streaming runtime, and a
bound is not a dependency. 78 workspace edges, now 77.
`app_state_views!` grows a `ctx_field <- state_field` form for a context
that names a handle by the role it plays because it is written against a
bound rather than against `EventManager`.
* feat(events): give events an identity with EventKind
Subscribers each discriminated by exhaustively matching `EventMessage`: the
websocket mapped variants to permissions, the notification bridge computed
severity per variant, and the wire layer maps them to `ProtocolMessage`.
Adding a variant compiled cleanly while reaching none of them.
`EventKind` carries the payload-free identity, and everything that is a
property of the event rather than of one consumer hangs off it:
* `required_permission` - who may see this. Not a websocket concern: it
does not change with the transport, so `websocket_can_receive_runtime_
events` is now one line and cannot fall out of date.
* `severity` - on `EventMessage`, because it reads the payload: a playlist
update that failed is an error and one that succeeded is not. The bridge
now decides only which notification id to use.
* `is_high_frequency` - progress ticks, deltas, the periodic system-info
sample. A statement about rate, deliberately separate from the bridge's
notifiability decision, which also depends on whether a terminal
counterpart exists.
* `as_wire_name` / `from_wire_name` - stable strings for plugin
subscriptions and operator config, with the same
must-not-change-once-released contract a notification channel id carries.
`EventKind::ALL` fixes the bit order that the subscription mask uses.
No behaviour change: the permission mapping is the same table and the
notifiable set is untouched.
* perf(events): filtered subscriptions and cheap fat payloads
`get_event_channel` handed every subscriber all fourteen kinds and each one
filtered afterwards - after the broadcast channel had already cloned the
message for it. Two costs, addressed separately.
Filtering: `EventKindMask` is a one-word set over `EventKind`, and
`subscribe_filtered` returns a `FilteredEventReceiver` that drops what the
subscriber did not ask for before waking it. A concrete type, not a boxed
stream: the call site is a `select!` arm awaiting `recv()`, and a virtual
call per event buys nothing. `Lagged` still propagates - a gap in events the
subscriber did not want is still a gap in the ones it did.
The notification bridge is the first user. It handles ten kinds and dropped
four, and those four are the bulk of the traffic during a playlist refresh,
so it now never wakes for them. A test asserts the mask and the `None` arms
of `to_notification` agree, and that the sample list covers every
`EventKind` - so a variant added later cannot quietly fall out of either.
Payloads: `SystemInfoUpdate` and `DownloadsUpdate` carried `SystemInfo` and
`DownloadsResponse` by value, which is what `large_enum_variant` was
allowed for. Behind `Arc` they cost a refcount bump per receiver instead of
a deep copy, and the `allow` is gone. Only the websocket needs the value
itself; `unwrap_or_clone` there means the last subscriber standing pays
nothing. The frontend mirror of this enum has held both behind `Rc` all
along.
* refactor(session): split the stream-meter registry out of EventManager
`EventManager` was two components sharing a name: 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 maps. No
event subscriber ever touched the second half, and no meter call site
touched the first.
`StreamMeterRegistry` is that second half. `EventManager` owns one and
forwards the meter methods, so the composition root still builds a single
handle and streams need not know metering is separate; `meters()` exposes
the registry for anything that does.
The three maps become one. `meters`, `meter_to_clients` and
`client_to_meter` had an invariant re-established by hand in four methods -
retain from the vec, remove the entry if it emptied, drop the index - and
`register_meter_client` spelled it differently from the other three. They
are now `HashMap<u32, MeterSlot>` plus the client index, with `detach_client`
and `remove_meter` as the only two mutators. The slot's handle stays
optional because the two halves genuinely arrive in either order: a client
can be assigned to a meter before the stream owning it registers.
`read_meter_qos` returns `Option<MeterQos>` instead of
`(Option<u64>, Option<u64>)`, where "both `None`" doubled as "this meter is
shared, ask no further" - a convention that lived only in a doc comment.
The sampler still declines to start outside a tokio runtime, but now says
so at debug rather than returning in silence.
All six meter tests pass unchanged, which is the point: this is a
rearrangement, not a behaviour change.
* feat(events): configurable bus capacity, and counters for what it drops
The bus was `broadcast::channel(10)` for everything. Ten is 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. The
evidence was already in the tree - the notification bridge carries a
dedicated `Lagged` arm with a comment about it, and the websocket has an
entire `ResyncStatus` recovery path that exists for no other reason.
Capacity is now `event_channel_capacity`, default 256, clamped to at least
1. `EventManager::new()` keeps the default for tests and early startup.
`EventBusStats` makes the drops visible. `send_event` returned a `bool`
that ~80 call sites discarded with `let _ =`, so "nobody received this" and
"nobody was listening" were equally invisible. It now counts emissions per
kind, emissions with no subscriber, and - reported by the subscribers,
since a broadcast channel drops for the receiver and not the sender - the
size of every gap a subscriber was told about. The notification bridge and
the websocket both report theirs.
This is also where the plugin system's promised drop counters come from,
rather than a second set of counters on a second queue.
* refactor(events): single-source the notification and wire mappings
Four parallel taxonomies described the same events: `EventMessage` on the
bus, `ProtocolMessage` on the wire, the frontend's own enum, and the
notification registry. Two of the three translation layers were hand-written
tables that had to be edited in lockstep, with nothing checking they agreed.
The notification id moves onto the event. `EventMessage::notification_id`
returns the registry id, or `None` for the kinds that are not notifiable -
one decision, made once. It lives on `EventMessage` rather than `EventKind`
because two of them read the payload: a playlist update that failed is a
different notification from one that succeeded, not merely a more severe
one. The bridge now decides only wording and attachments.
The wire mapping becomes a pure function. `handle_event_message` was a
hundred lines of `match` nested three deep inside the socket loop, where a
kind reaching no arm looked exactly like a kind deliberately ignored.
`to_protocol_message` is testable on its own, and the socket loop is now
guard, special case, send.
Both new tests iterate `EventKind::ALL` and assert the sample list covers
it, so a variant added later fails the tests instead of silently reaching
nobody - which is the failure mode all of this exists to prevent.
* feat(events): the seam the plugin host subscribes through
The plugin plan specified a second, independent event bus: its own bounded
mpsc, its own emit call sites in `process_sources`, the active-stream
tracker and user CRUD, and its own drop counters. Three of those four
emitters already publish to `EventManager`. Building the second one would
have meant two emit sites per event and two taxonomies to keep in step.
What the host actually needs from the existing bus, added here:
* `EventKindMask::from_wire_names` - a manifest's `events.*` list becomes a
subscription mask, with unknown names returned rather than silently
narrowing what the plugin asked for. An empty mask means no subscriber
task at all.
* `EventMessage::payload` - the JSON a plugin receives, defined on the event
so a new variant arrives with its payload shape decided instead of the
host growing a second match over the taxonomy. Never fails: an
unserialisable payload degrades to null rather than dropping the event.
* `EventKindMask::kinds`, for reporting what a subscription resolved to.
`plugin-system-plan.md` (untracked) is revised to match: the plugin queue
sits behind the bus rather than beside it, keeping its backpressure
isolation while making "emitters never block" structural - `broadcast::send`
cannot block - instead of a convention every emit site has to honour.
Tests assert the bit assignment is unique, wire names are unique and round
trip, and the taxonomy still fits the mask's `u32`.
Deliberately not done: `stream-start` / `stream-stop`, `user-created` and
`probe-result` still have no `EventMessage` variant. They belong in the
shared taxonomy rather than a plugin-private one, but stream start/stop
sits on the streaming hot path and should not gain a per-stream emission
speculatively, ahead of a consumer that needs it.
* feat(events): typed emit outcomes and nudge coalescing
Two shapes the messaging crate had already worked out for notification
channels, and the event bus lacked.
Typed outcomes. `send_event` returned a `bool` that conflated "nothing was
listening" with "this failed", told the caller nothing useful either way,
and was discarded by ~80 call sites. `EmitOutcome` distinguishes
`Delivered { receivers }`, `NoSubscribers` and `Coalesced` - the same
distinction `Delivery` draws for channels. Not `#[must_use]`: an emitter
genuinely may ignore all three, and pretending otherwise would just spread
`let _ =` further.
Coalescing. `rate_limit::admit` throttles notifications; nothing throttled
the bus. `RecordingChanged` 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.
`EventKind::is_coalescable` marks the kinds where N occurrences and one are
indistinguishable to every consumer: payload-free nudges that everyone
answers by re-reading current state. Only the two recording nudges qualify.
An event carrying a payload is never coalescable however repetitive, because
a dropped progress tick loses the message it carried - the tests assert
exactly that, and that the two nudges do not suppress each other.
The window is 250ms and is 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.
* feat(events): snapshots, an audit ring, graceful shutdown
The remaining smaller fixes from the event-manager review.
Latched snapshots. `EventKind::is_latched` marks the kinds that describe
current state rather than an occurrence - the last `SystemInfo` sample *is*
the system info. The bus retains the newest of each, and `snapshot()` hands
them to a session that connects between samples, which used to see empty
panels until the next one arrived up to three seconds later. Occurrences are
never latched: replaying "a playlist update finished" to a session that was
not there would be a lie, and the tests say so.
A recent-event ring. 256 entries, each with its kind, monotonic uptime and
outcome - including suppressed ones, since "it fired but was coalesced" is
exactly what the ring is consulted to find out. Exposed with the bus
counters at `GET /api/v1/events/stats`, behind `system.read` like `/status`.
"Why did my notification not fire?" was otherwise unanswerable without a
debug build.
Graceful shutdown. `Drop` cancelled the meter sampler but cannot await, so a
stream still running at shutdown lost its last window's bytes -
`flush_and_unregister_meter` already fixed that for one stream ending
between ticks. `EventManager::shutdown()` does it for the whole registry,
and runs after the connection manager so the final batch reports what the
streams actually transferred.
`send_provider_event` and `send_system_info` are gone. They wrapped
`send_event` only to log on failure, which `EventBusStats` now records
centrally - two calling conventions for one bus was one too many.
Also documents the new endpoint in the REST cookbook.
* fix(processing): pin the pipeline test context to NoopSink
`processing_context()` was made generic along with the pipeline it builds,
which left every call site unable to infer the sink type. These tests
exercise the pipeline, not the bus, so the helper names `NoopSink` and the
call sites say nothing.
Neither `cargo build --workspace` nor `cargo clippy --workspace` compiles
test code, so this only surfaced on the first full test run.
Also carries the lockfile change from `tuliprox-dvr` dropping its dependency
on `tuliprox-session`.
* refactor(messaging): static dispatch, no trait objects and no boxing
The notification layer used `Vec<Arc<dyn NotificationChannel>>` and a
`Pin<Box<dyn Future>>` per send. Both are gone.
`NotificationChannel::send` now returns `impl Future<Output = Delivery> +
Send` instead of a boxed future, which makes the trait deliberately *not*
object safe - there is no way to construct a trait object from it, so the
old shape cannot come back by accident.
`Channel` is an enum over the eight implementations. Every call goes
through a match the compiler turns into a direct call, so dispatch is
monomorphized and the send path allocates nothing. Adding a channel is now
a module, an enum variant, a config field and a line in `build` - one site
more than the trait-object version, and still far from the ten the original
closed-world dispatch cost.
The two `Box`es in the config types are gone as well:
* `ChannelRoutingDto` is stored inline again on all eight channel configs.
* `ConfigForm::Messaging` carries `MessagingConfigDto` directly.
Both were added only to silence `clippy::large_enum_variant`. That lint is
now allowed explicitly on `ConfigForm` with the reason recorded: the
variant is moved once per form submit and never in a hot path, so the
indirection bought nothing real.
The only remaining `dyn` in the crate is Handlebars' own `register_helper`
signature, which is pre-existing and part of that library's API.
No behaviour change - same 46 messaging tests, same delivery semantics.
* feat(events): put the notification-only lifecycle events on the bus
Six emit sites called `tuliprox_messaging` directly instead of publishing to
`EventManager`. Everything they emitted reached operators by mail and nothing
else: not the websocket, not the notification bridge, and - once a plugin
host subscribes to the bus - not plugins either. An event was
plugin-visible only by accident of which route its emitter happened to take.
Nine kinds move onto the bus, each keeping the registry id it already had:
* `DiskAlert` - and the *subscription* check in front of it is gone. The
thresholds still come from `messaging.disk_alert`, but gating emission on
someone being subscribed by mail meant a plugin watching for disk
pressure saw nothing unless an operator happened to want the same event.
The notification layer already drops unsubscribed events.
* `ConfigReloadFailed` - the typed counterpart to the `ServerError` string
the Web UI toasts. Both are emitted: the comment at that call site already
argued an operator should be able to subscribe to "my config stopped
loading" without taking every server error, and that argument applies to
plugins, which only ever saw the string.
* `PlaylistWatchChanged`
* `RecordingStarted` / `Completed` / `Failed`
* `ProviderAccountStatus` / `Expiring` / `Expired`
Recording lifecycle is published *alongside* its existing delivery, not
instead of it. That path is at-most-once: a durable marker is persisted
inside the queue-mutation boundary before delivery, and the outbox retries
per channel. A broadcast bus drops for a lagging subscriber, so routing it
through here would let a recording be marked delivered and then never sent.
The bridge ignores the kind for exactly that reason; `download_api` still
owns operator delivery.
`WatchChanges` and `RecordingLifecycleMessage` move to `shared` for the same
reason `EventMessage` did - an event payload has to be nameable by every
emitter - with `ProviderAccountEvent` and `ConfigReloadFailure` added there.
`tuliprox-core` re-exports the two that moved, so `MessageContent` and its
call sites are untouched. The bridge builds the three that already had a
`MessageContent` shape via `from_content`, so the templates that render them
see exactly the fields they saw before.
Severity is no longer a second table: it comes from the registered event's
descriptor, with one override for a partial playlist refresh, which shares
an id with a clean one but is not a clean success.
`tuliprox-iptv` drops its dependency on `tuliprox-messaging` - emitting an
event is not knowing how it gets delivered. 77 workspace edges, now 76.
* feat(events): carry the run summary on the playlist-update event
`PlaylistUpdate` carried the outcome enum alone, so "the refresh finished"
reached the bus but what it actually did did not. The run summary went
somewhere else entirely: straight to the notification layer as a second
message, built from `MessageContent::event_stats`.
Both messages resolve to `playlist.update.completed`, so a successful
refresh with statistics notified **twice** - once with the stats and once
with the bare outcome - and neither carried the other's content. A
subscriber on the bus saw an outcome with no detail, and the plugin plan's
`refresh-complete`, specified as "run summary as payload", had nothing to
read.
`PlaylistUpdateSummary` folds them: outcome, per-source statistics, and the
aggregated error text, emitted once at the end of `process_sources`. The
notification bridge renders it through `ProcessingStats`, so the "Stats" and
"Error" templates - which read `fields.stats` - see exactly the shape the
separate message used to hand them, while 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.
The websocket frame is unchanged: it carries `summary.state`, so the Web UI
sees what it always did.
`tuliprox-processing` no longer sends notifications directly. The four
timeout and panic paths use `PlaylistUpdateSummary::state_only`, having no
statistics to report.
`SourceStats` and friends gain `PartialEq`, which `EventMessage` requires of
everything it carries.
* chore(processing): drop the now-unused messaging dependency
`tuliprox-processing` published its last notification directly when the
playlist run summary moved onto the bus. Emitting an event is not knowing
how it is delivered, so the edge goes. 76 workspace edges, now 75.
* chore: lockfile for the dropped iptv and processing messaging edges
* feat(events): user account lifecycle and failed stream probes
Two emitters that changed state and told nobody. Creating, editing or
deleting an API-proxy user wrote api_proxy.yml, swapped the live config
and returned 200; ffprobe failures went to the item store and a warn
line. Neither could be notified on, and neither had an audit trail.
Both follow the RecordingLifecycle / ProviderAccount shape: one payload
with a state, several EventKinds, so a subscriber can ask for deletions
or 404s alone.
UserLifecycleEvent carries username, target and state - not the
password or token. That record reaches Telegram, webhooks and shell
commands, several of which log; the secret is absent from the type
rather than redacted at each render site.
StreamProbeFailure has no success counterpart: a metadata run probes
every unknown stream, so success would fire thousands of times per
refresh. It is published from prepare_generic_stream_metadata rather
than from the manager because that is the only place the reason still
exists - both outcome enums collapse NotFound, Other and Cancelled into
one ProbeFailed. Cancelled is deliberately not published: it is this
server shutting down, not a statement about the stream. The URL is
sanitized at the emit site, since a resolved provider URL carries
account credentials.
Notifications for both are deduplicated - per account+state, and per
*input* for probes, so a provider outage notifies once instead of once
per channel behind it.
Also widens EventKindMask from u32 to u64. The taxonomy is at 27 of 32
bits; the cheap time to widen is before operators have subscription
lists to migrate.
And repairs every_event_kind_is_either_wire_mapped_or_deliberately_not,
which has been failing on DiskAlert since the notification-only
lifecycle events joined the bus: twelve kinds returned None from
to_protocol_message while HANDLED_ELSEWHERE listed three. The two
reasons a kind produces no frame are now two lists, because they are
not the same fact.
* refactor(auth): roles as a static bitset, one is_admin, one JWT decode
`Claims::roles` was a `Vec<String>`: an allocation per mint, a string
comparison per check, and - because five call sites compared with `==`
while a sixth used `eq_ignore_ascii_case` - two different answers to the
same question depending on where you landed.
Roles are now a `RoleSet`, built by the same `create_bitset!` macro that
backs `PermissionSet`. A role check is a `test` instruction on a `u8`.
The JWT wire format is unchanged: `role_names` serialises the set back to
the legacy `["ADMIN"]` string array, so tokens minted before this change
still verify and clients that read the payload see what they always saw.
Unknown role names deserialise to no bit rather than failing the parse,
so a token from a newer build fails closed.
`Claims::is_admin` / `is_api_user` replace the six open-coded checks.
Case-insensitivity is now uniform - the parse accepts either case, which
is the superset that cannot regress an existing token.
`validate_request` took a `fn(&str, &[u8]) -> bool` that re-decoded the
token from scratch, so every authenticated request paid for two JWT
decodes and the API-user path paid for three. It now takes a
`fn(&Claims) -> bool` - still a plain fn pointer, still static dispatch -
and reads the claims it already decoded. `verify_token_admin` and
`verify_token_api_user` are gone with it.
* refactor(auth): one typed rejection for every auth extractor, and 401 means 401
`AuthBasic`, `AuthBearer` and `Fingerprint` each declared
`type Rejection = (StatusCode, &'static str)` - the same alias, defined
twice, once in `tuliprox-auth` and once beside `Fingerprint` in
`tuliprox-core`. The tuple carried no structure, so each arm picked a
status by hand, and they all picked the same wrong one: a *missing*
`Authorization` header answered `403 Forbidden`. That tells a client "you
are authenticated and still may not do this" when the truth is "you did
not authenticate at all", and the two are not distinguishable from the
outside. No `WWW-Authenticate` challenge was sent either, so a 401 from
this server was never a well-formed 401.
`AuthRejection` replaces both aliases: an enum in `tuliprox-core` with a
`status()`, a `message()`, and an `IntoResponse` that attaches the
`WWW-Authenticate` challenge for the scheme the extractor wanted. Missing,
malformed and wrong-scheme headers are now 401; only an unresolvable peer
address stays a 400. `auth_middleware::rejection_for` gained the same
challenge header on its 401 path.
* fix(auth): validate the issuer, bound the token lifetime, honour pwd_version
Three holes in the token lifecycle, all of them the same shape - a field
that was written into every token and then never read back.
**`iss`**: `Validation::new(Algorithm::HS256)` checks `exp` and nothing
else, so the configured issuer was decoration. `verify_token` now takes
the expected issuer and sets it on the validation. The WebSocket paths
carried a bare `Vec<u8>` secret across task boundaries, which is exactly
why they could not check an issuer they never received - they now carry a
`TokenVerifier` that holds both.
**`token_ttl_mins`**: a configured `0` meant "expire in 100 years", which
is a permanent bearer credential written as if it were a configuration
convenience. `0` now means the 24-hour default, anything above the 30-day
ceiling is clamped, and both log why.
**`pwd_version`**: minted into every web token, checked in exactly one
place - the refresh endpoint - so changing a password invalidated nothing
on any guarded route. Together with the 100-year TTL above, a leaked token
was a permanent credential. `validate_password_version` is the check, and
it rejects `pwd_version == 0` rather than treating it as "skip", which is
how the refresh endpoint's version of this check could be bypassed.
`AuthError::PasswordChanged` is deliberately not refresh-required: a
refresh applies the same check, so the client must sign in again.
Enforcement on the request path lands in the next commit.
* fix(auth): scope-bind access tokens, wipe prompted passwords, drop dead file
**Access tokens** signed `(timestamp, ttl)` and nothing else, so any valid
token was valid at every place a token was accepted - one token minted for
any purpose opened all of them. The scope is now mixed into the keyed hash,
so a token minted for one capability does not verify against another. The
token string format is unchanged; only the signed payload grew.
The scope is a compile-time constant, never caller-supplied: both sides of
a handshake have to agree on the exact bytes or the signature fails, which
is what keeps mint and verify from drifting.
This binds to a capability, not to a resource. The internal web player
mints one token that travels the whole chain - webplayer or recording
entry point, the xtream handler they delegate to, and the
custom-video-stream fallback the stream layer redirects into - and those
sites do not share a target or a virtual id to bind against. Per-resource
binding needs that redirect chain traced against a running server; the
scope parameter is where it goes when it is.
**Prompted passwords** were left sitting in two `String`s after
`generate_password` returned. `UserCredential::zeroize` already applies
this discipline to a password that arrives over HTTP; one typed at a
terminal now gets the same treatment.
**`backend/auth/src/user.rs`** was never declared in `lib.rs`. It was a
dead duplicate of the `UserCredential` in `shared::model::auth::user`.
* fix(auth): enforce pwd_version and live permissions on every guarded route
Two checks existed and neither ran where it mattered.
**Password version.** `pwd_version` was consulted only by the refresh
endpoint, so changing a password invalidated nothing on any guarded route -
a token minted against the old password kept working until it expired.
`authenticate` now applies `validate_password_version` to every request
whose principal is a web user. Proxy API users authenticate against
`api_proxy.yml` and carry no password version, so there is nothing to
compare and the check is skipped for them rather than failing them.
The refresh endpoint's own copy read `pwd_version != 0 && pwd_version !=
current`, so a token carrying `0` skipped the check. It now uses the same
strict validator as everything else.
**Permission revocation.** `require_permission` read `claims.permissions`,
a snapshot from mint time, so revoking a group permission had no effect
until the token expired. The effective set is now the intersection of the
claim with what the live config grants: a revocation takes effect on the
next request, while a new *grant* still needs 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 has no live set and keeps its claim.
Three permission paths had drifted apart and now share one implementation:
`rbac_api` had a hand-rolled check that verified the signature and read the
claim directly - no schema gate, no subject gate, no password version, no
live intersection; `v1_api_config::decode_permissions` filtered config
output off the raw claim; and `get_username_from_auth_header` decoded with
a bare `Validation::new`, which checks `exp` and nothing else.
**`permission_layer!`** now expands to `require_permission::<{P as u32}>` -
a bare `fn` item with the permission fixed at monomorphisation, rather than
a closure capturing a runtime `Permission`. `Permission::from_repr` (new on
`create_bitset!`) recovers the variant on the other side. The 15 call sites
are unchanged.
**`AuthorizedClaims<const P>`** is the same requirement in a handler
signature, handing the handler the claims the layer already verified -
they used to be dropped, so handlers behind a layer decoded the token
again. Its rejection is a two-word `Copy` enum, not a rendered `Response`.
* refactor(processing): exec_processing takes a run, not twelve arguments
Twelve positional parameters, seven of them `Option<_>`. A call site was a
wall of `None`s and `Some(..)`s where 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. The CLI path was literally
`exec_processing(&client, cfg, targets, NoopSink, None, None, None, None,
None, None, None, None)`.
`ProcessingRun` takes the four that are always present as constructor
arguments and names the rest. The CLI path is now
`ProcessingRun::new(client, cfg, targets, NoopSink)`. Setters take
`impl Into<Option<T>>`, so a site that already holds an `Option` passes it
through unchanged and one that holds a value does not have to wrap it.
`client` moved from `&reqwest::Client` to an owned clone - the client is an
`Arc` internally, so this is a refcount bump, and it drops a lifetime
parameter from the struct.
* perf(events): drop the last four `as Arc<dyn EventSink>` casts
`exec_processing` has been generic over `E: EventSink` for a while, but
every caller handed it `Arc::clone(&event_manager) as Arc<dyn EventSink>`.
The blanket `impl<T: EventSink + ?Sized> EventSink for Arc<T>` made that
compile, so it looked done - but the monomorphisation was against
`Arc<dyn EventSink>`, and every `emit` on the update path still went
through a vtable.
Deleting the four casts is the whole change. `EventManager` is now the
concrete `E`, and the emit sites in the playlist pipeline are direct calls.
* perf(processing): the update bootstrap is a trait, not two layers of boxing
`PlaylistUpdateBootstrap` was
`Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>`:
an erased closure returning an erased, heap-allocated future, for work that
runs exactly once per update. Every call site had to spell out both
coercions - an `Arc::new`, a `Box::pin`, and two `as` casts - around a
three-line closure.
`UpdateBootstrap` is one trait with an RPITIT future and a blanket impl for
`Fn() -> impl Future`. `ProcessingRun` carries it as a type parameter, so
the call sites are now just the closure:
.with_bootstrap({
let state = Arc::clone(&app_state);
move || {
let state = Arc::clone(&state);
async move { sync_panel_api_exp_dates(&state).await }
}
})
`NoBootstrap` - the type parameter for a run without one - is a function
pointer rather than a unit struct, so it satisfies the same blanket `Fn`
impl and needs no second impl to conflict with it. No value of it is ever
constructed.
`with_bootstrap` changes the run's type parameter, so it rebuilds the
struct rather than mutating it; the other setters are unchanged.
* perf(processing): MetadataUpdateSink loses the vtable and the boxed futures
The pipeline held the metadata worker as `Arc<dyn MetadataUpdateSink>` and
the trait's two async methods returned
`SinkFuture<'_, T> = Pin<Box<dyn Future + Send>>`. That is a heap
allocation per `prepare_enqueue_state` - once per input - and a vtable hop
on `should_skip_enqueue`, which runs once per playlist item. There is
exactly one implementor, so nothing ever needed the erasure.
The futures are returned by value (RPITIT) and `PlaylistProcessingContext`
carries the sink as a type parameter. `NoopMetadataSink` names the
parameter for a run without a worker - the same role `NoopSink` plays for
events - and its methods are correct no-ops rather than `unreachable!`,
since the whole point of the type is to be absent.
Making the trait non-dyn-compatible is what forced the last four
`as Arc<dyn MetadataUpdateSink>` casts out of the composition root; the
compiler will not let them come back.
`PlaylistProcessingContext`'s `Clone` is written out rather than derived:
the derive would demand `M: Clone`, but the sink is behind an `Arc` and is
cloneable whatever `M` is.
* feat(auth): stable subject ids come from the identity registry
`create_jwt_web_user` and `create_jwt_api_user` synthesised the subject as
`format!("web:{username}")` / `format!("api:{username}")`, with a TODO
saying the registry would provide it. The registry was fully built - with
persistence, bootstrap, fail-closed recovery and an explicit `rename` that
preserves the id - and never wired into the server, so the TODO was the
live behaviour: the subject was a function of the display name, and
renaming a user reassigned every recording the old subject owned to a
principal that does not exist.
`IdentityRegistry` now lives on `AppState`, bootstrapped from the storage
dir with the current web and API principals. The login and refresh paths
resolve the subject through it - `register` is get-or-create, so a user
bootstrap already synced keeps their id and one added since gets a fresh
one. `register_api_user` and `lookup_api_by_username` are new: the API
namespace had a bootstrap sync path but no way in for a principal that
appears at runtime.
A corrupt registry refuses to start rather than inventing replacement ids,
which is the whole reason the registry's fail-closed path exists. The
pre-scan that would hand bootstrap the subject ids already referenced by
persisted recordings is still unwired, so a *missing* registry alongside
existing recordings initialises fresh; a corrupt one - the case a
half-written file actually produces - fails closed regardless.
* feat(auth): back off repeated failed sign-ins
`/auth/token` verified an argon2 hash, answered 401 and forgot. Nothing
counted how often that happened, so a password list could be worked against
it as fast as the hash function allows, for as long as the attacker liked.
The reverse-proxy rate limiter is opt-in, disabled by default, and applies
one blanket budget to every route - it is not a credential-stuffing control.
`LoginThrottle` tracks consecutive failures on two dimensions, because
either alone is defeatable: by client address, which stops one host
grinding a list but not an attacker with an address pool; and by username,
which stops a distributed attack converging on one account. The username
dimension is a denial-of-service lever if handled carelessly - anyone who
knows a username could lock its owner out - so its block is short (15
minutes at the ceiling) and a correct password clears it immediately.
Three free attempts, then 2s doubling to the ceiling, and a 429 carrying
`Retry-After` so a well-behaved client backs off rather than hammering. The
check runs *before* the argon2 verify: an attacker who can still force the
hash on every attempt has not been slowed down.
Usernames are canonicalised the way the rest of the auth path compares
them, so `Alice` and `alice` share one budget against one account.
The address dimension is only as trustworthy as the address, which this
server still takes from `X-Forwarded-For` with no trusted-proxy allowlist.
Until that is fixed the username dimension is the one doing the work.
* feat(auth): authentication decisions reach the event bus
Sign-ins, rejected sign-ins and permission denials went to `warn!` and
`debug!` and nowhere else. Nothing that subscribes to the bus - a
notification channel, a plugin, an audit sink - could see any of them, so
the events that matter most for spotting an intrusion were exactly the ones
the bus never carried.
`EventMessage::AuthAudit` carries one decision. Following
`UserLifecycleEvent`: one payload, four `EventKind`s, so a subscriber can
ask for the failures without being woken by every successful sign-in. Each
kind is registered with a severity and a description, so it shows up in the
notification config like every other event.
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
site that renders one - these records reach Telegram, webhooks and shell
commands, several of which log on their own. Notifications dedupe per
principal, address and outcome, so a password-guessing run is one piece of
news rather than one per attempt.
`required_permission` is `UserRead`, not the `SystemRead` the other
operational events take: who signed in is the same question as who may read
the user list, and that is the narrower answer. They are not pushed to the
Web UI socket - no panel renders them, and every connected admin does not
need every sign-in.
`token` split into `web_user_sign_in` and `api_user_sign_in` on the way
past. The two branches stay sequential and short-circuiting: the API branch
compares credentials and can allocate a persisted subject id, neither of
which a successful web sign-in should trigger. `SignInAttempt::Refused`
distinguishes "these are not credentials for this branch, try the next" from
a decided answer, which is what keeps a `ui_enabled: false` API user from
falling through to the generic 401.
* feat(auth): tokens can be revoked
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 kills every
session for every principal at once, is not reversible, and leaves no
record of who did it or why.
`TokenRevocations` 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 things follow from that shape. It is bounded - a
deny-list grows with every revoked token and needs an expiry sweep; a
watermark is one timestamp per principal. And it revokes sessions a
deny-list cannot name: "sign out everywhere" and "revoke everything issued
before the breach" have no list of token ids behind them. The cost is
precision - it cannot revoke one session and spare another issued in the
same second - which is the right trade for what it is for.
`iat` is compared with `<=`, not `<`: it 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.
Revocations are persisted, because the tokens they revoke outlive the
process. An in-memory revocation would be a security control that quietly
stops applying at the next restart. A file that will not parse is an error
at startup rather than an empty store - reading it as "nothing is revoked"
would silently reinstate every revoked session.
`POST /auth/revoke/{username}` ends one principal's sessions across both
identity namespaces; `POST /auth/revoke` ends everyone's. Both state their
`UserWrite` requirement through `AuthorizedClaims` in the handler
signature, because the `/auth` routes are mounted before any state exists
to build a router layer from - which is the case that extractor was for.
The refresh endpoint checks revocation too: a revoked token that could be
exchanged for a fresh one would make revocation a formality.
* refactor(iptv): page arithmetic has one home
A Stalker catalog page is the last one when it came back empty, when it is
shorter than the advertised page size, when the accumulated row count reaches
`total_items`, or when `max_page` says so. That rule was written out four
times - in the accumulating paginator, in `parse_item_catalog_page`, in
`parse_series_catalog_page` and in the `apply_page_limit` guard - and the two
`parse_*_catalog_page` copies had already drifted from the loop's version in
how they measured progress against `total_items`.
`PageMeta::is_terminal` is now the single copy, with the progress measurement
made explicit: accumulating callers pass their real running count, single-page
callers pass `PageMeta::fetched_estimate`, which is deliberately zero when the
portal advertises no page size so the `total_items` arm stays inert rather
than truncating the catalog at page one.
The two per-row-type page parsers collapse into one generic walk as well. They
differed only in `T`, and both hand-rolled the same four-shape row collection
(bare array, `data` array, `data` object, id-keyed envelope).
Behaviour is unchanged. Not verified by tests - checked with cargo check and
nightly clippy on the crate.
* feat(iptv): catalogs can stream instead of accumulating
`get_live_streams` and friends buffered an entire provider catalog into a
`Vec` before the caller saw a single row, while the EPG path next door already
had the right shape - `stream_bulk_epg` hands over batches as they arrive.
Catalogs now have the same option.
The interesting part is not the callback but what it costs. The client tries
several endpoint candidates in turn, and a failure part-way through pagination
abandons that candidate and restarts on the next one. That retry is only sound
while nothing has left the client, so the sink decides: `CollectSink` holds
everything and can restart freely - the historical behaviour, and the reason a
truncated catalog is never returned as `Ok` - while `BatchSink` reports that it
can no longer restart once a page has been released, and the driver then
returns the failure as-is. The streaming methods document that an `Err` makes
the delivered batches an incomplete prefix.
`get_*_paginated` are now thin adapters over the same driver rather than a
second copy of the pagination loop.
Not verified by tests - checked with cargo check and nightly clippy.
* refactor(iptv): expiry rules take the instant, they no longer read the clock
Session staleness, cookie `Max-Age`, and the Xtream account-expiry warning were
all pure functions of "what time is it" that reached for the clock themselves.
That made every one of them assertable only by approximation: the session test
back-dated a struct field by hand, the cookie test slept for two milliseconds
and then checked the cookie was still there, and the three-day expiry window
had no test at all because reaching it meant waiting for the calendar.
Each now takes the instant as a parameter, following the pattern the workspace
clock module recommends for exactly this case - the deadline logic in
`tuliprox-hls` already does it this way. The old signatures survive as
wrappers over the system clock, so no caller changed.
`crate::clock` holds the one epoch-seconds conversion, and is the only place in
the crate that reads wall time without being handed it.
What this buys, concretely: the cookie boundary is now asserted at the exact
second it flips, a backwards-running clock is shown not to age a session, and
the expired / expiring / quiet split of the account-expiry warning has tests
for all three branches.
Not verified by test execution - checked with cargo check and nightly clippy.
* feat(iptv): the Stalker client's network and clock are seams
`StalkerApiClient` owned a `reqwest::Client` and read the system clock, which
put every interesting decision it makes behind a live portal: the recipe
fallback chain, endpoint-candidate failover, pagination termination, body caps,
and the portal's habit of reporting `{"code": 44, "text": "Account is blocked"}`
inside a `200 OK`. The module docs conceded it outright - "no HTTP requests are
issued from unit tests" - which is another way of saying none of that was
tested.
Both dependencies are now type parameters defaulted to the production
implementation, the shape the workspace clock module recommends. `new()` is
unchanged, `SystemClock` is zero-sized, and neither seam introduces a vtable or
an allocation, so nothing about the production path moved.
Only the *send* is abstracted - requests are still built with reqwest's builder,
because a fake has to build them too and re-modelling a query string buys
nothing. `execute` returns a domain error rather than `reqwest::Error`, which
callers converted anyway and which `reqwest` will not let anyone else construct.
Nine tests now cover paths that previously had no way to be reached at all: a
portal refusal hidden in a 200 body surfacing as a token rejection, an
over-cap body being refused rather than buffered, an HTML error page not
decoding as JSON, endpoint-candidate failover in priority order, exhaustion
reporting the last real failure rather than a synthetic one, pagination
stopping on the advertised last page, a truncated catalog never being returned
as success, the streaming variant making the opposite trade explicitly, and a
session ageing past its TTL on a clock that can be advanced.
`inspect_portal_code` became a free function - it never touched `self`, and as
an associated function on a now-generic type its callers could not infer `Tr`.
Checked with cargo check and nightly clippy across the crate and its two
consumers. The tests added here have NOT been executed.
* refactor(iptv): one redaction module and one error classification
The crate had three unrelated answers to "what must never reach a log line":
`safe_stalker_url` for error URLs, an inline key list inside the debug-dump
writer, and `sanitize_sensitive_info` on the Xtream side. The sibling
media-server crate already keeps that in one module; this is its counterpart,
so the sensitive-key list is defined once and every path that renders a
provider string goes through it. `safe_stalker_url` survives as the name its
callers already use.
Alongside it, `StalkerErrorKind` mirrors `MediaServerErrorKind`. Callers were
matching on variants to answer questions the variants were never organised
around - is the provider down or is my token stale, is this worth retrying -
and a 403 could arrive as either `TokenRejected` or `BadStatus` depending on
which layer noticed it. `kind()` collapses that, and `is_retryable()` is
deliberately false for auth failures so nothing loops on a rejected token.
Also adds a JSON redaction walk with a test that a nested `cmd` is caught,
which the debug-dump writer's own copy never had.
Not verified by test execution - cargo check and nightly clippy only.
* feat(iptv): providers remember what they already told us
Capability knowledge was discovered and then thrown away every refresh.
Whether a portal implements `get_all_channels` was inferred from the shape of
the error it returned; which bootstrap recipe worked was found by walking a
five-entry chain from the top; which of three endpoint candidates answered was
rediscovered per call. None of it survived, so every refresh re-probed
endpoints already known to 404 and replayed a chain whose answer was known.
That is not an ordinary cache miss. A handshake chain replayed from scratch
against a portal with stale credentials looks, from the provider's side, a lot
like credential stuffing - the failure mode the Xtream side already carries a
standing TODO about.
`ProviderCapabilities` is the snapshot, and it is a hint rather than a
contract: every claim carries the instant it was observed and expires after a
day, so a provider that starts implementing an action is picked up without
anyone clearing state by hand, and a remembered endpoint is moved to the front
of the candidate list rather than replacing it. A clock that has run backwards
leaves the snapshot alone instead of invalidating everything.
`CapabilityStore` has two implementations: in-memory for a single run, and one
JSON file per input written through the workspace's atomic-write helper. Input
names come from user config, so the filename is derived rather than taken
verbatim; a corrupt file is ignored rather than fatal, because re-probing is
always available.
Wired into three places that now behave differently: `get_all_channels` is not
re-probed on a portal that has refused it, the handshake chain starts at the
recipe that last worked, and endpoint candidates start at the one that last
answered. Tests cover each, including the cases where the remembered answer has
gone stale or gone away.
Not verified by test execution - cargo check and nightly clippy across the
crate and both consumers.
* feat(iptv): one shape for "fetch this input's playlist"
The three provider families were modelled three different ways - M3U as free
functions, Xtream as free functions with a different arity, Stalker as a struct
client orchestrated from another crate - and each returned a differently-shaped
tuple. The dispatcher paid for it: a ninety-line match whose eight arms each
hand-assembled a six-element tuple, padding the fields their provider does not
produce with literal zeros and `false`s, then destructured the lot by position.
Two of the six elements were dead on arrival - bound to `_m3u_error_count` and
`_xtream_error_count`, computed in two arms, read nowhere.
`PlaylistFetch` is that result with names. `PlaylistProvider` is the one method
every family implements. Provider-specific inputs stay on the provider value -
the event sink on Xtream, the refresh mode on Stalker - so `fetch` takes only
what all of them take.
Dispatch stays a match and stays statically dispatched; the providers share no
supertype and constructing one is free. What changed is that each arm now names
one type and awaits it, and the two unimplemented input types are an
`UnsupportedProvider` carrying its reason rather than a seven-line tuple
literal. Net 98 lines out of the dispatcher.
Stalker's orchestration did not move: it reaches into `tuliprox-repository` and
this crate's own processors, and `tuliprox-iptv` sits below both. The trait is
what lets it stay where it is and still answer in one shape - `StalkerProvider`,
`LibraryProvider` and `PlexProvider` live here, `M3uProvider` and
`XtreamProvider` ship with their clients. The Plex fetch moved out of the
dispatcher into its provider on the way.
Not verified by test execution - cargo check and nightly clippy across iptv,
processing and the binary.
* fix(iptv): catalog fetches honour the configured body cap again
Actions were `&'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 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`; the lookup tested for `ordered_list`
and `all_channels`. Neither ever matched, so both fell through to the 8 MiB
fallback and a user who raised `ordered_list_mb` got 8 MiB anyway, with nothing
logged to say so. It went unnoticed because the fallback and the default happen
to be the same number.
`StalkerAction` is that set as an enum. Every action names its cap, the match is
exhaustive by construction, and the error variants carry something comparable
rather than a `String` each call site had to spell identically for a later
comparison to work - `is_unsupported_catalog_action` was doing exactly that.
The two handshake calls send the same `action=` query against different
endpoints, so the enum is the label rather than the wire value, and they stay
distinguishable in an error.
Behaviour changes only for users who configured a non-default `ordered_list_mb`
or `get_epg_mb`: they now get what they asked for. Session-shaped actions keep
the same fixed cap they landed on before.
Not verified by test execution - cargo check and nightly clippy across iptv,
processing and the binary.
* feat(iptv): provider failures answer one question
The three provider families report failure in two incompatible ways: Stalker
has a typed `StalkerError`, while M3U and Xtream hand back `Vec<TuliproxError>`
drawn from a workspace enum of forty-odd categories. So the dispatcher never
asked the questions it cares about - is this worth retrying, is the provider
down or is the config wrong - and every error was counted, logged and treated
identically.
Rather than a third error type for everything to convert through,
`ProviderErrorKind` is a classification both existing types map onto. Errors
keep their identity; the judgement is what gets unified. It is ordered by how
much attention a failure deserves, so a fetch that hit one timeout and one bad
portal URL is judged on the URL - `PlaylistFetch::error_kind` takes the worst,
not the first.
`Auth` is deliberately not retryable. 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.
Also fixes the Stalker-to-workspace conversion, which flattened all fifteen
variants to `ProviderConnection` - "the network had a bad moment". A
misconfigured portal URL and a rejected password both came out as connection
trouble, and both counted as retryable. The conversion now preserves the class.
The workspace `ErrorKind` arms are enumerated rather than matched by name
prefix, so a renamed variant is a compile error rather than a silent
reclassification.
Not verified by test execution - cargo check and nightly clippy across iptv,
processing and the binary.
* feat(iptv): EPG acquisition has one shape too
EPG was wired per provider family and the two never met. Stalker streams
programme records straight into its repository from three calls in this crate.
M3U and Xtream have no EPG here at all: their XMLTV download lives in
processing, produces documents on disk, and is reached from a separate call
site. Neither knew the other existed.
What they share 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
share is the shape of the answer, and `EpgOutcome` says which happened rather
than forcing one into the other: pretending they were the same would mean
either materialising a several-hundred-megabyte XMLTV document into records in
memory, or teaching the Stalker client to write XMLTV it has no reason to
write. The guide handle is an associated type, so the file-based provider hands
back its `TVGuide` and the streaming one hands back nothing.
Two real implementors: `StalkerEpgProvider` here, `XmltvEpgProvider` in
processing next to the download it wraps, now driving `download_input_epg`.
Per-source EPG failures travel alongside the outcome rather than replacing it -
three sources where one 404s still yields a usable guide from the other two.
`EpgProgramRecord` is an alias rather than a new type: the workspace already
has exactly one programme record, in core rather than in any provider's module,
and its Stalker-flavoured name was the only thing provider-specific about it.
Not verified by test execution - cargo check, nightly clippy and the workspace
dependency gate across iptv, processing and the binary.
* style(iptv): format the new modules with the project rustfmt config
Applied to the eleven files added by this branch only. The rest of the crate
predates the current rustfmt.toml and running `cargo fmt --all` over it would
rewrite files this work never touched - that drift is left alone and called out
separately.
* chore: refresh Cargo.lock
Adds `http` as a dev-dependency of tuliprox-iptv (the fake transport builds
responses with it) and picks up the `dashmap` edge tuliprox-auth's manifest
already declared but the lockfile had not been regenerated for.
* docs(changelog): record the messaging, event bus and auth work
Sixty-six commits since a2de1164 had reached the changelog nowhere. The
file has not been touched since the multi-crate split, so every entry
below is new rather than a revision.
Four breaking changes, each one a field that was written and then never
read back: `token_ttl_mins: 0` no longer mints a ~100-year credential, a
request that never authenticated answers 401 rather than 403, `notify_on`
is glob patterns over dotted event ids, and config responses mask the
channel secrets they used to return in full.
The new features are three threads that turned out to be one. The
notification layer became open-world - an event is an id, a channel is an
impl - which is what let four channels, per-channel routing and a durable
outbox land without a dispatcher change. The event bus became the single
backbone underneath it, so an event emitted once reaches the Web UI, the
notification pipeline and a plugin rather than whichever one its emitter
happened to know about. And auth gained the three things a stateless-JWT
server was missing: a throttle in front of the hash, a revocation
watermark behind the token, and an audit trail for both.
Fixes, optimizations, new settings and maintenance are filled out from
the same range. The refactor commits are summarized by what they buy a
user - units in the type system, one shape for config preparation, no
boxing on the playlist traversal - rather than restated as diffs.
Two claims are deliberately narrower than their commit subjects. Provider
capability memory is described as lasting a client's lifetime, because
`JsonCapabilityStore` is built but not yet constructed in the composition
root. And the registered event count is 32, not the 24 the docs commit
wrote - the events added after it never reached the generated table, so
`every_registered_event_appears_in_the_docs_table` currently fails. That
is left for its own commit; this one only says what is true.
* fix(events): watch payloads carry counts, not prose
`handle_watch_notification` truncated its own lists by pushing a
synthesised 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. `payload()`
serialises `WatchChanges` straight to JSON for plugins, and a plugin has
no way to tell a sentinel from a channel actually named that. The
notification subject line had the same problem from the other side: it
read `w.added.len()`, so a suppressed change of five thousand announced
itself as "1 channel(s) added".
`WatchChanges` grows `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.
* fix(events): a failed library scan stops reporting itself as finished
`notification_id()` mapped `LibraryScanProgress` to
`LIBRARY_SCAN_COMPLETED` whatever the summary said, so the failure path
in `spawn_library_scan` - which emits the same variant with
`status: Error` - reached operators as "A local library scan finished"
at info severity.
The taxonomy now discriminates on the status the payload already
carries, the way `PlaylistUpdate` has always done on its state: success
keeps `library.scan.completed`, failure takes a new
`library.scan.failed` at error severity, and `severity()` picks that up
from the registry with no second table.
`EventKind` gains `LibraryScanFailed` alongside the progress kind rather
than reusing it. `LibraryScanProgress` 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.
The emitter is unchanged - it was already reporting the status
correctly, and nothing downstream was reading it.
* feat(metadata): a broken input stops being silent
`InputMetadataUpdatesCompleted` only fires when a cycle drains *with
changes*, and a task that burns through its retries only reaches 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 it looked
exactly like one still working through a long queue.
The worker now counts the tasks that exhaust their retries during a
cycle and emits `InputMetadataUpdatesFailed` when that count is non-zero,
carrying 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 - suppressing it would trade one
silent failure for another.
Per cycle, not per task: a provider that has stopped answering fails
every item behind it, and `dedup_key` is per input for the same reason
`StreamProbeFailure` is.
`EventBusStats::Default` becomes a hand-written impl on the way past -
the taxonomy crossed 32 kinds and `Default` for arrays stops there.
* feat(events): the server says when it starts and stops
`system.started` and `system.shutdown` have been in the notification
registry - and in the documented event table - since it was written, with
nothing in the tree emitting either. An operator who subscribed got
silence.
`EventMessage::ServerLifecycle` carries both, one payload with two kinds
so a subscriber can ask for restarts alone. It reports the running
version, the bound address on start, and the signal name on stop.
Placement is the whole design here. The start event goes after
`spawn_notification_bridge`, not at the top of `main`: the bridge is what
turns a bus event into a notification, and anything published before it
subscribes reaches nobody. The stop event goes before
`cancel_all_service_tokens`, which 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.
* fix(admission): keep the strategy index aligned past a suppressed eviction
The strategy loop counted with a manual `idx` incremented at the end of the
body, but the eviction-reentry suppression arm exits via `continue` and so
skipped it. Every strategy evaluated after a suppressed eviction was handed an
index one too low.
That index is what `build_grace_ctx` stores as
`GraceResolutionContext.strategy_index`, and
`evaluate_remaining_strategies_after_grace` 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.
Switched to `iter().enumerate()` so the index cannot drift from the item.
Not covered by a test run.
* feat(messaging): a lost notification reaches the bus
`notification.dead_lettered` was registered and documented; the outbox
detected the condition, bumped `health().dead_lettered` and logged to
`notification::audit`, and that was the end of it. Nothing subscribing to
the bus could learn that a notification had been permanently lost.
The outbox now takes an `EventSink` - generic, like the rest of the
emitters after the static-dispatch pass - and emits
`NotificationDeadLettered` at the point it 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; 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 - neither runs through the path that broke - 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 currently be
told apart from a clean delivery without tracking that does not exist
yet.
* fix(admission): re-read admission after acquiring the per-user gate
`resolve_admission_with_strategies` read the admission state, then queued on
`acquire_user_admission`, then walked the eviction strategies using the snapshot
it had taken before it queued. A request that lost the race therefore acted on a
count the winner had already changed, and could evict a live connection to free
a slot that had been released while it waited.
The re-read is placed after the gate and after the empty-strategy check, so the
uncontended path costs nothing extra, and `build_grace_ctx` now captures the
fresh `kind`.
This does not close the wider check-then-register window: `connection_admission`
only inspects the counts, and the slot is registered by the caller outside this
gate, so two requests at `max_connections - 1` can still both be admitted. That
needs the registration brought under the same gate and is left alone here.
Not covered by a test run.
* fix(admission): stop evicting once a kick frees no connection
Eviction is destructive and is never rolled back. The strategy loop would kick a
target, find the retry still denied, and move on to the next eviction strategy -
so a request that ended up denied anyway could leave several other streams killed
behind it.
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 sets `evictions_ineffective`, and further
`Evict` decisions are skipped for the rest of the walk; `Grace` strategies are
still evaluated.
Not covered by a test run.
* refactor(admission): drop the unused kind_for_exhausted parameter and refresh docs
`evaluate_admission_strategy_loop` took `_kind_for_exhausted` and never read it -
both callers construct the exhausted result themselves. Removing it takes the
argument count down by one on a function that needed
`#[allow(clippy::too_many_arguments)]`.
The doc block on `evaluate_remaining_strategies_after_grace` listed a `Deny`
rule, but `AdmissionDecision` only has `NoMatch`, `Grace` and `Evict`. Replaced
it with what the `Grace` arm actually does now that the strategy index it records
is correct.
Not covered by a test run.
* feat(processing): the watch feature stops failing silently
`watch` had one event for everything it knows, and three ways to stop
working without saying so:
* every pattern failed to compile, so `ConfigTarget::watch` became `None`
and `process_watch` returned immediately - the feature disabled itself
on a typo behind a single `warn!`;
* the target carries the reserved default name, which logs and returns;
* the watch state file could not be read or written, which either
re-baselines the group - losing the change it should have reported -
or drops it entirely.
All three now emit `playlist.watch.disabled` with 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: it means "configured and unusable",
which is not the same as "not configured", and only the runtime layer has
an event sink to report it from.
`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` is invisible. `EventKindMask::from_wire_names` already returns
unmatched subscription names for this reason, and its test says why: a
typo must surface, not silently narrow what was asked for.
Matching now walks the groups once and records which patterns hit,
instead of re-testing every pattern per group inside a filter.
* refactor(admission): make the empty-strategy-list rule explicit
`get_effective_admission_strategies` matched on `admission_strategies.is_some()`
and then re-unwrapped with `unwrap_or_default()`, so the guard proved something
the body checked again. More importantly, 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 `admission_strategies.as_ref()` with the distinction
stated. Behaviour is unchanged: `Some(vec![])` still means "no strategies", not
"fall back to grace".
Not covered by a test run.
* perf(admission): carry the effective strategy list as Arc<[AdmissionStrategy]>
`GraceResolutionContext` is stored on `StreamInfo` and travels with every clone
of it, so a `Vec<AdmissionStrategy>` field meant reallocating the list on each
clone. `get_effective_admission_strategies` also handed back a fresh `Vec` that
`build_grace_ctx` then cloned again.
The list is immutable once resolved, so `Arc<[AdmissionStrategy]>` fits: the
context clone is now a refcount bump, and the one allocation left is the
`Arc::from` at resolution time. `evaluate_admission_strategy_loop` still takes a
plain `&[AdmissionStrategy]`, reached by deref, so slicing the remaining
strategies is unchanged.
Not covered by a test run.
* feat(processing): a target reports the groups it gains and loses
`watch` tracks channels inside named groups and is blind to the group set
itself, in both directions.
A group appearing was silent: `process_group_watch` found no baseline
file, wrote one and emitted nothing, so the group's entire channel list
read as "not new" from then on. A group vanishing was worse - it is
absent from the refreshed playlist, so nothing iterated it and no code
path observed the disappearance at all.
`process_target_groups_watch` diffs the target's group titles against a
persisted index and emits `playlist.groups.changed`. It runs before the
per-group fan-out and sees every group, not 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 rather than inside it
(`<target>.groups.bin`, not `<target>/__groups.bin`) so it cannot collide
with a group whose sanitized title matches. First sight writes the
baseline and says nothing; announcing every group as new on the first
refresh after an upgrade would be noise.
Sampled the same way `WatchChanges` now is: titles only in the lists,
counts that stay true whatever the lists carry.
Gated on `target.watch` being configured, so it costs nothing for targets
that never asked to be watched.
* refactor(admission): bundle the request-scoped admission arguments
`resolve_admission_with_strategies`, `evaluate_remaining_strategies_after_grace`,
`evaluate_admission_strategy_loop`, `get_admission_for_request` and
`should_suppress_eviction_for_recent_request` each threaded the same ten
positional parameters, three of them consecutive bare `bool`s
(`use_session_admission`, then `activate_unbound_session` a slot later). Callers
read `..., true, Some(session_token), true, guard)` - a shape where transposing
two arguments still compiles and silently changes which admission check runs.
They now take one `AdmissionRequest<'a>`, which names every field at the call
site. The `use_session_admission` 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`.
Formatted with the project rustfmt config. Not covered by a test run.
* feat(session): the provider pool says when it runs out and when it falls back
Two moments the lineup manager knew about and told nobody.
`provider.pool.exhausted`: `log_exhausted_pool_snapshot` built a complete
picture - per-provider current/max plus expiry - and discarded it unless
debug logging happened to be on. `ActiveProvider` reports that connection
counts moved; nothing reported that a stream was refused because every
provider behind the input was full. The snapshot is now built
unconditionally on that path (already the slow one) and both the debug
line and the event render from the same structured data.
`provider.priority.fallback`: `acquire` walks priority groups highest to
lowest and silently falls through when the preferred ones are at
capacity, so "you are being served off your backup" was invisible.
Reported on transition, not per allocation - the fallthrough 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.
The group is resolved after the fact from the allocated provider rather
than by threading an index out of `acquire`, which keeps the allocation
path and its return type untouched.
Input and provider names go through `sanitize_sensitive_info` before
reaching the payload, for the reason `StreamProbeFailure` documents.
* feat(processing): a failed playlist fetch says what kind of failure it was
`ProviderErrorKind` already classifies every provider failure across all
three families, and already exposes `is_retryable()` and
`needs_operator()` - the two questions an operator actually asks. Nothing
consumed either. Every fetch failure was counted, logged and treated
identically.
The dispatcher now emits `provider.fetch.failed` when a fetch reports
errors, carrying 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. That is the same payload-dependent override `PlaylistUpdate`
already uses for a partial refresh.
`ProviderFailureKind` mirrors `ProviderErrorKind` in `shared`, which
cannot host the original - it classifies a `StalkerError` that `shared`
does not know about. The conversion lives beside the original so a new
variant there is a compile error rather than a silent fallthrough.
Input name and error text go through `sanitize_sensitive_info`: both can
carry a provider URL with credentials in it.
* feat(app): a scheduled task that fails says so
The playlist update and the library scan both report their own outcomes.
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.
`scheduled_task.failed` carries the task type, the cron expression that
triggered it, and the error. `GeoIpUpdateError::Disabled` stays silent:
the task ran and correctly found nothing to do, which is not a failure.
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.
* feat(session): a refused connection reaches the bus
`ActiveUser` reports connects and disconnects. A refusal is neither, so
the one outcome a user actually complains about was the one nothing
published - the admission ladder modelled it fully, as what is left after
every eviction strategy declines, and handed it to the caller and nobody
else.
`user.connection.denied` carries the user, the address the request was
attributed to, and the limit that was reached. It sits with the auth
events rather than the streaming ones and takes `UserRead`: "who was
turned away" is the same question as "who signed in", and strictly
narrower than the system-wide read.
Only the strategy path emits. An explicit `Terminate` also resolves to
`Exhausted`, but that is a requested teardown, not a denial.
`ActiveUserManager` gains an `events()` accessor rather than `AdmissionCtx`
growing a second handle to the same manager.
* refactor(app): the notification bridge stays a routing table
Fourteen new events left `to_notification` doing its own wording inline,
at which point it was 213 lines and clippy was right about it. Each new
event's wording moves into a `*_notification` builder beside the three
that already existed, and the match goes back to being one arm per
variant with no logic in it.
`push_sampled_list` is shared by the two events that carry sampled lists,
so the "... N more not listed" wording has one home rather than two.
Also completes the documented event table. 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`
has been failing since before this branch. The rows are generated from
the descriptors so severity and description match exactly.
`stream.probe.failed` had a run of twenty-seven spaces inside its
description, left behind when a wrapped literal was collapsed onto one
line. Normalised in the registry and the table together.
* docs(changelog): record the bridge refactor and the completed event table
The eighteen commits since 4dee77a6 reached the changelog in d4b9c7d9,
which was about the notification bridge and carried CHANGELOG.md along
with it. This commit adds the two entries that commit earned and its own
message did not put in the file.
The docs table entry is the one an operator can act on: eight registered
events had no row, so `every_registered_event_appears_in_the_docs_table`
was failing and anyone reading section 5 to pick a `notify_on` pattern
could not see `user.*`, `auth.*` or `stream.probe.failed` at all.
The bridge refactor is recorded as maintenance because it buys one thing
a reader cares about - each event's wording has one home - rather than as
a line count.
* fix(xtream): a blank container extension falls back to the item url
A VOD document's `container_extension` comes from the provider's
`get_vod_streams` response, where a missing or null value collapses to an
empty string, and nothing fills it in afterwards unless a per-item
`get_vod_info` fetch or an ffprobe run happens to reach it. A provider
that omits the field therefore published `""` on every VOD, and a client
building `<stream_id>.<container_extension>` has nothing to append -
several stringify the blank and go on to request `813563.null`.
`get_vod_info` already fell back to the extension carried by the item URL.
The four builders that did not now share one helper on
`XtreamPlaylistItem`: the stream-list document, both no-properties paths,
and the resolved info document. That last one is post-processed on the
returned `XtreamInfoDocument::Video` rather than threading `url` through
`StreamProperties::to_info_document`, whose signature is shared with the
series and episode paths that have no URL to pass.
A non-empty provider value still wins, so the fallback cannot talk over
what the provider actually said, and an item whose URL carries no
extension either still reports the blank.
`create_vod_info_from_item` had the fallback already but kept the leading
dot that `extract_extension_from_url` returns, publishing `.mkv` into a
field that holds `mkv` everywhere else - a client would have built
`813563..mkv` from it. Stripped.
Series episodes are left alone. `SeriesStreamDetailEpisodeProperties`
carries no provider URL at that layer, only a `direct_source`, so the same
fallback has nothing to read there.
The six new tests are compiled but unrun.
* fix(frontend): reload plans when opening user create/edit screen
UserEdit previously fetched user plans only once on initial mount (use_effect_with((), ...)). Because panels are kept mounted in the SPA, newly created or modified plans in the Plans view did not appear in the plan dropdown until a full page reload or container restart.
Update effect dependencies to include active_page and selected_user so plans are re-fetched whenever navigating to UserlistPage::Edit.
* refactor(requests): replace reqwasm with gloo-net for HTTP requests
* refactor(locking): drop fs2 for std file locking
fs2's last release was 2018. Rust 1.89 stabilized File::lock,
lock_shared, try_lock, try_lock_shared and unlock together with
std::fs::TryLockError, and the workspace MSRV is 1.95 — so the
dependency comes out entirely rather than being swapped for fs4.
The syscalls underneath are unchanged. The one semantic shift is
try_lock: contention now arrives as TryLockError::WouldBlock instead
of an io::Error carrying ErrorKind::WouldBlock, which makes "lock is
held" a distinct variant from a real I/O failure at the type level.
* refactor(catchup): introduce effective_mode method to prioritize catchup-type
* refactor(catchup): enhance timestamp validation and error handling for catchup windows
* refactor(mapping): prepare extensible processing pipeline
* refactor(processing): compile target execution pipeline
* clippy fixes
* test fixes
* test fixes
---------
Co-authored-by: DarkBreakpoint <darkbreakpoint@github.com>
Co-authored-by: DarkBreakpoint <243206744+DarkBreakpoint@users.noreply.github.com>
Co-authored-by: euzu <euzu@proton.me>
|
||
|
|
f0996d146b | ci: bump version v3.3.92 | ||
|
|
9ec775b35b |
Feature/multi crate (#837)
Refactored to multi crate project |
||
|
|
eb9b2a01fa | ci: bump version v3.3.91 | ||
|
|
56ea26d764 |
Fix/dvr (#836)
New Features
Added recording availability checks before opening recording forms, with clear service-status errors.
Added configurable recording settings for retention, disk limits, quotas, notifications, priority, and filename templates.
Recording configuration is now available in video settings.
Added validation for recording quotas, ranges, and unsigned values.
Bug Fixes
Improved recording progress updates and configuration preservation.
Recording navigation visibility now follows recording permissions.
Style
Improved muted and debug log-console contrast across themes.
Translations
Updated English, Arabic, and Russian recording-related messages.
|
||
|
|
baf6198157 | ci: bump version v3.3.90 | ||
|
|
ed7f680829 |
Fix/stats view (#835)
New Features
Log consoles now support consistent theming across all available visual themes, including severity and target colors.
Filtering and actions use shared controls with improved accessibility hints and visual states.
Logs are expanded by default, with playlist updates displayed under a dedicated “Playlist Update” section.
Bug Fixes
Improved radio-button group borders and selected-state layering.
Refined stats panel sizing and styling for more consistent layouts.
|
||
|
|
dfde03e900 | ci: bump version v3.3.89 | ||
|
|
4173724a23 |
Fix/dialog stack (#834)
New Features
Dialogs can now be stacked, allowing multiple dialogs to remain open while keeping only the topmost dialog interactive.
Added improved dialog layering and dismissal behavior for nested dialogs.
Improvements
Updated the Yew framework and related routing and hooks libraries.
Improved dialog handling so background layers remain visible without responding to clicks or results.
|
||
|
|
2a73d4f2c7 | ci: bump version v3.3.88 | ||
|
|
11fe8f0443 |
Fix DiskAlert activation (#833)
New Features
Disk-space alerts can now be enabled and saved from the Web UI.
Selected radio buttons now have clearer background, text, border, and focus styling.
Messaging settings preserve configured disk-alert thresholds, including default values.
Bug Fixes
Disabled disk-space alerts are correctly removed when deselected.
Messaging notification types now accept standard and case-insensitive variant names.
Improved reliability when saving messaging configuration changes.
|
||
|
|
e731a5b8f2 | ci: bump version v3.3.87 | ||
|
|
1afbe7cca5 |
health check fix (#832)
New Features
Added a /ready readiness endpoint with clear initializing, exhausted, and ready states.
Improved health-banner behavior for shared provider capacity, disabled providers, aliases, and idle fallback capacity.
Bug Fixes
Improved capacity calculations with shared provider grouping, overflow-safe totals, and empty-capacity handling.
Documentation
Documented liveness, readiness, and detailed status endpoints, including Docker and orchestrator usage.
|
||
|
|
5f63fd453b | ci: bump version v3.3.86 | ||
|
|
a37468aab9 | ci: bump version v3.3.85 | ||
|
|
febdd1f582 | ci: bump version v3.3.84 | ||
|
|
16d32d1298 |
DVR Feature (#819)
feat: complete DVR and improve streaming, security, configuration, and UI Complete the Digital Video Recorder subsystem and add a broad set of reliability, security, streaming, configuration, processing, and Web UI improvements across Tuliprox. DVR: * complete live recording and provider-aware VOD download support * add recording queue, workers, scheduling, and recurring recording rules * add conflict detection and capacity-aware scheduling * add pause, resume, retry, edit, cancel, and delete workflows * add recording quotas and configurable retention policies * add crash recovery and startup reconciliation * add durable lifecycle notifications with per-channel retries * add DVR health monitoring and diagnostic tooling * add secure access to recordings, thumbnails, and subtitles * add WebSocket notifications for recording and rule changes * add Web UI management for recordings, rules, progress, and task state * add RBAC, configuration, documentation, and i18n support Streaming and HLS: * fix shared-stream idle handling and release dead provider streams correctly * stop tee streams when both client and cache consumers are gone * cancel provisioning probes when client streams terminate * fix transient HLS origin work accounting and intermittent 503 responses * make stream buffer byte limits configurable * make shared subscriber idle timeout configurable * make initial HLS manifest wait timeout configurable * add configurable TS chunk packet count * add configurable HLS refresh failure backoff * centralize redirect limits and retry jitter handling * improve provider DNS refresh behavior and failover tuning * preserve UTF-8 characters in catchup templates * improve stream history validation and persistence error handling Security: * use constant-time credential comparisons * harden library and media path handling against traversal and symlink escapes * only trust forwarded client IP headers from configured trusted proxies * redact credentials and sensitive URL data from logs * reject invalid authentication status-code configuration * deny users with unresolved plans or invalid content filters * improve authentication error handling across proxy and HLS endpoints Configuration and reliability: * prevent invalid api-proxy.yml reloads from terminating the running server * fully validate API proxy configuration before persisting changes * log configuration and EPG cleanup failures instead of silently discarding them * keep the last valid configuration active after failed hot reloads * align backend and shared media-server validation * remove duplicated path and normalization logic * improve DNS-store recovery and Windows rename fallback handling * reject invalid duration, timestamp, and numeric conversions safely * fix playlist bouquet save error handling * fix provider record update detection * fix cache boundary handling * improve startup and persistence failure diagnostics Filtering, search, sorting, and processing: * add field-scoped playlist explorer search * centralize shared stream-history search field definitions * extend the filter DSL with string, set, and numeric operators * add EPG ID, channel number, and detected quality as filterable fields * add filter dry-run preview API with match statistics and samples * report filter syntax errors with line and column information * add natural numeric-aware sorting * add quality-aware channel deduplication * add accent-independent deduplication * move natural sorting and quality detection helpers into shared code * persist explorer search-field selection across reloads User plans and content access: * add reusable API user plans for capability tiers * support inherited cluster and connection limits with per-user overrides * add plan-level and user-level content filters * enforce content filters across Xtream, M3U, direct playback, resource access, stream info, short EPG, categories, and XMLTV * add trial plans with automatic expiry and Trial status * add plan selection and content filtering to the user editor * add full plan management to the API configuration Web UI * migrate the API user database to schema V7 with plan and filter persistence Web UI and accessibility: * add live logging console to the stats page * improve login error handling and prevent duplicate authentication requests * add keyboard navigation to tabs, menus, tables, and search * add ARIA roles, labels, validation state, and live-region feedback * add confirmation dialogs for destructive actions * add unsaved-change warnings and Ctrl/Cmd+S shortcuts * add loading, progress, empty, and in-flight states across views * improve dropdown and single-selection behavior * add clipboard and credential-copy helpers * persist table pagination and explorer search preferences * improve error recovery when UI context providers are unavailable * remove multiple panic-prone unwrap and browser API paths * replace remaining hardcoded UI strings with translation keys Maintenance: * resolve backend and frontend compiler and Clippy warnings * update packages and test fixtures * consolidate duplicated helpers and validation logic * improve documentation for configuration, filters, plans, DVR, and REST APIs * add and update tests for migrations, filters, deduplication, sorting, configuration, streaming, and accessibility behavior |
||
|
|
df961e4bf7 | ci: bump version v3.3.83 | ||
|
|
6c4082992b | ci: bump version v3.3.82 |