Commit Graph
2593 Commits
Author SHA1 Message Date
DarkBreakpoint 8564ba7cda fix(dvr): a deletion already in flight is skipped, not restamped
Task 11, step 4: retention and a user delete can reach the same
recording.

`begin_deletion` stamps a task by overwriting `state` with the deleting
marker and recording the real prior state in
`deleting_previous_state`. A second pass then read `state` -- now
`Cancelled` -- and derived the prior state from it, concluding that a
Completed recording had been cancelled.

That distinction decides which file the deletion owns. Completed owns
the final file; Cancelled owns the `.partial`. So the second deletion
claimed a path the recording does not own, unlinked that instead, and
finalized -- removing the entry that named the real file while leaving
the file itself on disk, referenced by nothing.

`prior_terminal_state_runtime` now refuses a task that is already
stamped. The caller gets `NotTerminal`, which the service maps to
`InvalidState`, which retention already maps to `Skipped` -- with a
comment saying "already in Deleting" that had never been true. The
guard the surrounding code was written against now exists.

Also covers step 1, which had no test for its sharpest case: a
cancelled entry owns the `.partial`, so removing it while another entry
is mid-transfer on the same media unlinks exactly the file being
written. The transfer would stream into an unlinked inode and the
remaining user would end with nothing. Its counterpart is pinned too --
with nobody else holding it, an abandoned partial is still cleaned up.

Step 3 needed no work: non-terminal entries are already refused
removal, and that is already tested.
2026-09-01 12:21:18 -05:00
DarkBreakpoint fe9f3d6d83 test(dvr): pin the live window and the recovery decision table
Two areas the plan flagged as untested, both of which I had just changed
and neither of which had any coverage.

The live window. `recording_deadline_instant` anchors a capture's stop
time to the programme, not to how long the capture has actually been
running, so time lost to preemption or a capacity wait is not handed
back. That is the correct behaviour -- the broadcast ran regardless --
but nothing said so, and the obvious "fix" for a preempted recording is
to extend its window, which would run it into the next programme. Now
pinned, along with a closed window stopping immediately and non-live
kinds having no deadline at all.

The strategy split. A resumable transfer stages through `.partial`
because it can be interrupted and resumed; ffmpeg owns its output for
the whole capture and writes in place. One test, both directions.

The recovery decision table. `recovery_action_for` reads four inputs and
I added the fourth last commit. The test enumerates all sixteen
combinations and computes the expectation from the rules independently,
so a change to the implementation has to disagree with a written rule
rather than quietly agree with itself.

That table surfaced a real trade-off worth naming rather than leaving
implicit: a shared file outside the recording root finishes the deletion
instead of raising the unsafe-path warning. The warning exists to stop
an unlink nobody can vouch for, and a shared file is never unlinked
here, so there is nothing to stop -- while restoring would resurrect an
entry the user deleted. The diagnostic is traded for not undoing the
deletion. It has its own test saying so.
2026-09-01 12:11:26 -05:00
DarkBreakpoint feec393eee fix(dvr): finalizing a transfer twice completes it instead of failing it
`finalize_http_transfer` links the staged `.partial` to its final path
and then removes the partial. Linking is the one step that is not
naturally repeatable: `hard_link` answers `AlreadyExists`.

A crash between those two steps leaves both files. The task was not yet
marked Completed -- that happens after finalization -- so it is requeued
on restart, resumes, finds the partial already complete, takes the
`Unsatisfiable { complete: true }` branch, and finalizes again. That
second call returned `AlreadyExists`, which the caller turns into
`Failed`. A recording whose bytes are entirely and correctly on disk was
reported as failed, and because retry re-runs the same path it failed
that way every time.

Finalization is now idempotent. An existing file at the final path is
this task's own earlier link -- path reservation gives it sole claim --
so the transfer completes. If the sizes disagree that reasoning does not
hold, and it errors loudly instead of publishing bytes nothing checked.

The signature narrows from `&RecordingTask` to the two paths it actually
used, which is what makes the behaviour testable at all.

Also pins a claim I made and had wrong: retention keys on
(owner, channel), so two users sharing one file are already in separate
groups and each keeps their own entry under keep_last_per_channel. There
was nothing to fix; there is now a test saying so.
2026-09-01 11:35:01 -05:00
DarkBreakpoint 7c8315cd08 test(dvr): one file, two users, across restarts
Every defect in this series was a subsystem that had never seen a shared
file: deletion unlinked bytes another entry held, disk pressure counted
space it had not freed, startup recovery resurrected deleted entries.
Each was found by reading, and each was fixed with a unit test against a
hand-built candidate. That leaves the seams between them untested, which
is precisely where the bugs were.

