Commit Graph
21 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 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 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
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