diff --git a/backend/dvr/src/recording/recording_deletion.rs b/backend/dvr/src/recording/recording_deletion.rs index 8e6230953..94d60e06d 100644 --- a/backend/dvr/src/recording/recording_deletion.rs +++ b/backend/dvr/src/recording/recording_deletion.rs @@ -936,6 +936,38 @@ mod tests { assert!(!final_path.exists(), "the recording the first deletion claimed is the one removed"); } + #[tokio::test] + async fn even_a_principal_allowed_to_delete_cannot_unlink_a_referenced_file() { + // Task 14: authorization decides whether an entry may be removed. It + // does not decide whether the bytes go -- that is the reference rule, + // and it takes no principal at all. An admin or the retention + // supervisor removing their entry must still leave another user's + // recording on disk. + let dir = TempDir::new().expect("tempdir"); + let queue = RecordingQueue::new_persistent(dir.path(), dir.path()).expect("open recording repository"); + let shared_file = dir.path().join("programme.ts"); + std::fs::write(&shared_file, b"recorded bytes").expect("write"); + for (uuid, owner) in [("admin-entry", "builtin:admin"), ("bob-entry", "web:bob")] { + let mut task = finished_with_state(uuid, RecordingTaskState::Completed, None); + task.file_path.clone_from(&shared_file); + task.recording.owner = RecordingOwner::User(UserId::from(owner)); + let persisted = RecordingQueue::to_persisted(&task); + mutate(&queue, move |candidate| { + candidate.finished.push(persisted.clone()); + Ok(()) + }) + .await + .expect("seed"); + } + + // The permit says yes, as it would for an administrator. + let target = begin_deletion_authorized(&queue, "admin-entry", |_| true).await.expect("permitted"); + assert!(target.still_referenced); + assert_eq!(target.path_to_unlink(), None, "permission does not override a live reference"); + execute_deletion_target(&target).await.expect("execute"); + assert!(shared_file.exists(), "Bob's recording survives an administrator removing their own entry"); + } + #[tokio::test] async fn a_sole_entry_still_removes_its_file() { let dir = TempDir::new().expect("tempdir"); diff --git a/backend/dvr/src/recording/recording_queue.rs b/backend/dvr/src/recording/recording_queue.rs index afdc949d9..840a682b4 100644 --- a/backend/dvr/src/recording/recording_queue.rs +++ b/backend/dvr/src/recording/recording_queue.rs @@ -43,6 +43,7 @@ fn poisoned_repository() -> std::io::Error { } const RECORDING_WINDOW_EXPIRED_ERR: &str = "Recording window already expired"; +const RECORDING_INTERRUPTED_ERR: &str = "Recording was interrupted by a restart and cannot be resumed"; static RECORDING_TASK_ID_COUNTER: AtomicU64 = AtomicU64::new(1); /// Reason a persisted entry cannot be converted back to its in-memory form @@ -1101,6 +1102,19 @@ impl RecordingQueue { task.finished = false; return task; } + // A live capture cannot be picked up again: whatever was broadcast while + // the process was down is gone. Requeueing would restart it and write a + // recording silently missing everything up to now. + if task.kind == RecordingKind::Live + && matches!(task.state, RecordingTaskState::Running | RecordingTaskState::WaitingForCapacity) + { + task.paused = false; + task.finished = true; + task.state = RecordingTaskState::Failed; + task.error = Some(RECORDING_INTERRUPTED_ERR.to_string()); + task.next_retry_at = None; + return task; + } if !task.finished { task.paused = false; task.state = RecordingTaskState::Queued; @@ -1732,6 +1746,40 @@ mod tests { let _ = std::fs::remove_dir_all(state_file); } + #[test] + fn an_interrupted_live_capture_fails_instead_of_restarting() { + // The broadcast that played while the process was down is gone. A + // resumed live capture would write a recording with a hole at the + // front and report it as complete. + for state in [RecordingTaskState::Running, RecordingTaskState::WaitingForCapacity] { + let interrupted = RecordingTask { state, ..task("live", RecordingKind::Live, state) }; + let recovered = RecordingQueue::recover_loaded_task(interrupted); + assert_eq!(recovered.state, RecordingTaskState::Failed, "live in {}", state.label()); + assert!(recovered.finished); + assert!(recovered.error.is_some(), "the operator is told why"); + } + } + + #[test] + fn an_interrupted_transfer_is_still_resumed() { + // The counterpart: a file on a server is still there after a restart, + // so VOD and series pick up where they left off. + for kind in [RecordingKind::Vod, RecordingKind::Series] { + let interrupted = task("film", kind, RecordingTaskState::Running); + let recovered = RecordingQueue::recover_loaded_task(interrupted); + assert_eq!(recovered.state, RecordingTaskState::Queued, "{kind} must resume"); + assert!(!recovered.finished); + } + } + + #[test] + fn a_future_live_recording_survives_a_restart_still_scheduled() { + let scheduled = task("live", RecordingKind::Live, RecordingTaskState::Scheduled); + let recovered = RecordingQueue::recover_loaded_task(scheduled); + assert_eq!(recovered.state, RecordingTaskState::Scheduled, "a window that has not opened is untouched"); + assert!(!recovered.finished); + } + #[test] fn recover_loaded_task_requeues_waiting_states() { let waiting_for_capacity = RecordingTask {