This walks the real thing through the real repository: two entries for
the same programme, a restart, one deletion, another restart, the last
deletion. It asserts the file survives the first removal and leaves with
the second, and that both facts are durable.

The test is not vacuous in the direction that matters. If the two
entries stopped resolving to one identity, nothing would hold the file
during Alice's deletion, `freed` would come back true, and the
"no space is reclaimed" assertion fails rather than silently passing.

The counterpart test pins the other side: two different programmes must
not be merged into one file just because both are Live recordings from
the same input.
2026-09-01 11:09:50 -05:00
DarkBreakpoint dcfbf82c49 fix(dvr): retention and startup recovery understand shared files
Sweep of the paths that still assumed one entry owns one file. Deletion
was fixed when the assumption broke; these two were not, and both are
silent.

Disk pressure credited itself the full file size whenever the delete
callback returned `Ok`. Since a shared-file deletion correctly unlinks
nothing, a pass could "reclaim" gigabytes that never left the disk,
satisfy its low-watermark check on those phantom bytes, stop early, and
report success with the disk still full -- `pressure_unrelieved` would
read false. `system_retention_delete` now reports whether the file was
actually unlinked and `DeleteOutcome::Detached` carries that: the entry
counts as deleted, its bytes do not count as reclaimed, and the pass
keeps looking for something that genuinely frees space.

Startup reconciliation inferred "file still present, so the unlink never
ran, so restore the recording". That inference was sound when a file had
one owner. Now a correct deletion deliberately leaves a shared file
alone, so a crash between the unlink step and the finalize step would
resurrect a recording the user had deleted -- and it would come back on
every boot until someone else's entry went away. `recovery_action_for`
takes `still_referenced` and finishes the deletion instead.

Both use the same holder rule as the deletion path, including excluding
entries that are themselves mid-deletion, so a crash between two
deletions cannot strand the file either.

Checked and deliberately left alone: playback resolves the attached
entry's own relative path and only reads, so sharing is invisible to it.
Retention's keep_last_per_channel counts two entries on one file as two
recordings; that is a policy question, not a correctness one, and
deleting either is now safe.
2026-09-01 10:59:34 -05:00
DarkBreakpoint 9b4a56cf6b feat(dvr): the server tells the client which controls a recording offers
The transition graph landed as the single source of truth for state
changes, but the frontend never saw it. `action_availability` restated
the rules in TypeScript-shaped Rust: its own pausable set, its own
cancellable set, its own resumable-kind check. Two copies of a state
machine, and the module docstring already recorded that they had drifted
once before -- the queue would pause a task in any state while the
frontend offered pause for three.

`RecordingAllowedActions` moves to `shared` and rides on
`RecordingTaskDto`, computed by `allowed_actions` from the same
predicates the commands themselves use. The frontend now narrows that
set by permission and renders it:

    pause: can_manage && allowed.pause

State rules appear once, on the server.

The frontend tests that asserted "Live cannot be paused" and "VOD can be
retried" are gone rather than ported. They were restating the backend's
table through a second implementation, and the backend already proves it
exhaustively over kind x state x command. What is left tests what the
frontend actually decides now: that a command the server would refuse is
never offered, and that manage and delete gate separately.

`the_dto_offers_exactly_what_the_transition_graph_permits` walks all
three kinds across all nine states and compares the DTO against the
graph, so the projection cannot quietly stop matching.

The field is `#[serde(default)]`: a client that has not been rebuilt
sees an empty set and offers no controls, which fails closed.
2026-09-01 10:41:47 -05:00
DarkBreakpoint 044fe1a70f fix(dvr): every path into the active slot attaches instead of re-running
Making promotion materialization-aware only covered `promote_next_download`.
Five other sites filled the active slot with `queue.remove(0)` directly,
and one of them is the path that actually matters: when a transfer
finishes, `finish_active_and_promote` took the next entry blindly. Two
users queueing the same film hit exactly that -- the first completes, the
second is promoted, and it downloads the same bytes over the file that
was just written.

The other four are the same shape: retry requeue, capacity-wait requeue,
retry-limit-exceeded, and cancelling a paused active recording.

Promotion is now one routine, `promote_from_queue`, and every site calls
it. Filling the active slot without consulting the attach rule is no
longer something a caller can do by accident.

The two requeue sites gain something from this beyond consistency: an
entry retrying a transfer whose file another entry finished in the
meantime now attaches to it rather than retrying a download of bytes
that are already on disk.

`PromotionDecision::Wait` is documented as unreachable rather than left
looking load-bearing. With a single active slot, promotion only runs once
that slot is empty, so nothing can be in flight for the same media. It is
kept because it is the correct answer if that ever changes.
2026-09-01 10:26:20 -05:00
DarkBreakpoint af679d637d fix(dvr): one entry's deletion no longer destroys another's recording
The previous commit made one file reachable from several library
entries. It did not tell the deletion path. `DeletionTarget` still
documented "the single file this deletion owns" and unlinked
unconditionally, so Alice removing her entry deleted the bytes out from
under Bob's -- his entry survived pointing at a path that no longer
existed, and his playback started 404ing on a recording he never
touched.

The repository has carried reference counting since the split, but
nothing consulted it on the way to `unlink`. This is the same shape as
every other defect in this series: a guard that exists and is tested,
wired to nothing.

`begin_deletion_authorized` now counts the other entries holding the
same media inside the boundary that stamps the task, and
`path_to_unlink` returns `None` when there are any. The bytes go when
the last entry does.

Entries already mid-deletion are deliberately not counted as holders.
Two concurrent deletions that each saw the other would both decline to
unlink and strand the file with nothing pointing at it, which is the one
outcome worse than deleting too eagerly -- it is invisible.

Not changed: private quota is still charged per entry at full size, so
two users sharing a 4 GB film are each charged 4 GB. That is deliberate.
A user's limit must not depend on what strangers happen to record, and
the shared pool cannot double-count because two shared entries on one
programme land in the same pool and are already deduplicated there.
2026-09-01 10:22:03 -05:00
DarkBreakpoint 22379238ea feat(dvr): record shared media once and link it twice
Two users asking for the same film produced two downloads of the same
bytes. Deduplication was scoped to the requester on purpose: with a
single physical file per request, telling the second user "duplicate"
would have left them with nothing.

The split made one file reachable from several library entries, so that
constraint is gone -- but promotion still took the head of the queue
unconditionally. Two entries on one file would each have been promoted
and each run a transfer to the same path: two writers, one file.

Promotion is now materialization-aware. Before an entry runs,
`promotion_decision` asks what else holds its media:

  Execute    nothing does -- transfer it
  AttachTo   another entry already finished it -- adopt the file
  Wait       another entry is producing it -- do not open a second writer

`attach_to_completed` copies only the physical result. The owner,
visibility and quota stay with the entry: this is one user's link onto a
file another user's request happened to produce.

With that in place `candidate_has_duplicate_recording` narrows from
"anyone already asked for this" to "this quota pool already asked for
this", and `RecordingIdentity` drops its owner and visibility fields --
the same URL over the same window is the same bytes, whoever asked.

An empty media identity deliberately matches nothing, not even another
empty one. It means the identity could not be resolved, and treating two
unidentified requests as one recording would merge two different files.

Not changed: quota is still charged per entry against the full size, so
a shared file is counted once per attached entry rather than once
physically. That is the next step.
2026-09-01 10:00:15 -05:00
DarkBreakpoint 525ed829e9 feat(dvr): key a recording file by the media it holds, not by who asked
Second step of the shared-recording model. A materialization is now
identified by what it contains rather than by the first request for it, so
two principals asking for the same media converge on one physical file with
two library entries and a reference count of two.

The identity rule stays in the DVR, which is the only place that knows what
makes two requests the same recording. It is supplied as a serialised,
field-named key rather than a Debug string, because it is persisted and has
to survive a build that reorders the enum. A task with no identity falls
back to a file keyed on its own uuid, so unidentified tasks cannot silently
collide on one file.

Detaching is now meaningful: dropping one entry leaves the file and the
other entry intact, and only the last detach removes the file. Six tests
cover convergence, per-principal views of one file, distinct media staying
distinct, partial detach and last detach.

Behaviour is unchanged. The service still rejects a cross-principal
duplicate before it reaches persistence, so no two entries share a file in
practice yet. Enabling that additionally requires the queue to stop
promoting one task at a time into a single `active` slot: two entries on one
file would otherwise each be promoted and each run a transfer to the same
path. That is the next step, and it is why this one stops at persistence.
2026-09-01 09:51:06 -05:00
DarkBreakpoint 64c7b2333a feat(dvr)!: split persistence into materializations and library entries
First step of the shared-recording model. A recording is now stored as two
records: a PersistedMaterialization, which is the file on disk and the work
that produces it, and a PersistedLibraryEntry, which is one principal's link
to it. The materialization carries no owner and no visibility, because a
file that several users have asked for cannot belong to any one of them.

The mapping is still one entry per file, so no behaviour changes yet. What
changes is where the truth lives: reference_count is now a stored field with
one owner, and read_records() refuses any state where it disagrees with the
links pointing at the file, or where a link names a file that is not there.
A file deleted while still reachable, or kept forever after its last
reference went away, is not something a later pass can work out by guessing.

The DVR still works in whole tasks; split() and join() are the repository's
job precisely so the count has a single place to be maintained. join() is
lossless, and the link is authoritative for owner, visibility and quota
while the file is authoritative for everything physical.

Entry ids and materialization ids are separate namespaces from the start
(mat-<uuid>), so they cannot be used interchangeably in the interval before
one file legitimately serves several entries.

The schema name and database file change to recording_library, because the
previous shape stored the owner inside the task and cannot be expressed as a
one-to-one value migration into two records. The queue does not migrate — as
already documented — so the old database and history are left untouched
beside the new pair rather than failing the open.

The startup tests now discover the repository's artefacts instead of naming
them; hardcoding the filenames would have silently stopped testing the
fail-closed path the moment they changed, which is exactly what happened
here.
2026-09-01 09:39:00 -05:00
DarkBreakpoint 67d75d7ab4 docs(dvr): correct the operator reference and repoint the doctor
The documentation described a system that no longer exists, and in two
places described the opposite of what the code now does.

- The layout section documented `<recording-root>/users/<owner-id>/<rel>`
  for private recordings and `shared/<rel>` for shared ones. That resolver
  was deleted: recordings are stored owner-independently at
  `<recording-root>/<rel>`, because one physical file is shared by every
  user who asked for it. The organised layouts and the component
  sanitisation rules are now documented as they are implemented.

- The config reference claimed persisted queue recovery "is tolerant of
  corruption" and "starts with an empty transfer queue instead of aborting
  server boot". The queue now fails closed: a damaged database is rebuilt
  from the recovery history, and a database ahead of every surviving
  history refuses to start. An operator following the old text would have
  expected silent recovery from a condition that is deliberately fatal.

- `download.read` / `download.write` were still listed as grantable
  permissions after their removal, and `recording.write` after its split.

bin/dvr_doctor.sh looked for `downloads_state.json` and summarised it with
jq. That file never existed under this name, and the queue it stood for is
now a B+Tree, so the section printed "(absent)" and skipped its summary
exactly when an operator needed it. It now reports the repository and its
recovery generations: the CURRENT pointer, the retained generation pair,
journal sizes, the fail-closed case where a database has no history, and a
warning when the recovery directory shares a filesystem with the database
— which survives a corrupt file but not the loss of the volume it exists
to protect against.

CHANGELOG records the three breaking changes: the non-migrating queue, the
permission split, and the moved recording files.
2026-09-01 09:23:52 -05:00
DarkBreakpoint 6ecda126a8 fix(dvr): stop disk pressure deleting recordings retention protects
disk_pressure_candidates selected every Completed recording with a
completed_at, sorted oldest first, and never consulted the retention
policy. Under pressure the DVR would therefore delete a recording that
finished a minute ago, even with delete_after_days: 30 and
keep_last_per_channel: 10 configured — destroying user data to reclaim
space the operator's own configuration says to keep. Each candidate was
also labelled RetentionReason::Age, which nothing had checked.

Pressure now accelerates retention rather than widening it: the candidate
set is exactly compute_candidates(), so only recordings already eligible by
age or per-channel count can be deleted early, and they keep the reason
retention assigned them.

When the eligible set is exhausted before the low watermark is reached, the
pass stops and reports pressure_unrelieved with an audit warning. Everything
still on disk is held by policy at that point, so only an operator can free
the space; deleting past it is the data loss this commit removes.

The watermark measurement moves into FilesystemUsage, which keeps the
selection guard (a measurement of another mount must never authorize
deletions) next to the numbers it qualifies.
2026-09-01 09:18:28 -05:00
DarkBreakpoint 49df51beac fix(dvr): make resume validation actually run, and keep the transport's headers
The resume validator was always constructed with ..ResumeValidator::default(),
so expected_etag and expected_last_modified were permanently None and the
ETag/Last-Modified branches of validate_resume_response were unreachable.
The checks were written and tested, but nothing fed them: a provider that
replaced a file between an interruption and the resume returned a
well-formed 206 at the requested offset, and those bytes were appended to a
partial belonging to the previous resource. The result is a silently
corrupt recording that reports success.

RecordingMetadata now persists the validators captured from the first
response, so they survive a restart. Only a fresh transfer may set them; a
resume keeps the ones its partial was written against.

Weak ETags are discarded rather than stored. RFC 9110 forbids a weak
validator in a range request because it promises only semantic equivalence,
so two responses can share a weak tag and still differ byte for byte. A
response that downgrades a previously strong tag to a weak one now fails
validation instead of passing it by absence.

filter_request_header also rejected only host and connection, so a
configured Range or If-Range became a client default header and collided
with the offset a resumable transfer asks for. The transport-owned set now
covers the range and conditional headers plus Content-Length and
Transfer-Encoding, matched case-insensitively.
2026-09-01 08:56:46 -05:00
DarkBreakpoint 9728200fd0 fix(dvr): deduplicate repeated VOD and series requests
Duplicate detection only produced an identity for Live captures, so no VOD
or series request was ever compared against anything. A user who asked for
the same film twice got two concurrent downloads of the same URL, written
side by side as film.mp4 and film_1.mp4, each charging their quota. There
were no tests for duplicate detection at all.

persisted_recording_identity now answers for every kind. The Url identity
gains the quota pool dimension that Programme already carried, so
deduplication is per principal: a second user asking for the same film is
still admitted. Deduplicating across principals would mean telling them
"already recording" and leaving them with nothing, which is only correct
once one physical file can carry several library entries.

Behaviour preserved: a finished transfer does not block a fresh request,
because after a completed or failed attempt the user may legitimately want
another copy. A completed rule occurrence still blocks, because the
scheduler re-evaluates rules on every tick and would otherwise
re-materialize it forever.

Six tests now pin these rules.
2026-09-01 08:31:04 -05:00
DarkBreakpoint 76acd7be03 fix(dvr): resolve recordings through one owner-independent layout
Playback could not find any recording. Two independent path computations
existed and disagreed:

  writer  RecordingTask::new  -> <root>[/<subdir>]/<filename>
  reader  resolve_for_open    -> <root>/users/<owner>/<filename>

resolve_recording_dir invented a users/<owner>/ or shared/ prefix that
nothing ever wrote, and relative_path stored only the bare filename while
the organised subdirectory lived in file_dir. Nothing covered the round
trip, so the mismatch survived.

recording_path.rs is now the only layout. It is pure, and deliberately
owner-independent: one physical file is shared by every user who requested
it, so keying its directory on an owner is wrong the moment a second user
attaches and would force the file to move when the first detaches.
resolve_recording_dir and its RecordingVisibility are deleted rather than
left as a second answer.

relative_path is now root-relative and carries the full layout, so the
writer's path and the reader's resolution are the same expression. Series
recordings gain the Season NN level they were documented to have but never
produced.

Component sanitisation is one function with a table test: separators,
control characters and the Windows-forbidden set collapse to _, leading and
trailing dots are stripped so a name cannot be hidden or silently aliased,
Windows device names fall back to a placeholder, and components are capped
at 255 bytes on a character boundary so a multi-byte title truncates to
valid UTF-8 rather than to a split character. A property test asserts that
no input the builder accepts can produce a path the containment check
rejects.

Collision suffixes now apply to the file component only, so reserving a
name no longer flattens the directory it belongs to.
2026-09-01 08:24:20 -05:00
DarkBreakpoint 38e81b84cd feat(dvr): make one transition graph the source of truth for recording state
The rules for which commands a recording accepts were restated in four
places: the queue's pause_active/resume_active/retry_finished, the
service's own guards, a stringly-typed EDITABLE_STATES list compared
against state.label(), and the frontend's action table. They had already
drifted — pause_active would pause a task in any state as long as its kind
was resumable, while the frontend only offered pause from Running,
WaitingForCapacity and RetryWaiting.

recording_transition.rs is now the only place that decides. transition()
is a pure function of (kind, state, command), and allowed_actions()
projects the control set from the same predicates rather than restating
them, so a button and the command behind it cannot disagree.

Behaviour this corrects:
- pause is refused outside the three interruptible states, instead of
  being accepted from any state.
- retry is refused for a Completed recording. There is nothing left to
  fetch, and requeuing one would re-download a finished file.
- Live refusals now say the kind cannot be paused rather than the state
  cannot, because a Live capture is never one state away from pausable.

state_is_editable takes a RecordingTaskState instead of a label, so the
edit cutoff is no longer a string comparison against a hand-maintained
list. EDITABLE_STATES stays as the wire vocabulary and is pinned to the
graph by a test.

The transition table is asserted exhaustively: every kind x state x
command triple is checked against a table of the twelve allowed edges, so
adding a state without deciding its rules fails the build.
2026-09-01 08:00:07 -05:00
DarkBreakpoint 154112de52 feat(dvr)!: persist the recording queue in a recoverable B+Tree
The queue was a single JSON document rewritten in full on every mutation.
That cannot survive a torn write, and a change to the record shape had no
upgrade path other than refusing to load — load_from_disk fails closed on
an unknown schema_version, which would have stranded every operator on a
version bump.

Replace it with RecordingRepository: one record per task in a B+Tree, with
every write routed through BPlusTreeRecoveryJournal. A deleted or corrupt
database is now rebuilt from the recovery history instead of being lost,
and a future record-shape change becomes a migration step rather than a
refusal to start.

- backend/repository/src/recording_repository.rs owns the persisted shape
  and RecordingRecoverySchema V1. It knows nothing about execution: the DVR
  decides what a task means and hands over a set to commit atomically.
- commit() diffs the candidate against what is stored, so the recovery
  journal stays proportional to what actually changed rather than to the
  size of the queue.
- The partition (queued/scheduled/active/finished) is stored explicitly.
  Deriving it from the state would be wrong: `active` is a distinct slot
  and two tasks in the same state can sit in different partitions.
- Repository calls run on spawn_blocking; it fsyncs both the journal and
  the B+Tree, so it must not run on a runtime worker thread.

RecordingTaskState moves to shared, along with the label() helper and the
TransferStatusDto conversion that were inherent impls in the DVR. The
repository crate cannot depend on the DVR, and the state is part of the
persisted contract either way.

Recovery generations are written under backup_dir, so they survive the loss
of the storage volume.

There is no migration from recordings_state.json: an existing queue is
discarded on upgrade.
2026-09-01 07:52:22 -05:00
DarkBreakpoint eaf456c18f refactor(recording): define the create-recording contract once, in shared
The request body was declared twice: CreateRecordingTaskBody in the REST
handler and CreateRecordingTaskRequest in the frontend client. The two had
to agree by hand, and they had already drifted — the frontend typed
visibility as a String and built "private"/"shared" literally at three call
sites, so a typo compiled cleanly and only failed once the server rejected
the body.

Move both the request and its source identifiers into shared as
CreateRecordingRequest / RecordingSourceRequest and have each side use
them. visibility is now the RecordingVisibility enum end to end, so
visibility_to_wire returns a value the server can parse by construction.

Both types deny unknown fields: a client sending a field this build does
not understand has a different idea of what it is asking for, and
recording the wrong thing is worse than refusing. Tests cover the exact
minimal JSON, an injected "url" field, an unknown visibility and an
unknown source field.

Also widen the availability preflight to accept any recording permission.
It answers "is the DVR usable" and carries no recording data, so gating it
on read-or-create alone would have refused a manage-only principal.
2026-09-01 07:23:02 -05:00
DarkBreakpoint 5561ff7690 feat(auth)!: split recording.write into create, manage and delete
A single write permission could not express the policy the DVR needs: a
user who may request a recording is not necessarily one who may cancel
someone else's, and neither implies the right to delete a file. Replace it
with recording.create, recording.manage and recording.delete, and map each
action onto the one it actually needs:

- create private/shared          -> recording.create
- edit, cancel, manage rules     -> recording.manage
- delete, system retention sweep -> recording.delete

The split renumbers every permission bit above recording.read, so
CURRENT_PERMISSION_SCHEMA_VERSION is bumped to 4 and tokens issued earlier
fail closed at the validator instead of having their bits reinterpreted.
The removed recording.write name now decodes to nothing, so a groups file
that still lists it loses the permission rather than silently gaining one
of the three replacements.

Permission bit values are frozen in a test: they are the wire format, and
reordering the enum would reinterpret every issued token.
2026-08-31 15:30:39 -05:00
DarkBreakpoint dcd51ee78d feat(btree): add a recoverable, schema-versioned B+Tree journal
The write-ahead log makes a single commit atomic but cannot rebuild a
database whose value type has changed, because its cells hold positional
MessagePack. Add a second, independent history of field-named JSON records
so a database can be reconstructed from any schema version the application
still knows how to migrate.

BPlusTreeRecoveryJournal owns the only mutation path: it appends and syncs a
journal record before touching the B+Tree, so a crash can only leave recovery
ahead - which open() repairs by rebuilding through staged publication - and
never leave the database ahead of its own history.

- recovery/format.rs: bounded frames (magic, length, BLAKE3) around JSON
  payloads, with a hash chain and torn-tail tolerance.
- recovery/schema.rs: RecoverySchema, plus the one-step-at-a-time migration
  loop; a record is only ever decoded at the current version.
- recovery/generation.rs: immutable manifests, the CURRENT pointer, crash-safe
  generation selection, verified checkpoint publication and bounded pruning.
- v3: BPlusTreeMetadata::Recovery carries database id, schema fingerprint,
  schema version and applied revision in the database header. Existing
  metadata encoding is unchanged.

The schema fingerprint deliberately excludes the version, so raising
CURRENT_VERSION migrates an existing database instead of rejecting it.
2026-08-31 15:22:09 -05:00
euzu 486aa955d8 DVR Feature 2026-08-29 11:14:44 +02:00
euzu 3d42c7ab9e DVR Feature 2026-08-28 19:17:39 +02:00
euzu 93a5c5f162 DVR Feature 2026-08-28 09:58:27 +02:00
euzuandGitHub 9ec775b35b Feature/multi crate (#837)
Refactored to multi crate project
2026-08-27 15:36:23 +02:00
euzu eb9b2a01fa ci: bump version v3.3.91 v3.3.91 2026-08-24 18:16:43 +00:00
euzuandGitHub 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.
2026-08-24 20:14:47 +02:00
euzu baf6198157 ci: bump version v3.3.90 v3.3.90 2026-08-24 10:10:51 +00:00
euzuandGitHub 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.
2026-08-24 12:09:40 +02:00
euzu dfde03e900 ci: bump version v3.3.89 v3.3.89 2026-08-22 22:15:03 +00:00
euzuandGitHub 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.
2026-08-23 00:12:52 +02:00
euzu 2a73d4f2c7 ci: bump version v3.3.88 v3.3.88 2026-08-22 19:37:16 +00:00
euzuandGitHub 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.
2026-08-22 21:35:13 +02:00
euzu e731a5b8f2 ci: bump version v3.3.87 v3.3.87 2026-08-22 16:53:29 +00:00
euzuandGitHub 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.
2026-08-22 18:52:19 +02:00
euzu 5f63fd453b ci: bump version v3.3.86 v3.3.86 2026-08-21 18:06:25 +00:00
MaxPain99andGitHub 95aeee4618 fix: allow same-session HLS soft-preserve reactivation (#826)
* fix: allow same-session HLS soft-preserve reactivation

Since #807, LiveHls segment gaps soft-preserve the stream then Activate returned Exhausted and kick-evicted the same session, causing disconnects/retry storms. Restore 3.3.78 entitlement via session_has_stream (includes preserved) and drop unused session_has_active_stream.
2026-08-21 20:05:05 +02:00
euzu a37468aab9 ci: bump version v3.3.85 v3.3.85 2026-08-21 17:25:13 +00:00
Stenio AraujoandGitHub f2370b1545 Support VOD (movies) and Series from M3U sources in the Xtream API (#830)
* feat(parser): support tvg-type VOD/Series in M3U and synthesize SeriesInfo

- recognize tvg-type=movie|vod|video as Xtream VOD, series|episode as
  Series, and live explicitly, taking precedence over extension inference
- for series rows, treat group-title as the show name and #EXTGRP only as
  the default Xtream series category, keeping @Group mappable
- group series rows by category + show name and synthesize
  SeriesStreamProperties/SeriesInfo via the existing Xtream series path
- extract SxxEyy from titles; generate deterministic u32 episode IDs;
  use numeric tvg-id as series ID when available
- build docker with rust 1.95 (project requires rustc >= 1.95.0)

* fix(xtream): serve embedded seasons/episodes for M3U-synthesized SeriesInfo

M3U-synthesized SeriesInfo items carry their series details in
additional_properties but were not recognized by the stream info endpoint
(item_type is not local, no provider URL), so get_series_info returned an
empty array and IPTV clients showed no seasons/episodes.

Route items with embedded details through the local info-document path so
embedded seasons/episodes are serialized by to_info_document.

* fix(parser): normalize series category in M3U series-map key
2026-08-21 19:23:53 +02:00
euzu febdd1f582 ci: bump version v3.3.84 v3.3.84 2026-08-21 12:52:19 +00:00
euzuandGitHub 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
2026-08-21 14:50:12 +02:00
euzu df961e4bf7 ci: bump version v3.3.83 v3.3.83 2026-08-06 20:31:25 +00:00
GoldyandGitHub add49abde0 feat(epg): make smart matching deterministic and programme-aware (#821)
* feat(epg): make smart matching deterministic and programme-aware

Centralize XMLTV and ICS candidate ranking, validate matches against programme availability, and reuse populated IDs across normalized stream variants.

Reject ambiguous, numeric-conflicting, cross-country, and decorative matches while respecting source priority and correcting stale IDs. Extend normalization defaults, diagnostics, documentation, and regression tests.

* fix(epg): address smart matching review feedback

Retain directly referenced XMLTV channels without treating empty guides as valid smart-match candidates.

Harden UTF-8 marker stripping, limit guide-name collection, refine decorative entry detection, avoid repeated country parsing, and document the normalization migration path.
2026-08-06 21:38:39 +02:00
knylbyteandGitHub 8921d8d5af fix: make Xtream refresh generations safe and Windows durable (#820)
## Summary

- Keep an Xtream refresh generation protected for its complete lifetime so orphan cleanup cannot delete active staging artifacts between B+Tree batches.
- Hold cleanup ownership through deletion and remove inactive generation artifacts, sidecars, and guards as one coordinated unit.
- Publish Xtream category snapshots on Windows with an atomic write-through rename while preserving the existing Unix parent-directory durability barrier.
- Add deterministic regression coverage for cleanup races, active generations, stale sidecars, atomic category replacement, and partial-publication errors.

## Root cause

The orphan sweep previously used an instantaneous sidecar-lock probe as proof that a refresh generation was inactive. Legitimate unlocked intervals between B+Tree batches created a race in which active staging data could be removed.

The category publisher also reused the Unix directory-sync approach on Windows. The rename could succeed before opening the parent directory failed, causing the refresh to report a misleading partial-publication error.

## Validation

- `make fmt-check`
- `make lint`
- `cargo +stable clippy --workspace --all-targets --all-features -- -D warnings`
- `make test` — 3100 backend tests passed, 5 ignored; integration and documentation tests passed
- `make markdown-lint`
- Focused Xtream cleanup and category-publication regression tests

Closes #817
Closes #818


* **Bug Fixes**
  * Improved cleanup of outdated Xtream refresh data while protecting active refreshes from accidental deletion.
  * Added safer handling for incomplete, invalid, or locked refresh data.
  * Improved reliability when publishing refreshed category data across supported platforms.
  * Added stricter validation to prevent unrelated files from being removed.
2026-08-06 21:36:55 +02:00
euzu 6c4082992b ci: bump version v3.3.82 v3.3.82 2026-08-05 15:04:28 +00:00
knylbyteandGitHub 66f1d05dcf fix: prevent ghost user connection slots (#815)
* fix: keep preserved user sessions uncounted

* The preserved branch now returns without mutating real counters. Preserved states continue to participate virtually in admission decisions and remain evictable. A real slot is created only by the existing stream-commit path, which also establishes its lifecycle and stream-kind ownership.

The regression helper derives Normal and Soft counters from logical owners while deduplicating a counted session and its active stream as the same slot.
2026-08-05 17:02:19 +02:00
euzu 77e7432ad6 ci: bump version v3.3.81 v3.3.81 2026-08-05 14:00:48 +00:00
knylbyteandGitHub 2dedd99719 fix: prevent Xtream refresh deadlocks (#814)
* **New Features**
  * Added staged, atomic publication for playlist databases and category refreshes.
  * Refreshes preserve existing playlist details and report update outcomes.
* **Bug Fixes**
  * Improved recovery from interrupted writes and pending changes.
  * Prevented conflicts between staging and published data, including path aliases.
  * Improved cleanup of temporary, stale, and orphaned refresh artifacts.
* **Reliability**
  * Enhanced handling and reporting of locking, synchronization, and publication errors.
  * Improved behavior during concurrent refreshes and repeated updates.
2026-08-05 15:58:49 +02:00
euzuandGitHub 52efaaa301 Feature/optimization (#816)
* **Performance**
  * Improved memory usage and persistence speed for large playlists and electronic programme guides.
  * Large EPG datasets now process more reliably while preserving channel priorities and cleaning up temporary data.
  * Reduced allocation overhead during parsing and playlist processing.

* **Bug Fixes**
  * Prevented programme descriptions from being truncated during XMLTV imports.
  * Improved handling of empty or single-source EPG data.

* **Network**
  * Optimized response compression by avoiding compression for small or already-compressed media files.
2026-08-05 15:04:13 +02:00
euzu de45535647 ci: bump version v3.3.80 v3.3.80 2026-08-01 10:51:57 +00:00