From 16d32d1298f8d64503daeb1e64669010df513ba3 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:50:12 +0500 Subject: [PATCH] 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 --- .github/workflows/ci.yaml | 6 + CHANGELOG.md | 97 + Cargo.lock | 107 +- Cargo.toml | 8 +- README.md | 31 +- backend/src/api/api_utils.rs | 70 +- .../api/endpoints/custom_video_stream_api.rs | 4 +- backend/src/api/endpoints/download_api.rs | 1779 +++++++++++--- backend/src/api/endpoints/hls_api.rs | 170 +- backend/src/api/endpoints/library_api.rs | 4 + backend/src/api/endpoints/log_ws_api.rs | 246 ++ backend/src/api/endpoints/m3u_api.rs | 8 +- backend/src/api/endpoints/mod.rs | 6 +- .../src/api/endpoints/provider_resolve_api.rs | 11 +- backend/src/api/endpoints/recording_api.rs | 1325 ++++++++++ .../src/api/endpoints/recording_media_api.rs | 568 +++++ .../src/api/endpoints/stream_history_api.rs | 146 +- backend/src/api/endpoints/user_api.rs | 1 + backend/src/api/endpoints/user_visibility.rs | 77 + backend/src/api/endpoints/v1_api.rs | 141 +- backend/src/api/endpoints/v1_api_config.rs | 79 +- backend/src/api/endpoints/v1_api_playlist.rs | 686 +++++- backend/src/api/endpoints/v1_api_user.rs | 37 +- backend/src/api/endpoints/web_index.rs | 39 +- backend/src/api/endpoints/websocket_api.rs | 304 ++- backend/src/api/endpoints/xmltv_api.rs | 10 + backend/src/api/endpoints/xtream_api.rs | 96 +- backend/src/api/main_api.rs | 17 +- backend/src/api/model/active_user_manager.rs | 9 + backend/src/api/model/app_state.rs | 153 +- backend/src/api/model/download.rs | 1894 +++++++++++++-- backend/src/api/model/event_manager.rs | 10 +- backend/src/api/model/hls_cache/manager.rs | 6 + .../src/api/model/hls_cache/manifest_fetch.rs | 23 +- backend/src/api/model/hls_cache/mod.rs | 3 + backend/src/api/model/hls_cache/refresh.rs | 50 +- .../src/api/model/hls_cache/resource_fetch.rs | 3 +- backend/src/api/model/hls_cache/response.rs | 7 +- .../src/api/model/hls_cache/segment_repair.rs | 8 +- .../api/model/hls_cache/transient_fetcher.rs | 8 +- backend/src/api/model/mod.rs | 11 +- backend/src/api/model/recording/mod.rs | 31 + .../recording/recording_catalog_access.rs | 438 ++++ .../api/model/recording/recording_conflict.rs | 398 +++ .../recording/recording_currently_airing.rs | 154 ++ .../api/model/recording/recording_deletion.rs | 783 ++++++ .../src/api/model/recording/recording_disk.rs | 403 ++++ .../src/api/model/recording/recording_edit.rs | 402 ++++ .../src/api/model/recording/recording_math.rs | 101 + .../model/recording/recording_notification.rs | 238 ++ .../recording_notification_adapter.rs | 201 ++ .../recording/recording_observability.rs | 260 ++ .../model/recording/recording_occurrence.rs | 545 +++++ .../api/model/recording/recording_quota.rs | 656 +++++ .../recording/recording_reconciliation.rs | 436 ++++ .../model/recording/recording_retention.rs | 740 ++++++ .../recording/recording_rule_scheduler.rs | 531 ++++ .../model/recording/recording_rule_service.rs | 369 +++ .../api/model/recording/recording_security.rs | 327 +++ .../api/model/recording/recording_service.rs | 2128 +++++++++++++++++ .../recording/recording_supervisor/health.rs | 63 + .../recording/recording_supervisor/mod.rs | 164 ++ .../recording/recording_supervisor/outbox.rs | 409 ++++ .../recording_supervisor/retention.rs | 236 ++ .../recording/recording_supervisor/startup.rs | 280 +++ .../api/model/recording/recording_worker.rs | 729 ++++++ .../recording/recording_worker_runner.rs | 762 ++++++ .../src/api/model/recording/recording_ws.rs | 359 +++ backend/src/api/model/recording_worker.rs | 451 ---- .../api/model/streams/active_client_stream.rs | 13 +- .../src/api/model/streams/buffered_stream.rs | 20 +- backend/src/api/model/streams/mod.rs | 1 + .../api/model/streams/persist_pipe_stream.rs | 19 +- .../model/streams/provider_stream_factory.rs | 47 +- .../model/streams/shared_stream_manager.rs | 41 +- .../model/streams/transport_stream_buffer.rs | 20 +- backend/src/api/setup_api.rs | 10 +- backend/src/api/sys_usage.rs | 72 +- backend/src/auth/access_token.rs | 35 +- backend/src/auth/api_user_context.rs | 6 +- backend/src/auth/auth_bearer.rs | 2 +- backend/src/auth/authenticator.rs | 360 ++- backend/src/auth/fingerprint.rs | 6 +- backend/src/auth/mod.rs | 4 +- backend/src/auth/recording_auth.rs | 814 +++++++ backend/src/iptv/m3u/catchup.rs | 6 +- backend/src/library/classifier.rs | 52 +- backend/src/library/metadata_resolver.rs | 2 + backend/src/library/metadata_storage.rs | 14 +- backend/src/library/scanner.rs | 6 + backend/src/main.rs | 2 + backend/src/messaging.rs | 149 +- backend/src/model/config/api_proxy.rs | 117 +- backend/src/model/config/api_user.rs | 170 +- backend/src/model/config/base.rs | 8 + backend/src/model/config/input.rs | 7 + backend/src/model/config/messaging.rs | 13 +- backend/src/model/config/reverse_proxy.rs | 4 + backend/src/model/config/sort.rs | 3 + backend/src/model/config/stream.rs | 8 + backend/src/model/config/video_download.rs | 237 +- backend/src/model/messaging.rs | 25 +- backend/src/model/xmltv.rs | 10 + backend/src/processing/input_cache.rs | 6 +- backend/src/processing/parser/xmltv.rs | 6 +- .../src/processing/processor/deduplicate.rs | 191 ++ backend/src/processing/processor/mod.rs | 1 + backend/src/processing/processor/playlist.rs | 12 + backend/src/processing/processor/sort.rs | 65 +- backend/src/repository/alias_repository.rs | 1 + backend/src/repository/bplustree/migration.rs | 228 +- backend/src/repository/identity_registry.rs | 655 +++++ .../src/repository/m3u_playlist_iterator.rs | 10 + backend/src/repository/mod.rs | 2 + .../src/repository/provider_dns_repository.rs | 73 +- .../repository/recording_rule_repository.rs | 118 + .../stream_history/async_iterator.rs | 4 +- .../src/repository/stream_history/writer.rs | 7 +- backend/src/repository/target_id_mapping.rs | 5 +- backend/src/repository/user_repository.rs | 43 +- .../repository/xtream_playlist_iterator.rs | 64 +- backend/src/repository/xtream_repository.rs | 4 +- backend/src/utils/atomic_json_store.rs | 324 +++ backend/src/utils/file/config_reader.rs | 119 +- backend/src/utils/file/file_utils.rs | 17 +- backend/src/utils/logging.rs | 87 + backend/src/utils/mod.rs | 4 + backend/src/utils/network/epg.rs | 9 +- backend/src/utils/network/ip_checker.rs | 24 +- backend/src/utils/recording_paths.rs | 575 +++++ backend/src/utils/stream_history_viewer.rs | 79 +- bin/dvr_doctor.sh | 208 ++ bin/test.sh | 17 + config/api-proxy.yml | 122 +- config/config.yml | 46 + config/plans.yml | 26 + docs/src/SUMMARY.md | 1 + docs/src/configuration/api-proxy.md | 59 + docs/src/configuration/config.md | 83 +- docs/src/configuration/reverse-proxy.md | 3 + docs/src/configuration/source.md | 81 +- docs/src/operator/dvr.md | 714 ++++++ docs/src/rest-api-cookbook.md | 95 +- frontend/public/assets/i18n/ar.json | 161 +- frontend/public/assets/i18n/en.json | 188 +- frontend/public/assets/i18n/ru.json | 169 +- frontend/public/assets/icons.json | 6 +- frontend/scss/app/_component.scss | 3 + .../scss/app/components/_custom_dialog.scss | 1 - frontend/scss/app/components/_downloads.scss | 13 + frontend/scss/app/components/_input.scss | 15 + frontend/scss/app/components/_recording.scss | 272 +++ frontend/scss/app/components/_table.scss | 7 + .../scss/app/components/_task_status.scss | 65 + .../components/dashboard/_log_console.scss | 275 +++ .../app/components/playlist/_epg_view.scss | 26 + .../components/api_user/target_playlist.rs | 1 + frontend/src/app/components/authentication.rs | 7 +- .../src/app/components/config/config_view.rs | 19 +- .../config/hdhomerun_device_view.rs | 16 +- .../components/config/library_config_view.rs | 28 +- frontend/src/app/components/config/mod.rs | 2 + .../src/app/components/config/plans_view.rs | 508 ++++ .../config/reverse_proxy_config_view.rs | 4 +- .../config/schedules_config_view.rs | 39 +- .../app/components/dashboard/log_console.rs | 268 +++ .../components/dashboard/metrics_history.rs | 4 + frontend/src/app/components/dashboard/mod.rs | 5 +- .../playlist_progress_status_card.rs | 27 +- .../app/components/dashboard/stats_view.rs | 80 +- .../dashboard/stream_display/mod.rs | 24 +- .../dashboard/stream_history_view.rs | 47 +- .../app/components/dashboard/streams_view.rs | 8 +- frontend/src/app/components/datetime_input.rs | 189 ++ frontend/src/app/components/downloads.rs | 54 +- .../app/components/drop_down_icon_button.rs | 33 +- frontend/src/app/components/field_wrapper.rs | 13 +- frontend/src/app/components/home.rs | 46 +- frontend/src/app/components/icon_button.rs | 18 +- frontend/src/app/components/input.rs | 9 +- .../src/app/components/key_value_editor.rs | 14 +- frontend/src/app/components/login.rs | 22 +- frontend/src/app/components/mod.rs | 17 +- .../src/app/components/playlist/epg_view.rs | 193 +- .../app/components/playlist/filter_view.rs | 31 + .../components/playlist/playlist_explorer.rs | 436 ++-- .../playlist/playlist_update_view.rs | 40 +- .../app/components/playlist/target_table.rs | 34 +- frontend/src/app/components/popup_menu.rs | 57 +- frontend/src/app/components/recording/mod.rs | 11 + .../recording/recording_edit_view.rs | 80 + .../components/recording/recording_form.rs | 945 ++++++++ .../recording/recording_library_view.rs | 631 +++++ .../recording/recording_rule_form.rs | 658 +++++ .../recording/recording_rules_view.rs | 648 +++++ .../recording/recording_task_edit_form.rs | 161 ++ frontend/src/app/components/search.rs | 68 +- frontend/src/app/components/select.rs | 18 +- frontend/src/app/components/sidebar.rs | 70 +- .../source_editor/alias_item_form.rs | 3 +- .../components/source_editor/editor_view.rs | 49 +- .../app/components/source_editor/layout.rs | 23 +- frontend/src/app/components/table.rs | 31 +- frontend/src/app/components/tabset.rs | 29 +- .../src/app/components/task_status_badge.rs | 144 ++ frontend/src/app/components/text_button.rs | 6 + frontend/src/app/components/theme.rs | 3 + frontend/src/app/components/theme_picker.rs | 31 +- frontend/src/app/components/toggle_switch.rs | 2 + frontend/src/app/components/userlist/edit.rs | 29 +- frontend/src/app/components/userlist/mod.rs | 4 +- .../userlist/proxy_user_credentials_form.rs | 151 +- .../src/app/components/userlist/user_table.rs | 82 +- frontend/src/hooks/mod.rs | 4 + frontend/src/hooks/use_clipboard_copy.rs | 28 + frontend/src/hooks/use_log_stream.rs | 308 +++ frontend/src/hooks/use_server_status.rs | 1 + frontend/src/model/event_message.rs | 17 +- frontend/src/services/config_service.rs | 27 +- frontend/src/services/mod.rs | 6 +- frontend/src/services/recording_service.rs | 850 +++++++ frontend/src/services/requests.rs | 39 +- frontend/src/services/websocket_service.rs | 17 +- frontend/src/utils/format.rs | 13 + frontend/src/utils/mod.rs | 6 +- shared/src/defaults/hls.rs | 1 + shared/src/defaults/network.rs | 2 + shared/src/error/tuliprox_error.rs | 4 + shared/src/foundation/filter.rs | 375 ++- shared/src/foundation/mod.rs | 3 +- shared/src/foundation/value_provider.rs | 29 +- shared/src/model/auth/permission.rs | 54 +- shared/src/model/auth/user.rs | 67 +- shared/src/model/cluster_flags.rs | 10 +- shared/src/model/config/api_proxy.rs | 116 +- shared/src/model/config/api_user.rs | 21 + shared/src/model/config/hls.rs | 20 +- shared/src/model/config/input.rs | 15 +- shared/src/model/config/library.rs | 5 + .../src/model/config/media_server_catalog.rs | 2 +- shared/src/model/config/mod.rs | 2 + shared/src/model/config/plans.rs | 32 + shared/src/model/config/sort.rs | 4 + shared/src/model/config/source.rs | 45 +- shared/src/model/config/stream.rs | 42 +- shared/src/model/config/target.rs | 34 + shared/src/model/config/video_download.rs | 954 +++++++- shared/src/model/epg.rs | 70 +- shared/src/model/identity_registry.rs | 165 ++ shared/src/model/item_field.rs | 6 + shared/src/model/log.rs | 142 ++ shared/src/model/messaging.rs | 26 + shared/src/model/mod.rs | 20 +- shared/src/model/playlist_request.rs | 105 +- shared/src/model/recording.rs | 536 +++++ shared/src/model/recording_catalog.rs | 140 ++ shared/src/model/recording_rule.rs | 383 +++ shared/src/model/search_fields.rs | 173 ++ shared/src/model/stats.rs | 21 +- shared/src/model/status_check.rs | 3 + shared/src/model/system_info.rs | 6 + shared/src/model/transfer.rs | 11 + shared/src/model/view_type.rs | 16 + shared/src/model/web_socket.rs | 34 +- shared/src/utils/mod.rs | 2 + shared/src/utils/recording_filename.rs | 472 ++++ shared/src/utils/serde_utils.rs | 5 +- shared/src/utils/size_utils.rs | 35 +- shared/src/utils/string_utils.rs | 87 +- shared/src/utils/time_utils.rs | 13 +- 270 files changed, 37731 insertions(+), 2607 deletions(-) create mode 100644 backend/src/api/endpoints/log_ws_api.rs create mode 100644 backend/src/api/endpoints/recording_api.rs create mode 100644 backend/src/api/endpoints/recording_media_api.rs create mode 100644 backend/src/api/endpoints/user_visibility.rs create mode 100644 backend/src/api/model/recording/mod.rs create mode 100644 backend/src/api/model/recording/recording_catalog_access.rs create mode 100644 backend/src/api/model/recording/recording_conflict.rs create mode 100644 backend/src/api/model/recording/recording_currently_airing.rs create mode 100644 backend/src/api/model/recording/recording_deletion.rs create mode 100644 backend/src/api/model/recording/recording_disk.rs create mode 100644 backend/src/api/model/recording/recording_edit.rs create mode 100644 backend/src/api/model/recording/recording_math.rs create mode 100644 backend/src/api/model/recording/recording_notification.rs create mode 100644 backend/src/api/model/recording/recording_notification_adapter.rs create mode 100644 backend/src/api/model/recording/recording_observability.rs create mode 100644 backend/src/api/model/recording/recording_occurrence.rs create mode 100644 backend/src/api/model/recording/recording_quota.rs create mode 100644 backend/src/api/model/recording/recording_reconciliation.rs create mode 100644 backend/src/api/model/recording/recording_retention.rs create mode 100644 backend/src/api/model/recording/recording_rule_scheduler.rs create mode 100644 backend/src/api/model/recording/recording_rule_service.rs create mode 100644 backend/src/api/model/recording/recording_security.rs create mode 100644 backend/src/api/model/recording/recording_service.rs create mode 100644 backend/src/api/model/recording/recording_supervisor/health.rs create mode 100644 backend/src/api/model/recording/recording_supervisor/mod.rs create mode 100644 backend/src/api/model/recording/recording_supervisor/outbox.rs create mode 100644 backend/src/api/model/recording/recording_supervisor/retention.rs create mode 100644 backend/src/api/model/recording/recording_supervisor/startup.rs create mode 100644 backend/src/api/model/recording/recording_worker.rs create mode 100644 backend/src/api/model/recording/recording_worker_runner.rs create mode 100644 backend/src/api/model/recording/recording_ws.rs delete mode 100644 backend/src/api/model/recording_worker.rs create mode 100644 backend/src/auth/recording_auth.rs create mode 100644 backend/src/processing/processor/deduplicate.rs create mode 100644 backend/src/repository/identity_registry.rs create mode 100644 backend/src/repository/recording_rule_repository.rs create mode 100644 backend/src/utils/atomic_json_store.rs create mode 100644 backend/src/utils/recording_paths.rs create mode 100755 bin/dvr_doctor.sh create mode 100755 bin/test.sh create mode 100644 config/plans.yml create mode 100644 docs/src/operator/dvr.md create mode 100644 frontend/scss/app/components/_recording.scss create mode 100644 frontend/scss/app/components/_task_status.scss create mode 100644 frontend/scss/app/components/dashboard/_log_console.scss create mode 100644 frontend/src/app/components/config/plans_view.rs create mode 100644 frontend/src/app/components/dashboard/log_console.rs create mode 100644 frontend/src/app/components/datetime_input.rs create mode 100644 frontend/src/app/components/recording/mod.rs create mode 100644 frontend/src/app/components/recording/recording_edit_view.rs create mode 100644 frontend/src/app/components/recording/recording_form.rs create mode 100644 frontend/src/app/components/recording/recording_library_view.rs create mode 100644 frontend/src/app/components/recording/recording_rule_form.rs create mode 100644 frontend/src/app/components/recording/recording_rules_view.rs create mode 100644 frontend/src/app/components/recording/recording_task_edit_form.rs create mode 100644 frontend/src/app/components/task_status_badge.rs create mode 100644 frontend/src/hooks/use_clipboard_copy.rs create mode 100644 frontend/src/hooks/use_log_stream.rs create mode 100644 frontend/src/services/recording_service.rs create mode 100644 shared/src/model/config/plans.rs create mode 100644 shared/src/model/identity_registry.rs create mode 100644 shared/src/model/log.rs create mode 100644 shared/src/model/recording.rs create mode 100644 shared/src/model/recording_catalog.rs create mode 100644 shared/src/model/recording_rule.rs create mode 100644 shared/src/model/search_fields.rs create mode 100644 shared/src/utils/recording_filename.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3d2dc5a0d..9e0a71eb6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -106,6 +106,12 @@ jobs: - name: Rust Cache uses: Swatinem/rust-cache@v2 + # Non-test code only. `--all-targets` would additionally compile the + # test modules, ~10 of which still use ungated `std::os::unix` + # (alias_repository, xtream_repository, bplustree/v3/publish, + # iptv/stalker/client, api_utils, api/model/download, setup_api). + # Widening this step means gating those first; the recording module + # tree is already `--all-targets`-clean on Windows. - name: Check Windows build run: cargo check --package tuliprox diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6e312eb..a773d221a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,37 @@ ## 🌟 New Features +- **DVR Feature**: a full digital video recorder built around a queue-mutation boundary with a typed `QueueMutationError`, + atomic edit/quota rollback, O(1) edit writes via a remembered `RecordingLocation`, server-side conflict preview + (`POST /api/v1/recording/conflicts/preview`), and a `ConflictSeverity` of `NoKnownConflict` / `PossibleCapacityWait` / + `LikelyMissedWindow`. Three background supervisors start once the HTTP listener is bound, honour the `downloads` + cancellation token, and re-read their config each tick so a reload applies without a restart: + - **Startup reconciliation** finishes or undoes deletions interrupted by a crash (tasks whose + `recording.deleting_previous_state` was set), and repairs queue/rule-store drift. + - **Retention** performs the age, count, and disk-watermark sweeps described in the operator guide + (`tuliprox/docs/src/operator/dvr.md`). + - **Notification outbox** delivers lifecycle notifications durably, retrying **per channel** with capped exponential + backoff and dead-lettering after `max_attempts`. A notification that reached Telegram but not Discord is retried + only against Discord, so retries stay compatible with the at-most-once contract. + - `GET /api/v1/recording/health` (administrator only) reports each supervisor's last-tick timestamp, the outbox + depth, and the dead-letter count. + + Two WebSocket notifications carry the recording subsystem: `RecordingChanged` (any queue mutation) and + `RecordingRulesChanged` (rule-store mutation). The cancel-recording-task endpoint emits both because cancelling + future rule recordings mutates the queue as well as the rule store. + + Authorization is gated by `Claims::is_system_principal`, which now requires both `username == "recording-supervisor"` + *and* `subject_id.is_builtin_admin()` so a web user registered with the sentinel name cannot forge the system bypass; + the supervisor is the only path that mints both. + + Media opens for catalog, range, full-body, thumbnail and subtitle flows go through `no_follow_path_in_root`, which + walks every component from `recording_root` to the leaf with `symlink_metadata`. A symlink at any intermediate path + such as `/users/alice` is rejected before `File::open` follows it, closing the `/users/alice -> /etc` + containment bypass. + + `bin/dvr_doctor.sh` exposes supervisor health, the effective recording config block, the quota ledger and on-disk + state as one read-only dump suitable for a support ticket. + - **Automatic Xtream Account Expiration Refresh**: - Server mode now refreshes missing or soon-expiring Xtream `exp_date` values directly through each account's `player_api.php` credentials, independently of playlist updates and reseller Panel API provisioning. @@ -561,6 +592,56 @@ ## 🐛 Fixes +- **DVR: cancelling a recording could kill a different one.** `cancel_recording` read the active slot, compared the + uuid, then called the no-uuid `cancel_active()`. If ffmpeg finished in between and the queue promoted another + recording, that innocent recording was cancelled instead. Now cancels by uuid. +- **DVR: a disk-pressure sweep deleted the entire recording library.** The stop condition compared a free-space + measurement taken once per pass against the low watermark, ignoring the bytes the pass had already reclaimed, so it + was constant for the whole pass — false on the first candidate and false forever. A single trigger therefore deleted + every completed recording instead of just enough of them. The projected free space now folds in what has been + reclaimed. +- **DVR: the recording module did not build on Windows.** `utils::recording_paths` carried a blanket `#![cfg(unix)]`, + which erased the module and left every caller with unresolved imports. The gate is now scoped to the single + `O_NOFOLLOW` line it was needed for; the no-clobber and no-follow guarantees are carried by `create_new` and + `symlink_metadata`, which behave identically on all supported targets. +- **DVR: `recording.enabled: false` was only half-honoured.** The REST routes refused requests while the rule + scheduler kept materializing tasks and the WebSocket kept streaming recording data. All four gates — routes, + scheduler, supervisors, socket — now share one predicate. +- **DVR: WebSocket delta filtering dropped tasks under load.** The visible-id set was built with `try_lock`/`try_read` + on all four queue guards and silently skipped whichever was contended, so recordings vanished from the client until + the next full snapshot. It now waits for the same committed boundary the snapshot path uses. +- **DVR: filenames were barely sanitized.** Only `/` and `\` were replaced, letting control characters, + Windows-reserved characters, trailing dots/spaces, and BiDi override codepoints reach the path the muxer opens. + Programme titles now pass through a single sanitizer that guarantees one safe path component. +- **DVR: duplicate detection was bypassable.** The key was `(url, start_at, duration_secs)` OR `file_path`. The + `file_path` half was dead (paths are disambiguated with a `_N` suffix, so they never match) and `start_at` is + `now.max(scheduled_start)`, so every request for a currently-airing programme produced a different key and could be + booked repeatedly. Identity is now derived from the rule occurrence, or the programme and source per quota pool. +- **DVR: a failed rule delete could lose upcoming recordings.** `DELETE /rules/{id}?future=cancel` cancelled the + occurrences first; if the rule store then failed, the rule stayed and its recordings were gone. The cancelled + occurrences are now restored from a pre-cancel snapshot. +- **DVR: `recording_mut_at` could edit the wrong task.** The `Finished` arm returned element 0 rather than the located + index. Currently unreachable, but a latent trap for any future caller. +- **DVR: a fatal ffmpeg error could loop until the window closed.** Retryability was decided by substring-matching the + whole stderr line, which includes the source URL, so a provider path containing e.g. `connection-refused` made every + failure look transient. URL-shaped tokens are now stripped before classification. +- **DVR: deletion authorization and the state transition could disagree.** The task was looked up, authorized, + stamped, then looked up a second time, and the second lookup could see a different task. Authorization now runs + inside the same mutation boundary that stamps it. +- **DVR: the `owner=` task filter accepted arbitrary values for non-administrators.** It returned an empty list rather + than refusing, which read as if cross-owner queries were supported. Now `403` unless the caller is an administrator. +- **DVR: an ineligible edit reported a misleading reason.** Clearing rule provenance surfaced as + `recording_invalid_state`; it now has its own `recording_provenance_immutable` code. +- **DVR: task status was rendered as Rust debug output.** The recording library showed `format!("{:?}", status)`, + untranslated and inconsistent with the downloads view. Both now share one localized status pill, and every recording + error code has a translated message in all shipped locales. +- **DVR: the socket could not report an actionable refusal.** A token predating a permission-schema bump produced an + empty task list, indistinguishable from "you have no recordings", while REST correctly answered + `recording_token_refresh_required`. A new `RecordingWsError { code }` frame carries the reason. +- **DVR: `NewEpisode` rules never matched anything.** The scheduler passes an empty EPG horizon to the planner, which + matches those rules by walking programmes, so only `WeeklyTimeslot` rules could materialize. The horizon is still + not wired, but the condition is now logged once per process instead of looking like a scheduler that found nothing. + - **Mapper Regex Capture Results**: - Regex expressions now evaluate one complete match instead of flattening later matches into duplicate, unreachable capture keys. @@ -651,6 +732,22 @@ ## ⚙️ New Settings +- **config.yml (`video.download.recording`)**: + - Added `enabled` (`bool`, default `true`): master switch for the DVR. When `false` the REST routes answer + `501 recording_disabled`, the rule scheduler and supervisors idle, the WebSocket serves no recording data, and the + sidebar entries are hidden. An absent `recording:` block still means "defaults", so upgrading never silently + disables a DVR that was already in use. + - Added `container_format` (`mpegts` | `matroska` | `mp4`, default `mpegts`): the muxer ffmpeg writes. Recordings + were previously hard-coded to MPEG-TS regardless of the source codecs. MPEG-TS remains the default because it + survives truncation — a recording killed mid-stream still plays. + - Added `retention.sweep_interval_secs` (`u64`, default `3600`): cadence of the age/count retention sweep, + independent of `disk.cleanup_interval_secs`, which paces the watermark check. + - Added a `notifications` block governing the new lifecycle-notification outbox: `outbox_buffer` (default `1024`, + fixed at startup), `max_attempts` (default `6`), `backoff_initial_secs` (default `5`), and `backoff_max_secs` + (default `900`). + - Startup now warns when the DVR is enabled with no retention policy, no disk watermarks, and no quota, since + nothing then bounds recording disk usage. + - **source.yml (target `options.epg_output`)**: - Added optional `lowercase_ids` (`bool`, default `false`) to canonicalize technical EPG IDs with ASCII lowercase consistently across visible M3U `tvg-id`, Xtream `epg_channel_id`, XMLTV `` / `` diff --git a/Cargo.lock b/Cargo.lock index e8b5fd3a2..da1866164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,7 +271,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -336,6 +336,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bincode" version = "1.3.3" @@ -415,6 +421,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "brotli" version = "8.0.3" @@ -551,7 +566,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -650,6 +665,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -711,9 +732,9 @@ dependencies = [ [[package]] name = "cron" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97" +checksum = "a5dcd6f69605c2956916ce24e8af637b754964c9a83f4662d3a2361654cdba09" dependencies = [ "chrono", "once_cell", @@ -762,6 +783,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -936,8 +966,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -1169,7 +1210,7 @@ checksum = "431a4c31778fde52b4400de34975f219eeca55cc829a9de157cd743a5b230ecb" name = "frontend" version = "3.3.83" dependencies = [ - "base64", + "base64 0.23.1", "brotli", "bytes", "chrono", @@ -1815,6 +1856,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1871,7 +1921,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2191,7 +2241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ "aws-lc-rs", - "base64", + "base64 0.22.1", "getrandom 0.2.17", "js-sys", "serde", @@ -2313,9 +2363,9 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lz4_flex" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" dependencies = [ "twox-hash", ] @@ -2657,7 +2707,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -2707,7 +2757,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3237,7 +3287,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -3356,7 +3406,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae76b7506744d254fd0eb2c0ff5c5d108201ccbb083111ac04a44eeda105680" dependencies = [ - "base64", + "base64 0.22.1", "blake2b_simd", "constant_time_eq", "crossbeam-utils", @@ -3577,7 +3627,7 @@ checksum = "dcc7fe48e34d02a97bc8e6253b8b91e5a47fe2c47eaacb5149cefbb69922eaf0" dependencies = [ "ahash", "annotate-snippets", - "base64", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -3707,7 +3757,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -3718,14 +3768,25 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "shared" version = "3.3.83" dependencies = [ - "base64", + "base64 0.23.1", "bitflags 2.13.0", "blake3", "brotli", @@ -4175,7 +4236,7 @@ checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http 1.4.0", @@ -4222,7 +4283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "base64", + "base64 0.22.1", "bitflags 2.13.0", "bytes", "futures-core", @@ -4320,7 +4381,7 @@ dependencies = [ "arc-swap", "async-compression", "axum", - "base64", + "base64 0.23.1", "blake3", "brotli", "bytes", @@ -4378,7 +4439,7 @@ dependencies = [ "serde-saphyr", "serde_html_form", "serde_json", - "sha2", + "sha2 0.11.0", "shared", "smallvec", "socket2", diff --git a/Cargo.toml b/Cargo.toml index c05d60f89..2ba55a2cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,10 +14,10 @@ regex = "1.12.3" log = "0.4.32" chrono = "0.4.45" bytes = "1.11.1" -base64 = "0.22.1" +base64 = "0.23.1" blake3 = "1.8.5" fastrand = "2.4.1" -lz4_flex = "0.13.1" +lz4_flex = "0.14.0" brotli = "8.0.3" indexmap = "2.14.0" dashmap = "6.2.1" @@ -29,10 +29,10 @@ zeroize = "1.8.2" deunicode = "1.6.2" path-clean = "1.0.1" url = "2.5.8" -cron = "0.16.0" +cron = "0.17.0" futures = "0.3.32" serde-saphyr = "0.0.26" -sha2 = "0.10.9" +sha2 = "0.11.0" thiserror = "2.0.18" tokio = { version = "1.52.3" } strum = "0.28.0" diff --git a/README.md b/README.md index 2d56be6ee..95d11ecca 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,12 @@ Generate all four formats simultaneously from the same source — one setup, eve - **Dashboard**: System status, active streams, CPU usage, provider connections in real-time via WebSocket - **Source Editor**: Dedicated forms for M3U, Xtream, Stalker, Plex, Emby, Jellyfin and local-library inputs, with drag & drop, block selection and batch mode -- **Playlist Explorer**: Tree and gallery view for channels with EPG timeline and search +- **Playlist Explorer**: Tree and gallery view for channels with EPG timeline and search — text or regex, optionally + scoped to specific fields (group, title, name, url) - **Download & Recording Manager**: Provider-aware VOD downloads and live recordings with retries, fairness, and RBAC-controlled actions - **Config Editor**: Direct editing of config.yml, source.yml, mapping.yml in the browser -- **User Management**: API users with category selection, priority, soft-priority, normal/soft connection limits, auto-generated credentials +- **User Management**: API users with category selection, priority, soft-priority, normal/soft connection limits, auto-generated credentials, + and reusable **plans** (capability tiers with cluster access, connection limits, and admin-enforced content filters) - **Network Access Policy UI**: API-user network restrictions can be configured with CIDR and GeoIP country rules, including the global GeoIP-unavailable `deny`/`allow` policy. - **RBAC Admin Panel**: Tabbed user/group management, permission checkbox grid, write-without-read warnings @@ -341,6 +343,31 @@ Generate all four formats simultaneously from the same source — one setup, eve - **Environment variables**: `${env:VAR}` interpolation in all config files - **Default User-Agent**: Configurable default user-agent for all outgoing requests +### 20. Digital Video Recorder (DVR) — Record, Manage & Keep What Matters + +Turn Tuliprox into your personal IPTV recorder. Record live TV directly from your existing sources, automate recurring recordings, +manage everything from the Web UI, and let Tuliprox handle storage, retries and cleanup in the background. + +- **Live TV Recording**: Record channels directly through Tuliprox without a separate DVR service +- **Scheduled & Recurring Recordings**: Create recording rules for programs you want to capture automatically +- **Smart Conflict Detection**: Preview potential recording conflicts before they happen and identify recordings that may exceed available provider capacity +- **Provider-Aware Scheduling**: Recording jobs respect available provider connections instead of blindly consuming stream slots +- **Reliable Recording Queue**: Queue, pause, resume, retry, edit or cancel recording jobs while Tuliprox keeps the queue consistent +- **Crash-Safe Recovery**: Interrupted operations are automatically reconciled after a restart so the recording library stays consistent +- **Automatic Retention**: Keep storage under control with configurable age, recording-count and disk-space policies +- **Quota Management**: Recording storage can be tracked and limited instead of allowing recordings to consume the entire disk +- **Durable Notifications**: Recording lifecycle events can be delivered through configured notification channels with automatic + retries when individual services are temporarily unavailable +- **Real-Time Web UI Updates**: Recording and recording-rule changes are pushed directly to connected Web UI clients +- **Secure Media Access**: Recorded media, thumbnails and subtitles are served through protected paths with strict filesystem containment +- **Built for 24/7 Operation**: Dedicated background supervisors continuously handle recovery, retention and notifications +- **Hot-Reloadable Configuration**: Change DVR settings without restarting Tuliprox +- **Built-In Health Monitoring**: Inspect DVR subsystem health, background processing and notification queues from the management interface +- **Support-Friendly Diagnostics**: A bundled DVR doctor command collects recording configuration, storage state, quota information and + supervisor health for troubleshooting + +**Your IPTV sources become your own managed recording library — without adding another database, DVR server or separate management stack.** + ## 🎯 Target Audiences ### For IPTV Enthusiasts diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index 0b5a9b427..5b32467e1 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -506,6 +506,13 @@ pub fn get_server_time() -> String { chrono::offset::Local::now().with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M:%S %Z").to_string() } +static PROCESS_START: LazyLock = LazyLock::new(std::time::Instant::now); + +/// Anchors the uptime clock; call once at process startup. +pub fn init_uptime_clock() { let _ = *PROCESS_START; } + +pub fn get_uptime_secs() -> u64 { PROCESS_START.elapsed().as_secs() } + pub fn get_build_time() -> Option { BUILD_TIMESTAMP .to_string() @@ -729,6 +736,7 @@ pub struct StreamOptions { pub stream_retry: bool, pub buffer_enabled: bool, pub buffer_size: usize, + pub buffer_max_bytes: usize, pub pipe_provider_stream: bool, } @@ -774,20 +782,26 @@ pub struct ForceStreamRequestContext<'a> { /// /// Returns a `StreamOptions` instance with the resolved configuration. pub(in crate::api) fn get_stream_options(app_state: &Arc) -> StreamOptions { - let (stream_retry, buffer_enabled, buffer_size) = app_state + let (stream_retry, buffer_enabled, buffer_size, buffer_max_bytes) = app_state .app_config .config .load() .reverse_proxy .as_ref() .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) - .map_or((true, false, 0), |stream| { - let (buffer_enabled, buffer_size) = - stream.buffer.as_ref().map_or((false, 0), |buffer| (buffer.enabled, buffer.size)); - (stream.retry, buffer_enabled, buffer_size) + .map_or((true, false, 0, crate::api::model::MAX_BUFFER_BYTES), |stream| { + let (buffer_enabled, buffer_size, buffer_max_bytes) = stream.buffer.as_ref().map_or( + (false, 0, crate::api::model::MAX_BUFFER_BYTES), + |buffer| { + let max_bytes = usize::try_from(buffer.max_bytes_mb.saturating_mul(1024 * 1024)) + .unwrap_or(crate::api::model::MAX_BUFFER_BYTES); + (buffer.enabled, buffer.size, max_bytes) + }, + ); + (stream.retry, buffer_enabled, buffer_size, buffer_max_bytes) }); let pipe_provider_stream = !stream_retry && !buffer_enabled; - StreamOptions { stream_retry, buffer_enabled, buffer_size, pipe_provider_stream } + StreamOptions { stream_retry, buffer_enabled, buffer_size, buffer_max_bytes, pipe_provider_stream } } /// Metadata capturing which grace strategy was chosen and the original connection kind, @@ -4593,6 +4607,15 @@ pub fn create_api_proxy_user(app_state: &Arc) -> ProxyUserCredentials soft_priority: 0, t_is_api_user: true, network_access: None, + plan: None, + filter: None, + raw_output_clusters: None, + raw_max_connections: 0, + raw_soft_connections: 0, + raw_proxy: Some(ProxyType::Reverse(None)), + t_filter: None, + t_has_unresolved_plan: false, + t_has_invalid_filter: false, } } @@ -6248,6 +6271,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), }); let addr: SocketAddr = "127.0.0.1:55220".parse().unwrap_or_else(|_| unreachable!()); @@ -6334,6 +6358,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), }); let addr: SocketAddr = "127.0.0.1:55221".parse().unwrap_or_else(|_| unreachable!()); @@ -6405,6 +6430,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), }); let addr: SocketAddr = "127.0.0.1:55230".parse().unwrap_or_else(|_| unreachable!()); @@ -6479,6 +6505,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), }); let addr: SocketAddr = "127.0.0.1:55231".parse().unwrap_or_else(|_| unreachable!()); @@ -6553,6 +6580,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::EvictUserSameIpOldest]), }); let addr: SocketAddr = "127.0.0.1:55222".parse().unwrap_or_else(|_| unreachable!()); @@ -6605,6 +6633,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), }); let addr: SocketAddr = "127.0.0.1:55223".parse().unwrap_or_else(|_| unreachable!()); @@ -6649,6 +6678,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), }); let addr: SocketAddr = "127.0.0.1:55224".parse().unwrap_or_else(|_| unreachable!()); @@ -6727,6 +6757,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), }; let mut app_cfg = create_test_app_config(); @@ -6832,6 +6863,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: None, }; let mut app_cfg = create_test_app_config(); @@ -6952,6 +6984,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: None, }); @@ -6975,6 +7008,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![]), }); @@ -6997,6 +7031,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![ AdmissionStrategy::EvictUserSameIpOldest, AdmissionStrategy::GraceHoldStream, @@ -7098,6 +7133,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]), }); @@ -7197,6 +7233,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![ AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserSameIpOldest, @@ -7293,6 +7330,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), }); @@ -7346,6 +7384,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), }); @@ -7406,6 +7445,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(strategies_for_config), }); @@ -7504,6 +7544,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), }); @@ -7562,6 +7603,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::GraceInstantStream]), }); @@ -7656,6 +7698,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream, AdmissionStrategy::EvictUserOldest]), }); @@ -7848,6 +7891,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), }); @@ -7944,6 +7988,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), }); @@ -8068,6 +8113,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), }); @@ -8192,6 +8238,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::EvictUserOldest]), }); @@ -9095,6 +9142,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![ AdmissionStrategy::EvictUserSameIpOldest, AdmissionStrategy::EvictUserSameIpLatest, @@ -9378,6 +9426,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![ AdmissionStrategy::EvictUserSameIpOldest, AdmissionStrategy::EvictUserSameIpLatest, @@ -9803,6 +9852,15 @@ mod tests { soft_priority: 0, t_is_api_user: false, network_access, + plan: None, + filter: None, + raw_output_clusters: None, + raw_max_connections: 0, + raw_soft_connections: 0, + raw_proxy: Some(ProxyType::default()), + t_filter: None, + t_has_unresolved_plan: false, + t_has_invalid_filter: false, } } diff --git a/backend/src/api/endpoints/custom_video_stream_api.rs b/backend/src/api/endpoints/custom_video_stream_api.rs index 51e15a8b8..a7630894e 100644 --- a/backend/src/api/endpoints/custom_video_stream_api.rs +++ b/backend/src/api/endpoints/custom_video_stream_api.rs @@ -219,7 +219,9 @@ async fn cvs_api_response(context: CvsApiResponseContext<'_>) -> Response { if route_kind == CvsRouteKind::Ts { let api_proxy_user = create_api_proxy_user(app_state); - if username == api_proxy_user.username && password == api_proxy_user.password { + if username == api_proxy_user.username + && crate::auth::constant_time_eq(password.as_bytes(), api_proxy_user.password.as_bytes()) + { let token = raw_query.and_then(|query| { form_urlencoded::parse(query.as_bytes()) .find_map(|(key, value)| (key == "token").then(|| value.into_owned())) diff --git a/backend/src/api/endpoints/download_api.rs b/backend/src/api/endpoints/download_api.rs index 81f5e3daa..61846a03d 100644 --- a/backend/src/api/endpoints/download_api.rs +++ b/backend/src/api/endpoints/download_api.rs @@ -1,19 +1,27 @@ use crate::{ + api::endpoints::v1_api_playlist, api::model::{ - AppState, ActiveProviderManager, ConnectionManager, DownloadControl, DownloadKind, DownloadQueue, - DownloadState, DownloadWaitOutcome, EventManager, EventMessage, FileDownload, FileDownloadRequest, - FileRecordingRequest, - RecordingExecutionResult, run_recording, + mutate, mutate_optional, AppState, ActiveProviderManager, ConnectionManager, DownloadControl, DownloadKind, + DownloadQueue, DownloadState, DownloadWaitOutcome, EventManager, EventMessage, FileDownload, + FileDownloadRequest, FileRecordingRequest, PersistedFileDownload, QueueMutationError, + RecordingExecutionResult, recording_partial_path, run_recording, }, - model::{AppConfig, VideoDownloadConfig}, + messaging::send_message, + model::{AppConfig, MessageContent, VideoDownloadConfig}, utils::{async_file_writer, request, request::create_client, IO_BUFFER_SIZE}, }; +use crate::api::model::recording_notification::LifecycleEvent; +use crate::api::model::recording_notification_adapter::{DispatchDecision, build_marker, decide, message_for}; use axum::response::IntoResponse; use futures::stream::TryStreamExt; use log::{debug, error, info, warn}; use serde::Deserialize; use serde_json::json; -use shared::{error::to_io_error, model::{DownloadsDelta, DownloadsResponse}, utils::bytes_to_megabytes}; +use shared::{ + error::to_io_error, + model::{DownloadsDelta, DownloadsResponse, RecordingMetadata}, + utils::bytes_to_megabytes, +}; use std::{collections::HashMap, ops::Deref, pin::Pin, sync::Arc}; use tokio::{ fs, @@ -51,6 +59,29 @@ enum ProviderAcquireResult { Preempted, } +fn recording_execution_download(app_config: &AppConfig, download: &FileDownload) -> Result { + let source = download + .recording + .as_ref() + .and_then(|metadata| metadata.source.as_ref()) + .ok_or_else(|| "Recording source metadata missing".to_string())?; + let virtual_id = source + .virtual_id + .parse::() + .map_err(|_| "Recording source virtual id invalid".to_string())?; + let url = v1_api_playlist::build_stable_recording_url( + app_config, + &source.target_id, + &source.input_name, + virtual_id, + source.cluster, + ) + .ok_or_else(|| "Recording execution URL unavailable".to_string())?; + let mut execution_download = download.clone(); + execution_download.url = reqwest::Url::parse(&url).map_err(|_| "Recording execution URL invalid".to_string())?; + Ok(execution_download) +} + fn classify_download_open_error(url: &reqwest::Url, err: &reqwest::Error) -> DownloadExecutionResult { if is_retryable_download_error(err) { DownloadExecutionResult::Retryable(format!("Error while opening url: {url} {err}")) @@ -169,40 +200,41 @@ fn recording_deadline_reached(download: &FileDownload, now_ts: i64) -> bool { .is_some_and(|(start_at, duration_secs)| now_ts >= start_at.saturating_add(i64::try_from(duration_secs).unwrap_or(90))) } +#[cfg(test)] async fn active_download_snapshot(active: &RwLock>) -> Option { active.read().await.clone() } -pub async fn download_queue_snapshot(download_queue: &DownloadQueue) -> DownloadsResponse { - let queue: Vec = download_queue - .queue - .lock() - .await - .iter() - .map(shared::model::FileDownloadDto::from) - .collect(); - let mut queue = queue; - queue.extend( - download_queue - .scheduled - .read() - .await - .iter() - .map(shared::model::FileDownloadDto::from), - ); - let finished = download_queue - .finished - .read() - .await - .iter() - .map(shared::model::FileDownloadDto::from) - .collect(); - let active = download_queue - .active +async fn active_download_snapshot_for_worker( + active: &RwLock>, + worker_uuid: &str, +) -> Option { + active .read() .await .as_ref() - .map(shared::model::FileDownloadDto::from) - .into_iter() - .collect(); + .filter(|download| download.uuid == worker_uuid) + .cloned() +} + +async fn update_active_download_for_worker( + active: &RwLock>, + worker_uuid: &str, + update: F, +) -> bool +where + F: FnOnce(&mut FileDownload) -> bool, +{ + let mut active = active.write().await; + let Some(download) = active.as_mut().filter(|download| download.uuid == worker_uuid) else { + return false; + }; + update(download) +} + +pub async fn download_queue_snapshot(download_queue: &DownloadQueue) -> DownloadsResponse { + let (queue, active, finished) = download_queue.committed_download_snapshot().await; + let queue = queue.iter().map(shared::model::FileDownloadDto::from).collect(); + let finished = finished.iter().map(shared::model::FileDownloadDto::from).collect(); + let active = active.as_ref().map(shared::model::FileDownloadDto::from).into_iter().collect(); DownloadsResponse { queue, @@ -215,39 +247,50 @@ async fn broadcast_download_queue_update(event_manager: &Arc, down if !event_manager.has_event_receivers() { return; } - let mut queue = download_queue - .queue - .lock() - .await - .iter() - .map(shared::model::FileDownloadDto::from) - .collect::>(); - queue.extend( - download_queue - .scheduled - .read() - .await - .iter() - .map(shared::model::FileDownloadDto::from), - ); - let finished = download_queue - .finished - .read() - .await - .iter() - .map(shared::model::FileDownloadDto::from) - .collect::>(); + let (queue, active, finished) = download_queue.committed_download_snapshot().await; + let queue = queue.iter().map(shared::model::FileDownloadDto::from).collect(); + let finished = finished.iter().map(shared::model::FileDownloadDto::from).collect(); let _ = event_manager.send_event(EventMessage::DownloadsDeltaUpdate(DownloadsDelta::QueueReplaced { queue })); let _ = event_manager.send_event(EventMessage::DownloadsDeltaUpdate(DownloadsDelta::FinishedReplaced { finished, })); - if let Some(download) = download_queue.active.read().await.as_ref() { + if let Some(download) = active.as_ref() { let _ = event_manager.send_event(EventMessage::DownloadsDeltaUpdate(DownloadsDelta::ActivePatched( shared::model::FileDownloadDto::from(download), ))); } else { let _ = event_manager.send_event(EventMessage::DownloadsDeltaUpdate(DownloadsDelta::ActiveCleared)); } + let _ = event_manager.send_event(EventMessage::RecordingChanged); +} + +async fn broadcast_worker_mutation( + event_manager: &Arc, + download_queue: &DownloadQueue, + result: Result, + action: &str, +) -> Result { + match result { + Ok(true) => { + broadcast_download_queue_update(event_manager, download_queue).await; + Ok(true) + } + Ok(false) => Ok(false), + Err(err) => Err(QueueMutationError::new(format!("{action}: {err}"))), + } +} + +async fn broadcast_required_worker_mutation( + event_manager: &Arc, + download_queue: &DownloadQueue, + result: Result, + action: &str, +) -> Result<(), QueueMutationError> { + if broadcast_worker_mutation(event_manager, download_queue, result, action).await? { + Ok(()) + } else { + Err(QueueMutationError::new(format!("{action}: active task changed"))) + } } async fn broadcast_active_download_delta(event_manager: &Arc, active: &RwLock>) { @@ -263,23 +306,20 @@ async fn broadcast_active_download_delta(event_manager: &Arc, acti async fn refresh_recording_progress( active: &RwLock>, + worker_uuid: &str, file_path: &std::path::Path, event_manager: &Arc, ) { let current_size = tokio::fs::metadata(file_path).await.map_or(0, |metadata| metadata.len()); - let changed = { - let mut active = active.write().await; - if let Some(download) = active.as_mut() { - if download.kind == DownloadKind::Recording && download.size != current_size { - download.size = current_size; - true - } else { - false - } + let changed = update_active_download_for_worker(active, worker_uuid, |download| { + if download.kind == DownloadKind::Recording && download.size != current_size { + download.size = current_size; + true } else { false } - }; + }) + .await; if changed { broadcast_active_download_delta(event_manager, active).await; } @@ -288,16 +328,16 @@ async fn refresh_recording_progress( #[allow(clippy::too_many_lines)] async fn download_file( active: Arc>>, + file_download: FileDownload, client: &reqwest::Client, control_signal: Arc>, control_notify: Arc, provider_cancel_token: Option, event_manager: Option<&Arc>, - download_queue: Option<&Arc>, ) -> DownloadExecutionResult { - if let Some(file_download) = active_download_snapshot(&active).await { - let url = file_download.url.clone(); - let file_path = file_download.file_path.clone(); + let worker_uuid = file_download.uuid.as_str(); + let url = file_download.url.clone(); + let file_path = file_download.file_path.clone(); // Check for existing partial file for resume let existing_size = tokio::fs::metadata(&file_path).await.map_or(0, |metadata| metadata.len()); @@ -306,9 +346,7 @@ async fn download_file( request_builder = request_builder.header("Range", format!("bytes={existing_size}-")); } - if let Some(result) = - handle_download_control_without_writer(&active, current_download_control(&control_signal)).await - { + if let Some(result) = handle_download_control_without_writer(current_download_control(&control_signal)) { return result; } @@ -320,10 +358,9 @@ async fn download_file( biased; () = cancel_token.cancelled() => return DownloadExecutionResult::Preempted, () = control_notify.notified() => { - if let Some(result) = handle_download_control_without_writer( - &active, - *control_signal.read().await, - ).await { + if let Some(result) = + handle_download_control_without_writer(*control_signal.read().await) + { return result; } } @@ -333,10 +370,9 @@ async fn download_file( tokio::select! { biased; () = control_notify.notified() => { - if let Some(result) = handle_download_control_without_writer( - &active, - *control_signal.read().await, - ).await { + if let Some(result) = + handle_download_control_without_writer(*control_signal.read().await) + { return result; } } @@ -361,11 +397,15 @@ async fn download_file( let total_size = compute_download_total_size(&response, existing_size); if let Some(total) = total_size { - if let Some(download) = active.write().await.as_mut() { + let changed = update_active_download_for_worker(&active, worker_uuid, |download| { download.total_size = Some(total); - } - if let Some(event_manager) = event_manager { - broadcast_active_download_delta(event_manager, &active).await; + true + }) + .await; + if changed { + if let Some(event_manager) = event_manager { + broadcast_active_download_delta(event_manager, &active).await; + } } } @@ -401,12 +441,6 @@ async fn download_file( loop { if deadline_at.is_some_and(|deadline| Instant::now() >= deadline) { - if let Some(lock) = active.write().await.as_mut() { - lock.paused = false; - lock.finished = true; - lock.state = DownloadState::Completed; - lock.error = None; - } if let Err(err) = buf_writer.flush().await { return DownloadExecutionResult::Failed(err.to_string()); } @@ -568,10 +602,7 @@ async fn download_file( } last_progress_log_at = now; last_progress_logged_bytes = downloaded; - if let (Some(event_manager), Some(download_queue)) = - (event_manager, download_queue) - { - let _ = download_queue; + if let Some(event_manager) = event_manager { broadcast_active_download_delta(event_manager, &active).await; } } @@ -583,10 +614,7 @@ async fn download_file( ); last_progress_log_at = Instant::now(); last_progress_logged_bytes = downloaded; - if let (Some(event_manager), Some(download_queue)) = - (event_manager, download_queue) - { - let _ = download_queue; + if let Some(event_manager) = event_manager { broadcast_active_download_delta(event_manager, &active).await; } } @@ -596,9 +624,15 @@ async fn download_file( || Instant::now().duration_since(last_snapshot_update_at) >= DOWNLOAD_SNAPSHOT_UPDATE_INTERVAL; if should_update_snapshot { - if let Some(lock) = active.write().await.as_mut() { - lock.size = downloaded; - } + update_active_download_for_worker( + &active, + worker_uuid, + |download| { + download.size = downloaded; + true + }, + ) + .await; last_snapshot_update_at = Instant::now(); last_snapshot_update_bytes = downloaded; } @@ -612,12 +646,15 @@ async fn download_file( } else { let megabytes = bytes_to_megabytes(downloaded); info!("Downloaded {file_path_str}, filesize: {megabytes}MB"); - if let Some(lock) = active.write().await.as_mut() { - lock.paused = false; - lock.size = downloaded; - lock.finished = true; - lock.state = DownloadState::Completed; - } + update_active_download_for_worker( + &active, + worker_uuid, + |download| { + download.size = downloaded; + true + }, + ) + .await; if let Err(err) = buf_writer.flush().await { return DownloadExecutionResult::Failed(err.to_string()); } @@ -646,9 +683,6 @@ async fn download_file( } Err(err) => classify_download_open_error(&url, &err), } - } else { - DownloadExecutionResult::Failed("No active file download".to_string()) - } } fn current_download_control(control_signal: &RwLock) -> DownloadControl { @@ -658,7 +692,7 @@ fn current_download_control(control_signal: &RwLock) -> Downloa fn should_exit_worker_after_preempt(control: DownloadControl) -> bool { control == DownloadControl::Restart } async fn handle_download_control( - active: &Arc>>, + _active: &Arc>>, control: DownloadControl, buf_writer: &mut W, ) -> Option @@ -667,10 +701,6 @@ where { match control { DownloadControl::Pause => { - if let Some(download) = active.write().await.as_mut() { - download.paused = true; - download.state = DownloadState::Paused; - } if let Err(err) = buf_writer.flush().await { return Some(DownloadExecutionResult::Failed(err.to_string())); } @@ -680,14 +710,6 @@ where Some(DownloadExecutionResult::Paused) } DownloadControl::Cancel => { - if let Some(download) = active.write().await.as_mut() { - download.finished = true; - download.paused = false; - download.state = DownloadState::Cancelled; - if download.error.is_none() { - download.error = Some("Cancelled by user".to_string()); - } - } if let Err(err) = buf_writer.flush().await { return Some(DownloadExecutionResult::Failed(err.to_string())); } @@ -709,29 +731,10 @@ where } } -async fn handle_download_control_without_writer( - active: &Arc>>, - control: DownloadControl, -) -> Option { +fn handle_download_control_without_writer(control: DownloadControl) -> Option { match control { - DownloadControl::Pause => { - if let Some(download) = active.write().await.as_mut() { - download.paused = true; - download.state = DownloadState::Paused; - } - Some(DownloadExecutionResult::Paused) - } - DownloadControl::Cancel => { - if let Some(download) = active.write().await.as_mut() { - download.finished = true; - download.paused = false; - download.state = DownloadState::Cancelled; - if download.error.is_none() { - download.error = Some("Cancelled by user".to_string()); - } - } - Some(DownloadExecutionResult::Cancelled) - } + DownloadControl::Pause => Some(DownloadExecutionResult::Paused), + DownloadControl::Cancel => Some(DownloadExecutionResult::Cancelled), DownloadControl::Restart => Some(DownloadExecutionResult::Preempted), DownloadControl::None => None, } @@ -753,44 +756,338 @@ fn recording_deadline_instant(download: &FileDownload) -> Option { async fn set_active_download_state( download_queue: &DownloadQueue, + uuid: &str, state: DownloadState, error: Option, paused: bool, -) -> bool { - let mut active = download_queue.active.write().await; - if let Some(download) = active.as_mut() { +) -> Result { + Ok(mutate_optional(download_queue, |candidate| { + let Some(download) = candidate.active.as_mut().filter(|active| active.uuid == uuid) else { + return Ok(None); + }; + if download.state == state && download.error == error && download.paused == paused && !download.finished { + return Ok(None); + } download.state = state; download.error = error; download.paused = paused; download.finished = false; - true - } else { - false + Ok(Some(true)) + }) + .await? + .unwrap_or(false)) +} + +async fn commit_acquired_download( + download_queue: &DownloadQueue, + uuid: &str, +) -> Result, QueueMutationError> { + mutate_optional(download_queue, |candidate| { + let Some(active) = candidate.active.as_mut().filter(|active| active.uuid == uuid) else { + return Ok(None); + }; + active.state = DownloadState::Downloading; + active.error = None; + active.paused = false; + active.finished = false; + let notification = mark_recording_metadata_notification( + active.recording.as_mut(), + LifecycleEvent::Started, + None, + ); + Ok(Some(notification)) + }) + .await +} + +#[cfg(test)] +fn mark_recording_notification( + download: &mut FileDownload, + event: LifecycleEvent, + failure_reason: Option, +) -> RecordingNotificationPlan { + mark_recording_metadata_notification(download.recording.as_mut(), event, failure_reason) +} + +fn mark_recording_metadata_notification( + recording: Option<&mut RecordingMetadata>, + event: LifecycleEvent, + failure_reason: Option, +) -> RecordingNotificationPlan { + let Some(meta) = recording else { + return RecordingNotificationPlan::empty(); + }; + let is_admin_owner = match &meta.owner { + shared::model::recording::RecordingOwner::LegacyAdmin => true, + shared::model::recording::RecordingOwner::User(owner) => owner.is_builtin_admin(), + }; + match decide(meta, event, chrono::Utc::now().timestamp(), is_admin_owner, failure_reason) { + DispatchDecision::PersistAndDeliver { payload, kind, attempted_at } => { + meta.notification_markers.push(build_marker(kind.clone(), attempted_at)); + RecordingNotificationPlan { + message: Some(MessageContent::RecordingLifecycle(message_for(event, &payload))), + #[cfg(test)] + marker_kind: Some(kind), + } + } + DispatchDecision::AlreadyDelivered { .. } | DispatchDecision::Suppressed { .. } => RecordingNotificationPlan::empty(), } } -async fn requeue_active_download_for_retry(download_queue: &DownloadQueue) { - if let Some(mut download) = download_queue.active.write().await.take() { +/// Result of `mark_recording_notification`. `marker_kind` is set when a +/// marker was added to the in-memory recording; the caller uses it to roll +/// the marker back if the subsequent persist fails. +struct RecordingNotificationPlan { + message: Option, + #[cfg(test)] + marker_kind: Option, +} + +impl RecordingNotificationPlan { + fn empty() -> Self { + Self { + message: None, + #[cfg(test)] + marker_kind: None, + } + } +} + +#[cfg(test)] +fn rollback_last_recording_marker(fd: &mut FileDownload, kind: &shared::model::recording::NotificationMarkerKind) { + if let Some(meta) = fd.recording.as_mut() { + if let Some(idx) = meta.notification_markers.iter().rposition(|marker| marker.kind == *kind) { + meta.notification_markers.remove(idx); + } + } +} + +/// Hand a lifecycle notification off for delivery once its marker has +/// been persisted. +/// +/// The marker is written inside the queue-mutation boundary, so it is +/// durable before this runs; delivery must be durable too. The +/// notification outbox owns that: it persists the entry, retries per +/// channel with backoff, and dead-letters what it cannot deliver. This +/// used to `tokio::spawn(send_message(..))` directly, which meant a +/// transient Telegram/Discord/REST error silently dropped the +/// notification and a crash before the spawned task ran lost it too. +/// +/// The direct send is kept as a fallback for the paths that run before +/// the supervisor is installed (notably unit tests), so behaviour there +/// is unchanged. +fn spawn_recording_notification_after_persist( + app_config: &Arc, + client: &reqwest::Client, + plan: RecordingNotificationPlan, + persisted: bool, +) { + if !persisted { + return; + } + let Some(message) = plan.message else { + return; + }; + // A full or closed outbox hands the message back; fall through to the + // direct send rather than dropping it outright. + let message = match crate::api::model::recording::recording_supervisor::notification_outbox() { + Some(outbox) => match outbox.enqueue(message) { + None => return, + Some(rejected) => rejected, + }, + None => message, + }; + let app_config = Arc::clone(app_config); + let client = client.clone(); + tokio::spawn(async move { + send_message(&app_config, &client, message).await; + }); +} + +async fn requeue_active_download_for_retry( + download_queue: &DownloadQueue, + uuid: &str, + promote: bool, +) -> Result { + Ok(mutate_optional(download_queue, |candidate| { + let Some(mut download) = candidate.active.take() else { + return Ok(None); + }; + if download.uuid != uuid { + candidate.active = Some(download); + return Ok(None); + } download.finished = false; download.paused = false; download.error = None; download.state = DownloadState::Queued; download.next_retry_at = None; - download_queue.queue.lock().await.push_front(download); - } - let _ = download_queue.persist_to_disk().await; + candidate.queue.insert(0, download); + if promote { + candidate.active = Some(candidate.queue.remove(0)); + } + Ok(Some(true)) + }) + .await? + .unwrap_or(false)) } -async fn requeue_active_download_for_capacity_wait(download_queue: &DownloadQueue, reason: &str) { - if let Some(mut download) = download_queue.active.write().await.take() { +async fn requeue_active_download_for_capacity_wait( + download_queue: &DownloadQueue, + uuid: &str, + reason: &str, + promote: bool, + consumed_control: Option, +) -> Result { + let mutation = |candidate: &mut crate::api::model::PersistedDownloadQueue| { + let Some(mut download) = candidate.active.take() else { + return Ok(None); + }; + if download.uuid != uuid { + candidate.active = Some(download); + return Ok(None); + } download.finished = false; download.paused = false; download.error = Some(reason.to_string()); download.state = DownloadState::WaitingForCapacity; download.next_retry_at = None; - download_queue.queue.lock().await.push_front(download); - } - let _ = download_queue.persist_to_disk().await; + candidate.queue.insert(0, download); + if promote { + candidate.active = Some(candidate.queue.remove(0)); + } + Ok(Some(true)) + }; + let result = if let Some(control) = consumed_control { + download_queue + .mutate_optional_and_clear_control(control, mutation) + .await? + } else { + mutate_optional(download_queue, mutation).await? + }; + Ok(result.unwrap_or(false)) +} + +async fn promote_next_download(download_queue: &DownloadQueue) -> Result, QueueMutationError> { + mutate_optional(download_queue, |candidate| { + if candidate.active.is_some() || candidate.queue.is_empty() { + return Ok(None); + } + let next = candidate.queue.remove(0); + let promoted = (next.uuid.clone(), next.filename.clone()); + candidate.active = Some(next); + Ok(Some(promoted)) + }) + .await +} + +async fn finish_active_and_promote( + download_queue: &DownloadQueue, + uuid: &str, + finish: F, +) -> Result, QueueMutationError> +where + F: FnOnce(&mut PersistedFileDownload) -> RecordingNotificationPlan, +{ + mutate_optional(download_queue, |candidate| { + let Some(mut active) = candidate.active.take() else { + return Ok(None); + }; + if active.uuid != uuid { + candidate.active = Some(active); + return Ok(None); + } + let notification = finish(&mut active); + candidate.finished.push(active); + if !candidate.queue.is_empty() { + candidate.active = Some(candidate.queue.remove(0)); + } + Ok(Some(notification)) + }) + .await +} + +async fn cancel_active_and_promote( + download_queue: &DownloadQueue, + uuid: &str, +) -> Result { + Ok(download_queue + .mutate_optional_and_clear_control(DownloadControl::Cancel, |candidate| { + let Some(mut active) = candidate.active.take() else { + return Ok(None); + }; + if active.uuid != uuid { + candidate.active = Some(active); + return Ok(None); + } + active.finished = true; + active.paused = false; + active.next_retry_at = None; + active.error.get_or_insert_with(|| "Cancelled by user".to_string()); + active.state = DownloadState::Cancelled; + candidate.finished.push(active); + if !candidate.queue.is_empty() { + candidate.active = Some(candidate.queue.remove(0)); + } + Ok(Some(true)) + }) + .await? + .unwrap_or(false)) +} + +enum RetryCommit { + Waiting { delay_secs: u64, attempts: u8 }, + Failed(RecordingNotificationPlan), +} + +async fn prepare_active_retry( + download_queue: &DownloadQueue, + uuid: &str, + download_cfg: &VideoDownloadConfig, +) -> Result, QueueMutationError> { + mutate_optional(download_queue, |candidate| { + let Some(active) = candidate.active.as_mut().filter(|active| active.uuid == uuid) else { + return Ok(None); + }; + active.retry_attempts = active.retry_attempts.saturating_add(1); + let attempts = active.retry_attempts; + if attempts > download_cfg.retry_max_attempts { + let Some(mut failed) = candidate.active.take() else { + return Ok(None); + }; + let error = format!("Retry limit reached after {} attempts", download_cfg.retry_max_attempts); + failed.finished = true; + failed.paused = false; + failed.next_retry_at = None; + failed.state = DownloadState::Failed; + failed.error = Some(error.clone()); + let notification = mark_recording_metadata_notification( + failed.recording.as_mut(), + LifecycleEvent::Failed, + Some(error), + ); + candidate.finished.push(failed); + if !candidate.queue.is_empty() { + candidate.active = Some(candidate.queue.remove(0)); + } + return Ok(Some(RetryCommit::Failed(notification))); + } + + let delay_secs = compute_download_retry_backoff_secs(attempts, download_cfg); + let next_retry_at = chrono::Utc::now() + .timestamp() + .saturating_add(i64::try_from(delay_secs).unwrap_or(i64::MAX)); + active.next_retry_at = Some(next_retry_at); + active.state = DownloadState::RetryWaiting; + active.paused = false; + active.finished = false; + active.error = Some(format!( + "Retrying after transient failure in {delay_secs}s (attempt {attempts}/{})", + download_cfg.retry_max_attempts + )); + Ok(Some(RetryCommit::Waiting { delay_secs, attempts })) + }) + .await } const DOWNLOAD_PREEMPTED_REASON: &str = "Preempted by higher-priority foreground stream"; @@ -821,16 +1118,16 @@ pub(in crate::api) async fn ensure_download_worker_running( *worker_running = true; drop(worker_running); - if download_queue.active.read().await.is_none() { - let next_download = download_queue.as_ref().queue.lock().await.pop_front(); - if let Some(next_download) = next_download { - debug!( - "Promoting queued download {} ({}) to active", - next_download.uuid, next_download.filename - ); - *download_queue.as_ref().active.write().await = Some(next_download); + match promote_next_download(download_queue).await { + Ok(Some((uuid, filename))) => { + debug!("Promoting queued download {uuid} ({filename}) to active"); broadcast_download_queue_update(event_manager, download_queue).await; } + Ok(None) => {} + Err(err) => { + *download_queue.worker_running.write().await = false; + return Err(err.to_string()); + } } if download_queue.active.read().await.is_some() { @@ -849,6 +1146,7 @@ pub(in crate::api) async fn ensure_download_worker_running( let active_provider = Arc::clone(active_provider); let connection_manager = Arc::clone(connection_manager); let download_cfg = download_cfg.clone(); + let app_config = Arc::new(cfg.clone()); if let Ok(client) = create_client(cfg).default_headers(headers).build() { if let Some(active) = dq.active.read().await.as_ref() { @@ -858,13 +1156,16 @@ pub(in crate::api) async fn ensure_download_worker_running( ); } tokio::spawn(async move { - loop { + 'worker: loop { if dq.active.read().await.deref().is_some() { if let Some(download) = dq.active.read().await.as_ref() { if download.paused { break; } } + let Some(worker_uuid) = dq.active.read().await.as_ref().map(|download| download.uuid.clone()) else { + break; + }; // Acquire a provider connection slot for this download. // If the provider is at capacity, wait in the priority queue until signalled. @@ -878,9 +1179,23 @@ pub(in crate::api) async fn ensure_download_worker_running( loop { let capacities = active_provider.provider_capacities_for_input(&input_name).await; if background_download_should_wait(priority, &capacities, &download_cfg) { - if set_active_download_state(&dq, DownloadState::WaitingForCapacity, None, false).await { - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; + if let Err(err) = broadcast_worker_mutation( + &event_manager, + &dq, + set_active_download_state( + &dq, + &worker_uuid, + DownloadState::WaitingForCapacity, + None, + false, + ) + .await, + "waiting-for-capacity state", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; } match dq .slot_waiters @@ -900,9 +1215,6 @@ pub(in crate::api) async fn ensure_download_worker_running( continue; } if let Some(handle) = active_provider.acquire_connection_for_download(&input_name, priority).await { - let _ = set_active_download_state(&dq, DownloadState::Downloading, None, false).await; - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; break ProviderAcquireResult::Acquired(Some(handle)); } if *control_signal.read().await == DownloadControl::Cancel { @@ -914,9 +1226,23 @@ pub(in crate::api) async fn ensure_download_worker_running( if *control_signal.read().await == DownloadControl::Restart { break ProviderAcquireResult::Preempted; } - if set_active_download_state(&dq, DownloadState::WaitingForCapacity, None, false).await { - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; + if let Err(err) = broadcast_worker_mutation( + &event_manager, + &dq, + set_active_download_state( + &dq, + &worker_uuid, + DownloadState::WaitingForCapacity, + None, + false, + ) + .await, + "waiting-for-capacity state", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; } // Wait for highest-priority signal — no sleep, no polling. match dq @@ -942,60 +1268,109 @@ pub(in crate::api) async fn ensure_download_worker_running( let provider_handle = match provider_acquire_result { ProviderAcquireResult::Acquired(handle) => { - *control_signal.write().await = DownloadControl::None; + match commit_acquired_download(&dq, &worker_uuid).await { + Ok(Some(notification)) => { + spawn_recording_notification_after_persist( + &app_config, + &client, + notification, + true, + ); + broadcast_download_queue_update(&event_manager, &dq).await; + } + Ok(None) => { + connection_manager.release_provider_handle(handle).await; + error!("Download worker active task changed after provider acquire"); + break 'worker; + } + Err(err) => { + connection_manager.release_provider_handle(handle).await; + error!("Download worker commit failed after provider acquire: {err}"); + break 'worker; + } + } handle } ProviderAcquireResult::Paused => { - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; break; } ProviderAcquireResult::Cancelled => { - if let Some(fd) = dq.active.write().await.take() { - let mut fd = fd; - fd.next_retry_at = None; - dq.finished.write().await.push(fd); + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + cancel_active_and_promote(&dq, &worker_uuid).await, + "provider-wait cancellation", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; } - *dq.control_signal.write().await = DownloadControl::None; - let _ = dq.persist_to_disk().await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; continue; } ProviderAcquireResult::Preempted => { - *control_signal.write().await = DownloadControl::None; - requeue_active_download_for_capacity_wait(&dq, "Reloading download service configuration").await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + requeue_active_download_for_capacity_wait( + &dq, + &worker_uuid, + "Reloading download service configuration", + true, + Some(DownloadControl::Restart), + ) + .await, + "configuration-reload requeue", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; + } break; } }; let execution_result = { - let active = dq.active.read().await; - let Some(download) = active.as_ref().cloned() else { - break; + let Some(download) = active_download_snapshot_for_worker(&dq.active, &worker_uuid).await + else { + connection_manager.release_provider_handle(provider_handle).await; + break 'worker; }; - drop(active); - match download.kind { + match download.kind.clone() { DownloadKind::Download => download_file( Arc::clone(&dq.active), + download, &client, Arc::clone(&control_signal), Arc::clone(&control_notify), provider_handle.as_ref().and_then(|handle| handle.cancel_token.clone()), Some(&event_manager), - Some(&dq), ) .await, - DownloadKind::Recording => { + DownloadKind::Recording => 'recording_execution: { + let execution_download = match recording_execution_download(&app_config, &download) { + Ok(execution_download) => execution_download, + Err(err) => break 'recording_execution DownloadExecutionResult::Failed(err), + }; + let progress_path = recording_partial_path(&execution_download.file_path); + let container_format = app_config + .config + .load() + .video + .as_ref() + .and_then(|video| video.download.as_ref()) + .and_then(|dl| dl.recording.as_ref()) + .map_or_else( + shared::model::RecordingContainerFormat::default, + |recording| recording.container_format, + ); let mut recording_future = Box::pin(run_recording( - &download, + &execution_download, &control_signal, &control_notify, provider_handle.as_ref().and_then(|handle| handle.cancel_token.as_ref()), + container_format, )); let mut progress_tick = time::interval(RECORDING_PROGRESS_UPDATE_INTERVAL); progress_tick.set_missed_tick_behavior(time::MissedTickBehavior::Skip); @@ -1005,13 +1380,25 @@ pub(in crate::api) async fn ensure_download_worker_running( recording_result = &mut recording_future => break recording_result, _ = progress_tick.tick() => { if event_manager.has_event_receivers() { - refresh_recording_progress(&dq.active, &download.file_path, &event_manager).await; + refresh_recording_progress( + &dq.active, + &worker_uuid, + &progress_path, + &event_manager, + ) + .await; } } } }; if event_manager.has_event_receivers() { - refresh_recording_progress(&dq.active, &download.file_path, &event_manager).await; + refresh_recording_progress( + &dq.active, + &worker_uuid, + &progress_path, + &event_manager, + ) + .await; } match result { @@ -1030,35 +1417,80 @@ pub(in crate::api) async fn ensure_download_worker_running( { DownloadExecutionResult::Completed => { connection_manager.release_provider_handle(provider_handle).await; - if let Some(fd) = &mut *dq.active.write().await { + let measured_bytes = { + let active = dq.active.read().await; + match active.as_ref() { + Some(fd) => tokio::fs::metadata(&fd.file_path) + .await + .map_or(fd.size, |metadata| metadata.len()), + None => 0, + } + }; + let committed = finish_active_and_promote(&dq, &worker_uuid, |fd| { fd.finished = true; + fd.paused = false; fd.state = DownloadState::Completed; + fd.size = measured_bytes; + fd.error = None; fd.next_retry_at = None; - dq.finished.write().await.push(fd.clone()); + if let Some(meta) = fd.recording.as_mut() { + meta.measured_bytes = measured_bytes; + meta.reserved_bytes = 0; + meta.completed_at = Some(chrono::Utc::now().timestamp()); + meta.partial_relative_path = None; + } + mark_recording_metadata_notification( + fd.recording.as_mut(), + LifecycleEvent::Completed, + None, + ) + }) + .await; + match committed { + Ok(Some(notification)) => { + spawn_recording_notification_after_persist( + &app_config, + &client, + notification, + true, + ); + broadcast_download_queue_update(&event_manager, &dq).await; + } + Ok(None) => {} + Err(err) => { + error!("Failed to persist completed download state: {err}"); + break 'worker; + } } - let _ = dq.persist_to_disk().await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; } DownloadExecutionResult::Paused => { connection_manager.release_provider_handle(provider_handle).await; - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + set_active_download_state(&dq, &worker_uuid, DownloadState::Paused, None, true).await, + "paused state", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; + } break; } DownloadExecutionResult::Cancelled => { connection_manager.release_provider_handle(provider_handle).await; - if let Some(fd) = dq.active.write().await.take() { - let mut fd = fd; - fd.next_retry_at = None; - dq.finished.write().await.push(fd); + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + cancel_active_and_promote(&dq, &worker_uuid).await, + "cancelled state", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; } - *dq.control_signal.write().await = DownloadControl::None; - let _ = dq.persist_to_disk().await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; } DownloadExecutionResult::Preempted => { connection_manager.release_provider_handle(provider_handle).await; @@ -1067,7 +1499,6 @@ pub(in crate::api) async fn ensure_download_worker_running( DownloadControl::Restart => warn!("Active transfer is restarting to apply updated download service configuration"), _ => warn!("Active transfer was preempted by a higher-priority stream"), } - *dq.control_signal.write().await = DownloadControl::None; let reason = { let active = dq.active.read().await; if control == DownloadControl::Restart { @@ -1076,10 +1507,24 @@ pub(in crate::api) async fn ensure_download_worker_running( active.as_ref().map_or(DOWNLOAD_PREEMPTED_REASON, preemption_reason_for) } }; - requeue_active_download_for_capacity_wait(&dq, reason).await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + requeue_active_download_for_capacity_wait( + &dq, + &worker_uuid, + reason, + true, + (control == DownloadControl::Restart).then_some(DownloadControl::Restart), + ) + .await, + "preempted requeue", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; + } if should_exit_worker_after_preempt(control) { break; } @@ -1087,64 +1532,32 @@ pub(in crate::api) async fn ensure_download_worker_running( DownloadExecutionResult::Retryable(_err) => { connection_manager.release_provider_handle(provider_handle).await; warn!("Retrying active download after transient failure"); - let retry_plan = { - let mut active = dq.active.write().await; - if let Some(download) = active.as_mut() { - download.retry_attempts = download.retry_attempts.saturating_add(1); - if download.retry_attempts > download_cfg.retry_max_attempts { - None - } else { - let retry_delay_secs = - compute_download_retry_backoff_secs(download.retry_attempts, &download_cfg); - let next_retry_at = chrono::Utc::now() - .timestamp() - .saturating_add(i64::try_from(retry_delay_secs).unwrap_or(i64::MAX)); - download.next_retry_at = Some(next_retry_at); - Some((retry_delay_secs, next_retry_at, download.retry_attempts)) + let retry_commit = prepare_active_retry(&dq, &worker_uuid, &download_cfg).await; + let retry_delay_secs = match retry_commit { + Ok(Some(RetryCommit::Waiting { delay_secs, attempts })) => { + debug!("Download retry attempt {attempts} scheduled in {delay_secs}s"); + broadcast_download_queue_update(&event_manager, &dq).await; + delay_secs + } + Ok(Some(RetryCommit::Failed(notification))) => { + spawn_recording_notification_after_persist( + &app_config, + &client, + notification, + true, + ); + broadcast_download_queue_update(&event_manager, &dq).await; + if dq.active.read().await.is_some() { + continue; } - } else { - let retry_delay_secs = compute_download_retry_backoff_secs(1, &download_cfg); - let next_retry_at = chrono::Utc::now() - .timestamp() - .saturating_add(i64::try_from(retry_delay_secs).unwrap_or(i64::MAX)); - Some((retry_delay_secs, next_retry_at, 1)) + break; + } + Ok(None) => break, + Err(err) => { + error!("Failed to persist retry state: {err}"); + break; } }; - let Some((retry_delay_secs, _next_retry_at, retry_attempts)) = retry_plan else { - if let Some(fd) = &mut *dq.active.write().await { - fd.finished = true; - fd.paused = false; - fd.next_retry_at = None; - fd.state = DownloadState::Failed; - fd.error = Some(format!( - "Retry limit reached after {} attempts", - download_cfg.retry_max_attempts - )); - dq.finished.write().await.push(fd.clone()); - } - let _ = dq.persist_to_disk().await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; - if dq.active.read().await.is_some() { - continue; - } - break; - }; - if set_active_download_state( - &dq, - DownloadState::RetryWaiting, - Some(format!( - "Retrying after transient failure in {retry_delay_secs}s (attempt {retry_attempts}/{})", - download_cfg.retry_max_attempts - )), - false, - ) - .await - { - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; - } let mut retry_sleep = Box::pin(time::sleep(Duration::from_secs(retry_delay_secs))); let retry_wait_outcome = loop { tokio::select! { @@ -1162,44 +1575,73 @@ pub(in crate::api) async fn ensure_download_worker_running( match retry_wait_outcome { DownloadExecutionResult::Retryable(_) => { - *dq.control_signal.write().await = DownloadControl::None; - requeue_active_download_for_retry(&dq).await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + requeue_active_download_for_retry(&dq, &worker_uuid, true).await, + "retry requeue", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; + } } DownloadExecutionResult::Paused => { - if let Some(download) = dq.active.write().await.as_mut() { - download.paused = true; - download.state = DownloadState::Paused; - download.next_retry_at = None; + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + set_active_download_state( + &dq, + &worker_uuid, + DownloadState::Paused, + None, + true, + ) + .await, + "paused retry state", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; } - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; break; } DownloadExecutionResult::Cancelled => { - if let Some(fd) = dq.active.write().await.take() { - let mut fd = fd; - fd.next_retry_at = None; - fd.error.get_or_insert_with(|| "Cancelled by user".to_string()); - fd.state = DownloadState::Cancelled; - dq.finished.write().await.push(fd); + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + cancel_active_and_promote(&dq, &worker_uuid).await, + "cancelled retry state", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; } - *dq.control_signal.write().await = DownloadControl::None; - let _ = dq.persist_to_disk().await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; } DownloadExecutionResult::Completed | DownloadExecutionResult::Failed(_) => {} DownloadExecutionResult::Preempted => { - *dq.control_signal.write().await = DownloadControl::None; - requeue_active_download_for_capacity_wait(&dq, "Reloading download service configuration").await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; + if let Err(err) = broadcast_required_worker_mutation( + &event_manager, + &dq, + requeue_active_download_for_capacity_wait( + &dq, + &worker_uuid, + "Reloading download service configuration", + true, + Some(DownloadControl::Restart), + ) + .await, + "configuration-reload retry requeue", + ) + .await + { + error!("Download worker commit failed: {err}"); + break 'worker; + } break; } } @@ -1207,18 +1649,35 @@ pub(in crate::api) async fn ensure_download_worker_running( DownloadExecutionResult::Failed(err) => { connection_manager.release_provider_handle(provider_handle).await; warn!("Download failed permanently: {err}"); - if let Some(fd) = &mut *dq.active.write().await { + let committed = finish_active_and_promote(&dq, &worker_uuid, |fd| { fd.finished = true; fd.paused = false; fd.next_retry_at = None; - fd.error = Some(err); + fd.error = Some(err.clone()); fd.state = DownloadState::Failed; - dq.finished.write().await.push(fd.clone()); + mark_recording_metadata_notification( + fd.recording.as_mut(), + LifecycleEvent::Failed, + Some(err), + ) + }) + .await; + match committed { + Ok(Some(notification)) => { + spawn_recording_notification_after_persist( + &app_config, + &client, + notification, + true, + ); + broadcast_download_queue_update(&event_manager, &dq).await; + } + Ok(None) => {} + Err(commit_err) => { + error!("Failed to persist failed download state: {commit_err}"); + break 'worker; + } } - let _ = dq.persist_to_disk().await; - *dq.active.write().await = dq.queue.lock().await.pop_front(); - let _ = dq.persist_to_disk().await; - broadcast_download_queue_update(&event_manager, &dq).await; } } } else { @@ -1394,8 +1853,18 @@ pub async fn queue_download_file( "Queueing download {} ({}) from {}", file_download.uuid, file_download.filename, file_download.url ); - app_state.downloads.queue.lock().await.push_back(file_download.clone()); - let _ = app_state.downloads.persist_to_disk().await; + if let Err(err) = mutate(&app_state.downloads, |candidate| { + candidate.queue.push(DownloadQueue::to_persisted(&file_download)); + Ok(()) + }) + .await + { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.message()})), + ) + .into_response(); + } if app_state.downloads.active.read().await.is_none() { match ensure_download_worker_running( &app_state.app_config, @@ -1479,8 +1948,18 @@ pub async fn queue_recording_file( ); return axum::Json(shared::model::FileDownloadDto::from(&existing)).into_response(); } - app_state.downloads.scheduled.write().await.push(recording.clone()); - let _ = app_state.downloads.persist_to_disk().await; + if let Err(err) = mutate(&app_state.downloads, |candidate| { + candidate.scheduled.push(DownloadQueue::to_persisted(&recording)); + Ok(()) + }) + .await + { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.message()})), + ) + .into_response(); + } broadcast_download_queue_update(&app_state.event_manager, &app_state.downloads).await; axum::Json(shared::model::FileDownloadDto::from(&recording)).into_response() } @@ -1509,25 +1988,29 @@ pub async fn pause_download( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(req): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { - let active = { app_state.downloads.active.read().await.clone() }; - if let Some(active) = active { - if active.uuid == req.uuid { - app_state.downloads.pause_active().await; + match app_state.downloads.pause_active(&req.uuid).await { + Ok(true) => { broadcast_download_queue_update(&app_state.event_manager, &app_state.downloads).await; - return axum::Json(json!({"success": true})).into_response(); + axum::Json(json!({"success": true})).into_response() + } + Ok(false) => axum::Json(json!({"success": false})).into_response(), + Err(err) => { + error!("Failed to persist paused download state: {err}"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.message()})), + ) + .into_response() } } - axum::Json(json!({"success": false})).into_response() } pub async fn resume_download( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(req): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { - let active = { app_state.downloads.active.read().await.clone() }; - if let Some(active) = active { - if active.uuid == req.uuid && active.paused { - app_state.downloads.resume_active().await; + match app_state.downloads.resume_active(&req.uuid).await { + Ok(true) => { let download_cfg = app_state .app_config .config @@ -1560,38 +2043,30 @@ pub async fn resume_download( }); } broadcast_download_queue_update(&app_state.event_manager, &app_state.downloads).await; - return axum::Json(json!({"success": true})).into_response(); + axum::Json(json!({"success": true})).into_response() + } + Ok(false) => axum::Json(json!({"success": false})).into_response(), + Err(err) => { + error!("Failed to persist resumed download state: {err}"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.message()})), + ) + .into_response() } } - axum::Json(json!({"success": false})).into_response() } pub async fn cancel_download( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(req): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { - let active = { app_state.downloads.active.read().await.clone() }; - if let Some(active) = active { - if active.uuid == req.uuid { - let was_paused = active.paused; - app_state.downloads.cancel_active().await; + match app_state.downloads.cancel_requested(&req.uuid).await { + Ok(Some(was_paused)) => { if was_paused { - if let Some(fd) = app_state.downloads.active.write().await.take() { - let mut fd = fd; - fd.next_retry_at = None; - fd.finished = true; - fd.paused = false; - fd.state = DownloadState::Cancelled; - fd.error.get_or_insert_with(|| "Cancelled by user".to_string()); - app_state.downloads.finished.write().await.push(fd); - } - *app_state.downloads.control_signal.write().await = DownloadControl::None; - let next_active = app_state.downloads.queue.lock().await.pop_front(); - *app_state.downloads.active.write().await = next_active; - let _ = app_state.downloads.persist_to_disk().await; let config = app_state.app_config.config.load(); if let Some(download_cfg) = config.video.as_ref().and_then(|video| video.download.as_ref()) { - let _ = ensure_download_worker_running( + if let Err(err) = ensure_download_worker_running( &app_state.app_config, download_cfg, &app_state.downloads, @@ -1599,38 +2074,73 @@ pub async fn cancel_download( &app_state.active_provider, &app_state.connection_manager, ) - .await; + .await + { + error!("Failed to start download worker after cancelling paused task: {err}"); + } } } broadcast_download_queue_update(&app_state.event_manager, &app_state.downloads).await; - return axum::Json(json!({"success": true})).into_response(); + axum::Json(json!({"success": true})).into_response() + } + Ok(None) => { + match app_state.downloads.remove_from_queue(&req.uuid).await { + Ok(true) => { + broadcast_download_queue_update(&app_state.event_manager, &app_state.downloads).await; + axum::Json(json!({"success": true})).into_response() + } + Ok(false) => axum::Json(json!({"success": false})).into_response(), + Err(err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.message()})), + ) + .into_response(), + } + } + Err(err) => { + error!("Failed to persist cancelled download state: {err}"); + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.message()})), + ) + .into_response() } } - // Remove from queue - let found = app_state.downloads.remove_from_queue(&req.uuid).await; - if found { - broadcast_download_queue_update(&app_state.event_manager, &app_state.downloads).await; - } - axum::Json(json!({"success": found})).into_response() } pub async fn remove_download( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(req): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { - let removed_from_finished = app_state.downloads.remove_finished(&req.uuid).await; - let removed_from_queue = app_state.downloads.remove_from_queue(&req.uuid).await; - if removed_from_finished || removed_from_queue { - broadcast_download_queue_update(&app_state.event_manager, &app_state.downloads).await; + match app_state.downloads.remove(&req.uuid).await { + Ok(removed) => { + if removed { + broadcast_download_queue_update(&app_state.event_manager, &app_state.downloads).await; + } + axum::Json(json!({"success": removed})).into_response() + } + Err(err) => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.message()})), + ) + .into_response(), } - axum::Json(json!({"success": removed_from_finished || removed_from_queue})).into_response() } pub async fn retry_download( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(req): axum::extract::Json, ) -> impl axum::response::IntoResponse + Send { - let retried = app_state.downloads.retry_finished(&req.uuid).await; + let retried = match app_state.downloads.retry_finished(&req.uuid).await { + Ok(retried) => retried, + Err(err) => { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": err.message()})), + ) + .into_response(); + } + }; if retried { // Start the queue if not running let app_config = &app_state.app_config; @@ -1658,24 +2168,41 @@ pub async fn retry_download( #[cfg(test)] mod tests { use super::{ - active_download_snapshot, parse_content_range_total, pause_download, preemption_reason_for, recording_deadline_reached, - requeue_active_download_for_capacity_wait, requeue_active_download_for_retry, resume_download, - retryable_transport_error_message, set_active_download_state, should_exit_worker_after_preempt, DownloadActionRequest, + active_download_snapshot, active_download_snapshot_for_worker, broadcast_download_queue_update, + broadcast_required_worker_mutation, broadcast_worker_mutation, cancel_active_and_promote, cancel_download, + commit_acquired_download, finish_active_and_promote, + mark_recording_notification, + parse_content_range_total, pause_download, preemption_reason_for, recording_deadline_reached, + recording_execution_download, + refresh_recording_progress, requeue_active_download_for_capacity_wait, + requeue_active_download_for_retry, resume_download, rollback_last_recording_marker, + retryable_transport_error_message, set_active_download_state, should_exit_worker_after_preempt, + DownloadActionRequest, DOWNLOAD_PREEMPTED_REASON, RECORDING_PREEMPTED_REASON, }; use crate::{ api::model::{ ActiveProviderManager, ActiveUserManager, AppState, CancelTokens, ConnectionManager, DownloadControl, - DownloadKind, DownloadQueue, DownloadState, EventManager, FileDownload, MetadataUpdateManager, + DownloadKind, DownloadQueue, DownloadState, EventManager, EventMessage, FileDownload, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, UpdateGuard, }, - model::{AppConfig, Config, ConfigInput, MediaToolCapabilities, ProcessTargets, SourcesConfig}, + api::model::recording_notification::LifecycleEvent, + model::{ + ApiProxyConfig, ApiProxyServerInfo, AppConfig, Config, ConfigInput, MediaToolCapabilities, + MessageContent, ProcessTargets, SourcesConfig, + }, utils::{FileLockManager, GeoIp}, }; use arc_swap::{ArcSwap, ArcSwapOption}; use axum::response::IntoResponse; use reqwest::header::{HeaderMap, HeaderValue}; - use shared::{model::{ConfigPaths, InputFetchMethod, InputType}, utils::Internable}; + use shared::{ + model::{ + ConfigPaths, InputFetchMethod, InputType, RecordingMetadata, RecordingOwner, RecordingSource, + RecordingVisibility, UserId, + }, + utils::Internable, + }; use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; use tokio::sync::mpsc; use tokio::sync::RwLock; @@ -1700,9 +2227,90 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, } } + fn attach_recording(download: &mut FileDownload, owner: RecordingOwner, visibility: RecordingVisibility) { + let mut metadata = RecordingMetadata::new( + owner, + visibility, + RecordingSource::new("1", "42", "input-a"), + 1_000, + 1_060, + 0, + 0, + ); + metadata.program_title = Some("Programme".to_string()); + metadata.channel_name = Some("Channel".to_string()); + metadata.relative_path = Some("Channel/Programme.ts".to_string()); + download.recording = Some(metadata); + } + + #[test] + fn recording_notification_marker_is_at_most_once() { + let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); + attach_recording(&mut download, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); + + let first = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); + let duplicate = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); + + assert!(matches!(first.message, Some(MessageContent::RecordingLifecycle(_)))); + assert!(duplicate.message.is_none()); + assert_eq!( + download + .recording + .as_ref() + .map_or(0, |metadata| metadata.notification_markers.len()), + 1 + ); + } + + #[test] + fn private_user_recording_notification_is_suppressed() { + let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); + attach_recording( + &mut download, + RecordingOwner::User(UserId::from("web:alice")), + RecordingVisibility::Private, + ); + + let message = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); + + assert!(message.message.is_none()); + assert_eq!( + download + .recording + .as_ref() + .map_or(0, |metadata| metadata.notification_markers.len()), + 0 + ); + } + + #[test] + fn rollback_last_recording_marker_removes_most_recent_matching_kind() { + let mut download = make_download(DownloadKind::Recording, DownloadState::Completed, Some(1_000), Some(60)); + attach_recording(&mut download, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); + + // Two distinct marker kinds end up in the same metadata after a + // successful Completed followed by a Failed on the same task — this + // mirrors what would happen in production across two persist rounds. + let first = mark_recording_notification(&mut download, LifecycleEvent::Completed, None); + let second = mark_recording_notification(&mut download, LifecycleEvent::Failed, Some("ffmpeg exited".to_string())); + assert!(first.marker_kind.is_some()); + assert!(second.marker_kind.is_some()); + + let kind = second.marker_kind.unwrap(); + rollback_last_recording_marker(&mut download, &kind); + + let markers = &download.recording.as_ref().unwrap().notification_markers; + assert_eq!(markers.len(), 1, "only the Completed marker should remain"); + assert!(matches!( + markers[0].kind, + shared::model::recording::NotificationMarkerKind::Completed + )); + } + #[test] fn recording_deadline_uses_start_plus_duration() { let recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); @@ -1713,16 +2321,59 @@ mod tests { assert!(!recording_deadline_reached(&normal, 1_060)); } + #[test] + fn recording_execution_requires_metadata_source() { + let recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); + + let result = recording_execution_download(&create_test_app_config(), &recording); + + assert_eq!(result.as_ref().err().map(String::as_str), Some("Recording source metadata missing")); + } + + #[test] + fn recording_execution_uses_fresh_token_without_mutating_persisted_descriptor() { + let mut recording = make_download(DownloadKind::Recording, DownloadState::Downloading, Some(1_000), Some(60)); + recording.url = reqwest::Url::parse( + "tuliprox-recording://source?target_name=stable-target&input_name=provider_1&virtual_id=42&cluster=live", + ) + .expect("valid descriptor"); + attach_recording(&mut recording, RecordingOwner::LegacyAdmin, RecordingVisibility::Private); + let source = recording + .recording + .as_mut() + .and_then(|metadata| metadata.source.as_mut()) + .expect("recording source"); + source.target_id = "stable-target".to_string(); + source.virtual_id = "42".to_string(); + source.input_name = "provider_1".to_string(); + let persisted_before = DownloadQueue::to_persisted(&recording); + let app_config = create_test_app_config(); + + let execution = recording_execution_download(&app_config, &recording).expect("execution download"); + let token = execution + .url + .path_segments() + .and_then(|segments| segments.collect::>().get(4).copied()) + .expect("route token"); + + assert!(crate::auth::verify_access_token(token, &app_config.access_token_secret)); + assert_eq!(recording.url.as_str(), persisted_before.url); + assert_eq!(DownloadQueue::to_persisted(&recording).url, persisted_before.url); + assert_ne!(execution.url, recording.url); + } + #[tokio::test] - async fn retry_requeues_active_download_at_front() { - let queue = DownloadQueue::new(); + async fn retry_requeues_active_download_at_front_in_one_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file.clone())); let queued = make_download(DownloadKind::Download, DownloadState::Queued, None, None); let active = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); queue.queue.lock().await.push_back(queued); *queue.active.write().await = Some(active); - requeue_active_download_for_retry(&queue).await; + requeue_active_download_for_retry(&queue, "id", false).await.expect("requeue retry"); assert!(queue.active.read().await.is_none()); let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); @@ -1730,6 +2381,10 @@ mod tests { assert_eq!(queued_items[0].state, DownloadState::Queued); assert_eq!(queued_items[0].size, 128); assert!(queued_items[0].error.is_none()); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); + let persisted: crate::api::model::PersistedDownloadQueue = + serde_json::from_slice(&std::fs::read(state_file).expect("read state")).expect("parse state"); + assert_eq!(persisted.revision, shared::model::QueueRevision(1)); } #[tokio::test] @@ -1740,7 +2395,9 @@ mod tests { active.total_size = Some(2048); *queue.active.write().await = Some(active); - requeue_active_download_for_capacity_wait(&queue, DOWNLOAD_PREEMPTED_REASON).await; + requeue_active_download_for_capacity_wait(&queue, "id", DOWNLOAD_PREEMPTED_REASON, false, None) + .await + .expect("requeue capacity wait"); assert!(queue.active.read().await.is_none()); let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); @@ -1751,6 +2408,54 @@ mod tests { assert_eq!(queued_items[0].error.as_deref(), Some(DOWNLOAD_PREEMPTED_REASON)); } + #[tokio::test] + async fn terminal_transition_finishes_active_and_promotes_next_in_one_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + let queue = DownloadQueue::new_with_state_file(Some(dir.path().join("downloads_state.json"))); + *queue.active.write().await = Some(make_download( + DownloadKind::Download, + DownloadState::Downloading, + None, + None, + )); + let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); + next.uuid = "next".to_string(); + queue.queue.lock().await.push_back(next); + + finish_active_and_promote(&queue, "id", |finished| { + finished.finished = true; + finished.state = DownloadState::Completed; + super::RecordingNotificationPlan::empty() + }) + .await + .expect("terminal commit"); + + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(queue.finished.read().await.len(), 1); + assert_eq!(queue.active.read().await.as_ref().map(|active| active.uuid.as_str()), Some("next")); + } + + #[tokio::test] + async fn worker_mutation_failure_keeps_memory_and_revision_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let blocking_dir = dir.path().join("state"); + std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); + let queue = DownloadQueue::new_with_state_file(Some(blocking_dir)); + *queue.active.write().await = Some(make_download( + DownloadKind::Download, + DownloadState::Downloading, + None, + None, + )); + + let result = requeue_active_download_for_retry(&queue, "id", false).await; + + assert!(result.is_err()); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(queue.queue.lock().await.is_empty()); + assert_eq!(queue.active.read().await.as_ref().map(|active| active.state.clone()), Some(DownloadState::Downloading)); + } + #[tokio::test] async fn preempted_active_recording_requeues_with_recording_specific_policy_message() { let queue = DownloadQueue::new(); @@ -1758,7 +2463,9 @@ mod tests { active.size = 512; *queue.active.write().await = Some(active); - requeue_active_download_for_capacity_wait(&queue, RECORDING_PREEMPTED_REASON).await; + requeue_active_download_for_capacity_wait(&queue, "id", RECORDING_PREEMPTED_REASON, false, None) + .await + .expect("requeue recording"); let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); assert_eq!(queued_items.len(), 1); @@ -1792,19 +2499,101 @@ mod tests { let changed = set_active_download_state( &queue, + "id", DownloadState::WaitingForCapacity, Some("waiting".to_string()), false, ) .await; - assert!(changed); + assert!(changed.expect("set active state")); let active = queue.active.read().await.clone().expect("active download"); assert_eq!(active.state, DownloadState::WaitingForCapacity); assert_eq!(active.error.as_deref(), Some("waiting")); assert!(!active.paused); } + #[tokio::test] + async fn acquisition_without_provider_handle_commits_downloading_state() { + let queue = DownloadQueue::new(); + *queue.active.write().await = Some(make_download( + DownloadKind::Download, + DownloadState::Queued, + None, + None, + )); + + let notification = commit_acquired_download(&queue, "id").await.expect("acquired commit"); + + assert!(notification.is_some()); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(queue.active.read().await.as_ref().map(|active| active.state.clone()), Some(DownloadState::Downloading)); + } + + #[tokio::test] + async fn acquired_transition_rejects_switched_active_task() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + let mut switched = make_download(DownloadKind::Recording, DownloadState::Queued, None, None); + switched.uuid = "task-b".to_string(); + attach_recording(&mut switched, RecordingOwner::LegacyAdmin, RecordingVisibility::Shared); + *queue.active.write().await = Some(switched); + + let transition = commit_acquired_download(&queue, "task-a").await.map(|notification| notification.is_some()); + let result = broadcast_required_worker_mutation( + &event_manager, + &queue, + transition, + "acquired downloading state", + ) + .await; + + assert!(result.is_err()); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(queue.active.read().await.as_ref().map(|active| active.uuid.as_str()), Some("task-b")); + assert_eq!( + queue + .active + .read() + .await + .as_ref() + .and_then(|active| active.recording.as_ref()) + .map_or(0, |recording| recording.notification_markers.len()), + 0 + ); + assert!(events.try_recv().is_err()); + } + + #[tokio::test] + async fn post_acquire_snapshot_rejects_switched_active_task() { + let queue = DownloadQueue::new(); + let mut switched = make_download(DownloadKind::Download, DownloadState::Downloading, None, None); + switched.uuid = "task-b".to_string(); + *queue.active.write().await = Some(switched); + + assert!(active_download_snapshot_for_worker(&queue.active, "task-a").await.is_none()); + } + + #[tokio::test] + async fn stale_worker_progress_does_not_update_switched_active_task() { + let queue = DownloadQueue::new(); + let mut switched = make_download(DownloadKind::Recording, DownloadState::Downloading, None, None); + switched.uuid = "task-b".to_string(); + switched.size = 10; + *queue.active.write().await = Some(switched); + let dir = tempfile::tempdir().expect("tempdir"); + let progress_path = dir.path().join("task-a.ts.part"); + std::fs::write(&progress_path, [0_u8; 20]).expect("progress file"); + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + + refresh_recording_progress(&queue.active, "task-a", &progress_path, &event_manager).await; + + assert_eq!(queue.active.read().await.as_ref().map(|active| active.size), Some(10)); + assert!(events.try_recv().is_err()); + } + #[test] fn compute_download_retry_backoff_uses_multiplier_and_cap() { let download_cfg = crate::model::VideoDownloadConfig { @@ -1821,6 +2610,7 @@ mod tests { retry_backoff_max_secs: 30, retry_backoff_jitter_percent: 0, retry_max_attempts: 5, + recording: None, }; assert_eq!(super::compute_download_retry_backoff_secs(1, &download_cfg), 3); @@ -1845,6 +2635,7 @@ mod tests { retry_backoff_max_secs: 30, retry_backoff_jitter_percent: 0, retry_max_attempts: 5, + recording: None, }; let capacities = vec![(Arc::::from("a"), 2, 5), (Arc::::from("b"), 3, 5)]; @@ -1868,6 +2659,7 @@ mod tests { retry_backoff_max_secs: 30, retry_backoff_jitter_percent: 0, retry_max_attempts: 5, + recording: None, }; let blocked = vec![(Arc::::from("a"), 4, 5), (Arc::::from("b"), 4, 5)]; @@ -1905,6 +2697,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }))); let snapshot = active_download_snapshot(&active).await; assert!(snapshot.is_some()); @@ -1946,7 +2739,18 @@ mod tests { config: Arc::new(ArcSwap::from_pointee(Config::default())), sources: Arc::new(ArcSwap::from_pointee(sources)), hdhomerun: Arc::new(ArcSwapOption::default()), - api_proxy: Arc::new(ArcSwapOption::default()), + api_proxy: Arc::new(ArcSwapOption::from(Some(Arc::new(ApiProxyConfig { + server: vec![ApiProxyServerInfo { + name: "default".to_string(), + protocol: "http".to_string(), + host: "player.example".to_string(), + port: None, + timezone: "UTC".to_string(), + message: String::new(), + path: None, + }], + ..ApiProxyConfig::default() + })))), file_locks: Arc::new(FileLockManager::default()), paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { home_path: String::new(), @@ -1968,7 +2772,7 @@ mod tests { } } - fn create_test_app_state() -> Arc { + fn create_test_app_state_with_downloads(downloads: Arc) -> Arc { let app_cfg = Arc::new(create_test_app_config()); let event_manager = Arc::new(EventManager::new()); let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager)); @@ -2001,7 +2805,7 @@ mod tests { http_client: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), public_http_client_no_redirect: Arc::new(ArcSwap::from_pointee(reqwest::Client::new())), - downloads: Arc::new(DownloadQueue::new()), + downloads, cache: Arc::new(ArcSwapOption::default()), shared_stream_manager, hls_proxy: Arc::new(crate::api::model::HlsProxyManager::new()), @@ -2019,6 +2823,195 @@ mod tests { }) } + fn create_test_app_state() -> Arc { + create_test_app_state_with_downloads(Arc::new(DownloadQueue::new())) + } + + #[tokio::test] + async fn pause_persist_failure_returns_error_without_event_or_memory_change() { + let dir = tempfile::tempdir().expect("tempdir"); + let blocking_dir = dir.path().join("state"); + std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); + *downloads.active.write().await = Some(make_download( + DownloadKind::Download, + DownloadState::Downloading, + None, + None, + )); + let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); + let mut events = app_state.event_manager.get_event_channel(); + + let response = pause_download( + axum::extract::State(app_state), + axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), + ) + .await + .into_response(); + + assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); + assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); + assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + let active = downloads.active.read().await; + assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Downloading)); + assert_eq!(active.as_ref().map(|download| download.paused), Some(false)); + } + + #[tokio::test] + async fn resume_persist_failure_returns_error_without_event_or_memory_change() { + let dir = tempfile::tempdir().expect("tempdir"); + let blocking_dir = dir.path().join("state"); + std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); + let mut paused = make_download(DownloadKind::Download, DownloadState::Paused, None, None); + paused.paused = true; + *downloads.active.write().await = Some(paused); + let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); + let mut events = app_state.event_manager.get_event_channel(); + + let response = resume_download( + axum::extract::State(app_state), + axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), + ) + .await + .into_response(); + + assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); + assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); + assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + let active = downloads.active.read().await; + assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Paused)); + assert_eq!(active.as_ref().map(|download| download.paused), Some(true)); + } + + #[tokio::test] + async fn paused_cancel_persist_failure_returns_error_without_event_or_memory_change() { + let dir = tempfile::tempdir().expect("tempdir"); + let blocking_dir = dir.path().join("state"); + std::fs::create_dir_all(&blocking_dir).expect("create blocking dir"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(blocking_dir))); + let mut paused = make_download(DownloadKind::Download, DownloadState::Paused, None, None); + paused.paused = true; + *downloads.active.write().await = Some(paused); + let app_state = create_test_app_state_with_downloads(Arc::clone(&downloads)); + let mut events = app_state.event_manager.get_event_channel(); + + let response = cancel_download( + axum::extract::State(app_state), + axum::extract::Json(DownloadActionRequest { uuid: "id".to_string() }), + ) + .await + .into_response(); + + assert_eq!(response.status(), axum::http::StatusCode::INTERNAL_SERVER_ERROR); + assert!(events.try_recv().is_err(), "failed mutation must not broadcast"); + assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(downloads.finished.read().await.is_empty()); + let active = downloads.active.read().await; + assert_eq!(active.as_ref().map(|download| download.state.clone()), Some(DownloadState::Paused)); + assert_eq!(active.as_ref().map(|download| download.paused), Some(true)); + } + + #[tokio::test] + async fn cancel_normalizes_active_and_promotes_next_in_one_commit() { + let dir = tempfile::tempdir().expect("tempdir"); + let queue = DownloadQueue::new_with_state_file(Some(dir.path().join("downloads_state.json"))); + let mut active = make_download(DownloadKind::Download, DownloadState::Paused, None, None); + active.paused = true; + active.next_retry_at = Some(42); + active.error = None; + *queue.active.write().await = Some(active); + let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); + next.uuid = "next".to_string(); + queue.queue.lock().await.push_back(next); + + let committed = cancel_active_and_promote(&queue, "id").await.expect("cancel commit"); + + assert!(committed); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 1); + let finished = queue.finished.read().await; + let cancelled = finished.first().expect("cancelled task"); + assert!(cancelled.finished); + assert!(!cancelled.paused); + assert_eq!(cancelled.state, DownloadState::Cancelled); + assert_eq!(cancelled.error.as_deref(), Some("Cancelled by user")); + assert!(cancelled.next_retry_at.is_none()); + assert_eq!(queue.active.read().await.as_ref().map(|download| download.uuid.as_str()), Some("next")); + } + + #[tokio::test] + async fn cancel_uuid_mismatch_does_not_finish_or_promote_next_task() { + let queue = DownloadQueue::new(); + *queue.active.write().await = Some(make_download( + DownloadKind::Download, + DownloadState::Paused, + None, + None, + )); + let mut next = make_download(DownloadKind::Download, DownloadState::Queued, None, None); + next.uuid = "next".to_string(); + queue.queue.lock().await.push_back(next); + + let committed = cancel_active_and_promote(&queue, "next").await.expect("cancel no-op"); + + assert!(!committed); + assert_eq!(queue.revision.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(queue.active.read().await.as_ref().map(|download| download.uuid.as_str()), Some("id")); + assert_eq!(queue.queue.lock().await.front().map(|download| download.uuid.as_str()), Some("next")); + assert!(queue.finished.read().await.is_empty()); + } + + #[tokio::test] + async fn worker_noop_mutation_does_not_broadcast() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + + let changed = broadcast_worker_mutation( + &event_manager, + &queue, + Ok(false), + "test no-op mutation", + ) + .await; + + assert!(!changed.expect("no-op result")); + assert!(events.try_recv().is_err()); + } + + #[tokio::test] + async fn worker_commit_error_is_propagated_without_clearing_control() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + *queue.control_signal.write().await = DownloadControl::Cancel; + + let result = broadcast_worker_mutation( + &event_manager, + &queue, + Err(crate::api::model::QueueMutationError::DiskFull), + "terminal transition", + ) + .await; + + assert!(result.is_err()); + assert_eq!(*queue.control_signal.read().await, DownloadControl::Cancel); + assert!(events.try_recv().is_err()); + } + + #[tokio::test] + async fn required_worker_noop_is_an_error_without_broadcast() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + + let result = + broadcast_required_worker_mutation(&event_manager, &queue, Ok(false), "terminal transition").await; + + assert!(result.is_err()); + assert!(events.try_recv().is_err()); + } + #[tokio::test] async fn pause_and_resume_handlers_return_without_hanging() { let app_state = create_test_app_state(); @@ -2041,6 +3034,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; *app_state.downloads.active.write().await = Some(active); @@ -2071,4 +3065,21 @@ mod tests { let _ = pause_response.expect("pause response").into_response(); let _ = resume_response.expect("resume response").into_response(); } + + #[tokio::test] + async fn queue_update_notifies_recording_subscribers() { + let event_manager = Arc::new(EventManager::new()); + let mut events = event_manager.get_event_channel(); + let queue = DownloadQueue::new(); + + broadcast_download_queue_update(&event_manager, &queue).await; + + let mut recording_changed = false; + while let Ok(event) = events.try_recv() { + if event == EventMessage::RecordingChanged { + recording_changed = true; + } + } + assert!(recording_changed); + } } diff --git a/backend/src/api/endpoints/hls_api.rs b/backend/src/api/endpoints/hls_api.rs index 233919a5e..ac6e7067b 100644 --- a/backend/src/api/endpoints/hls_api.rs +++ b/backend/src/api/endpoints/hls_api.rs @@ -65,7 +65,8 @@ use crate::{ HlsOriginResourceFetchError, HlsOriginSource, HlsOriginSourceKind, HlsOriginWorkClass, HlsMasterBandwidth, HlsMasterBandwidthSelection, HlsPanelProvisioningRedirectPaths, HlsPlaybackFamilyKey, HlsProvisioningStatus, HlsQosMeterInit, - HlsQosRuntimeConfig, HlsResourceServeFailure, HlsResourceServeOutcome, HlsSegmentFile, HlsSession, + HlsQosRuntimeConfig, HlsResourceFetchAttempt, HlsResourceServeFailure, HlsResourceServeOutcome, + HlsSegmentFile, HlsSession, HlsSessionHandle, HlsSessionKey, HlsSessionMode, HlsSessionStoreOutcome, HlsTerminalSegmentPath, HlsRuntimeCustomTailOutcome, HlsRuntimeCustomTailReason, HlsRuntimeCustomTailRequest, HlsTransientCacheCommitContext, @@ -74,7 +75,8 @@ use crate::{ HlsTransientOriginFetchRequest, HlsTransientOriginIoGuard, LiveHlsOriginEntry, OriginRefreshRequest, OriginSegmentKey, ProviderAllocation, ProviderConfig as RuntimeProviderConfig, ProviderHandle, ProxySessionId, RetryPolicy, SegmentCacheKey, SegmentCacheStatus, SegmentDemandFetchOutcome, - SegmentFetchContext, SegmentFetchPolicy, StreamMeterHandle, TransientObjectUnavailableState, + SegmentFetchContext, SegmentFetchPolicy, StreamMeterHandle, + TransientObjectUnavailableState, TransientResourceFile, TransientResourceRef, TransportStreamBuffer, UserSession, HlsSingleVariantMasterPlaylist, trigger_origin_refresh_sync, HLS_ACCESS_LEASE_ID_PLACEHOLDER, HLS_PROVISIONING_GAP_ORIGIN_EPOCH, HLS_PROVISIONING_ORIGIN_EPOCH, @@ -123,9 +125,13 @@ use shared::{ use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration}; use url::Url; -const MAX_MANUAL_REDIRECTS: usize = 10; const HLS_TEMPORARY_RESOURCE_RETRY_AFTER_SECS: u64 = 1; const HLS_TEMPORARY_RESOURCE_RETRY_AFTER_MS: u64 = HLS_TEMPORARY_RESOURCE_RETRY_AFTER_SECS * 1_000; +/// Poll interval while waiting for a canonical manifest commit. Lower values +/// reduce time-to-first-manifest at the cost of more wakeups per waiting client. +const HLS_MANIFEST_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(25); + +use crate::api::model::MAX_MANUAL_REDIRECTS; /// Recover archive EPG reference from `m3u-catchup|...|archive|{start}|{duration}` session keys. /// @@ -607,8 +613,8 @@ fn hls_access_lease_ttl_ms(app_state: &Arc) -> u64 { app_state.hls_pro fn duration_to_millis_saturating(duration: Duration) -> u64 { u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) } -fn hls_pending_bootstrap_window_ms() -> u64 { - duration_to_millis_saturating(hls_initial_manifest_decision_wait_timeout()) +fn hls_pending_bootstrap_window_ms(app_state: &Arc) -> u64 { + duration_to_millis_saturating(hls_initial_manifest_decision_wait_timeout(app_state)) } async fn hls_access_lease_timing_for_session( @@ -1628,19 +1634,45 @@ async fn fetch_transient_origin_response_with_provider_io( log_identity, }; let runtime_prepare_error = Arc::new(tokio::sync::Mutex::new(None)); - let app_state_for_prepare = Arc::clone(request.app_state); - let session_for_prepare = Arc::clone(request.session); - let access_context_for_prepare = request.access_context.clone(); - let fingerprint_for_prepare = request.fingerprint.clone(); - let headers_for_prepare = request.headers.clone(); - let runtime_prepare_error_for_prepare = Arc::clone(&runtime_prepare_error); - let result = fetch_hls_transient_origin_response_with_attempt_prepare(fetch_request, move |_attempt| { - let app_state = Arc::clone(&app_state_for_prepare); - let session = Arc::clone(&session_for_prepare); - let access_context = access_context_for_prepare.clone(); - let fingerprint = fingerprint_for_prepare.clone(); - let headers = headers_for_prepare.clone(); - let runtime_prepare_error = Arc::clone(&runtime_prepare_error_for_prepare); + let prepare_attempt = hls_transient_origin_prepare_closure( + request.app_state, + request.session, + request.access_context, + request.fingerprint, + request.headers, + &runtime_prepare_error, + ); + let result = fetch_hls_transient_origin_response_with_attempt_prepare(fetch_request, prepare_attempt).await; + let runtime_prepare_error = *runtime_prepare_error.lock().await; + HlsTransientOriginFetchResult { result, runtime_prepare_error } +} + +/// Builds the shared per-attempt prepare closure for transient origin fetches. +/// Runtime acquire failures are captured in `runtime_prepare_error` and mapped +/// to a provider-unavailable fetch error so the retry loop can proceed uniformly. +fn hls_transient_origin_prepare_closure( + app_state: &Arc, + session: &HlsSessionHandle, + access_context: &HlsAccessContext, + fingerprint: &Fingerprint, + headers: &HeaderMap, + runtime_prepare_error: &Arc>>, +) -> impl FnMut( + HlsResourceFetchAttempt, +) -> futures::future::BoxFuture<'static, Result, HlsOriginResourceFetchError>> { + let app_state = Arc::clone(app_state); + let session = Arc::clone(session); + let access_context = access_context.clone(); + let fingerprint = fingerprint.clone(); + let headers = headers.clone(); + let runtime_prepare_error = Arc::clone(runtime_prepare_error); + move |_attempt| { + let app_state = Arc::clone(&app_state); + let session = Arc::clone(&session); + let access_context = access_context.clone(); + let fingerprint = fingerprint.clone(); + let headers = headers.clone(); + let runtime_prepare_error = Arc::clone(&runtime_prepare_error); async move { match prepare_hls_transient_origin_io_for_authorized_resource_work( &app_state, @@ -1660,10 +1692,7 @@ async fn fetch_transient_origin_response_with_provider_io( } } .boxed() - }) - .await; - let runtime_prepare_error = *runtime_prepare_error.lock().await; - HlsTransientOriginFetchResult { result, runtime_prepare_error } + } } #[allow(clippy::too_many_arguments)] @@ -2453,7 +2482,7 @@ async fn hls_manifest_access_context_and_state( now_ms, None, Some(HlsAccessLeasePendingDeadline::Bootstrap { - deadline_ms: now_ms.saturating_add(hls_pending_bootstrap_window_ms()), + deadline_ms: now_ms.saturating_add(hls_pending_bootstrap_window_ms(app_state)), }), hls_access_lease_ttl_ms(app_state), ) @@ -2553,44 +2582,18 @@ async fn fetch_and_cache_transient_origin_response( cache_duration_ms: context.cache_duration_ms, }, }; - let app_state_for_prepare = Arc::clone(context.app_state); - let session_for_prepare = Arc::clone(context.session); - let access_context = context.access_context.clone(); - let fingerprint_for_prepare = context.fingerprint.clone(); - let headers_for_prepare = context.headers.clone(); let runtime_prepare_error = Arc::new(tokio::sync::Mutex::new(None)); - let runtime_prepare_error_for_prepare = Arc::clone(&runtime_prepare_error); + let prepare_attempt = hls_transient_origin_prepare_closure( + context.app_state, + context.session, + context.access_context, + context.fingerprint, + context.headers, + &runtime_prepare_error, + ); let final_failure = match fetch_and_commit_hls_transient_origin_response_with_attempt_prepare( cache_fetch_request, - move |_attempt| { - let app_state = Arc::clone(&app_state_for_prepare); - let session = Arc::clone(&session_for_prepare); - let access_context = access_context.clone(); - let fingerprint = fingerprint_for_prepare.clone(); - let headers = headers_for_prepare.clone(); - let runtime_prepare_error = Arc::clone(&runtime_prepare_error_for_prepare); - async move { - match prepare_hls_transient_origin_io_for_authorized_resource_work( - &app_state, - &session, - &access_context, - &fingerprint, - &headers, - current_time_millis(), - ) - .await - { - Ok(guard) => Ok(guard), - Err(err) => { - *runtime_prepare_error.lock().await = Some(err); - Err(HlsOriginResourceFetchError::ProviderUnavailable( - HlsBoundAccountAcquireErrorKind::Unavailable, - )) - } - } - } - .boxed() - }, + prepare_attempt, ) .await { @@ -4492,7 +4495,7 @@ async fn create_hls_cache_entry_master_playlist_response( origin_source.stream_ref.clone(), virtual_id, now_ms, - hls_pending_bootstrap_window_ms(), + hls_pending_bootstrap_window_ms(app_state), ) .with_known_bitrate_bps(known_bitrate_bps) .with_archive_playback( @@ -6005,7 +6008,7 @@ async fn try_hls_cache_canonical_manifest_response( } }; let manifest_boundary_rendered_at_ms = handoff_previous_rendered_at_ms.unwrap_or(previous_manifest_rendered_at_ms); - let wait_timeout = hls_manifest_wait_timeout_for_requirement(&session, manifest_commit_requirement).await; + let wait_timeout = hls_manifest_wait_timeout_for_requirement(app_state, &session, manifest_commit_requirement).await; let cached_manifest_options = hls_cached_manifest_options_for_requirement( wait_timeout, manifest_commit_requirement, @@ -6279,15 +6282,22 @@ async fn try_hls_cache_canonical_manifest_response( ) } -fn hls_initial_manifest_decision_wait_timeout() -> Duration { Duration::from_secs(90) } +fn hls_initial_manifest_decision_wait_timeout(app_state: &Arc) -> Duration { + Duration::from_secs(app_state.hls_proxy.initial_manifest_wait_timeout_secs()) +} async fn hls_manifest_wait_timeout_for_requirement( + app_state: &Arc, session: &HlsSessionHandle, requirement: HlsManifestCommitRequirement, ) -> Duration { match requirement { - HlsManifestCommitRequirement::FreshCommitRequired { .. } => hls_initial_manifest_decision_wait_timeout(), - HlsManifestCommitRequirement::CommittedManifestAllowed => hls_initial_manifest_wait_timeout(session).await, + HlsManifestCommitRequirement::FreshCommitRequired { .. } => { + hls_initial_manifest_decision_wait_timeout(app_state) + } + HlsManifestCommitRequirement::CommittedManifestAllowed => { + hls_initial_manifest_wait_timeout(app_state, session).await + } } } @@ -6303,7 +6313,7 @@ async fn touch_initial_manifest_access_lease_window( return; } let wait_timeout_ms = duration_to_millis_saturating(wait_timeout); - let deadline_ms = now_ms.saturating_add(wait_timeout_ms.max(hls_pending_bootstrap_window_ms())); + let deadline_ms = now_ms.saturating_add(wait_timeout_ms.max(hls_pending_bootstrap_window_ms(app_state))); let touch = app_state .hls_proxy .touch_manifest_access_lease( @@ -6329,13 +6339,13 @@ async fn touch_initial_manifest_access_lease_window( ); } -async fn hls_initial_manifest_wait_timeout(session: &HlsSessionHandle) -> Duration { +async fn hls_initial_manifest_wait_timeout(app_state: &Arc, session: &HlsSessionHandle) -> Duration { let session = session.read().await; if matches!( session.account_binding_protection(current_time_millis()), HlsAccountBindingProtection::NoMediaYet | HlsAccountBindingProtection::Expired ) { - hls_initial_manifest_decision_wait_timeout() + hls_initial_manifest_decision_wait_timeout(app_state) } else { Duration::ZERO } @@ -6848,7 +6858,7 @@ async fn try_hls_cached_manifest_response( return None; } let remaining = options.wait_timeout.saturating_sub(started_at.elapsed()); - tokio::time::sleep(remaining.min(Duration::from_millis(25))).await; + tokio::time::sleep(remaining.min(HLS_MANIFEST_WAIT_POLL_INTERVAL)).await; } } @@ -7521,14 +7531,17 @@ async fn hls_api_stream( axum::extract::State(app_state): axum::extract::State>, ) -> impl IntoResponse + Send { let api_proxy_user = create_api_proxy_user(&app_state); - let (user, target) = if params.username == api_proxy_user.username && params.password == api_proxy_user.password { + let (user, target) = if params.username == api_proxy_user.username + && crate::auth::constant_time_eq(params.password.as_bytes(), api_proxy_user.password.as_bytes()) + { let Some(target) = app_state.app_config.get_target_by_id(params.target_id) else { return axum::http::StatusCode::BAD_REQUEST.into_response(); }; (Arc::new(api_proxy_user), target) } else { let Some((user, target)) = app_state.app_config.get_target_for_user(¶ms.username, ¶ms.password) else { - return axum::http::StatusCode::BAD_REQUEST.into_response(); + // Credential failure is an auth error, not a malformed request + return app_state.app_config.get_auth_error_status().into_response(); }; if target.id != params.target_id { return axum::http::StatusCode::BAD_REQUEST.into_response(); @@ -9423,8 +9436,21 @@ mod tests { } #[test] - fn hls_initial_manifest_decision_wait_timeout_is_ninety_seconds() { - assert_eq!(super::hls_initial_manifest_decision_wait_timeout(), Duration::from_secs(90)); + fn hls_custom_video_manifest_body_is_none_for_non_provisioning() { + let user = hls_custom_video_test_user(); + let manifest = build_hls_custom_video_manifest_body( + "https://example.test/iptv/", + &user, + CustomVideoStreamType::UserConnectionsExhausted, + ); + + assert!(manifest.is_none(), "non-provisioning custom video types have no static manifest body"); + } + + #[tokio::test] + async fn hls_initial_manifest_decision_wait_timeout_defaults_to_ninety_seconds() { + let app_state = test_app_state(); + assert_eq!(super::hls_initial_manifest_decision_wait_timeout(&app_state), Duration::from_secs(90)); } #[tokio::test] @@ -16584,7 +16610,7 @@ mod tests { now_ms, None, Some(super::HlsAccessLeasePendingDeadline::Bootstrap { - deadline_ms: now_ms.saturating_add(super::hls_pending_bootstrap_window_ms()), + deadline_ms: now_ms.saturating_add(super::hls_pending_bootstrap_window_ms(&app_state)), }), super::hls_access_lease_ttl_ms(&app_state), ) diff --git a/backend/src/api/endpoints/library_api.rs b/backend/src/api/endpoints/library_api.rs index 87a265b7d..09a98d188 100644 --- a/backend/src/api/endpoints/library_api.rs +++ b/backend/src/api/endpoints/library_api.rs @@ -101,6 +101,10 @@ async fn get_thumbnail( axum::extract::Path(id): axum::extract::Path, headers: axum::http::HeaderMap, ) -> axum::response::Response { + // ids/hashes are hex-like tokens; anything else could traverse the storage path + if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return axum::http::StatusCode::NOT_FOUND.into_response(); + } let config_snapshot = app_state.app_config.config.load(); let Some(library_config) = config_snapshot.library.as_ref().filter(|l| l.enabled) else { return axum::http::StatusCode::NOT_FOUND.into_response(); diff --git a/backend/src/api/endpoints/log_ws_api.rs b/backend/src/api/endpoints/log_ws_api.rs new file mode 100644 index 000000000..577f841aa --- /dev/null +++ b/backend/src/api/endpoints/log_ws_api.rs @@ -0,0 +1,246 @@ +use crate::{ + api::model::AppState, + auth::verify_token, + utils::{get_log_history, subscribe_logs}, +}; +use axum::{ + extract::{ + ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}, + Query, State, + }, + response::IntoResponse, +}; +use log::{trace, warn}; +use serde::Deserialize; +use shared::{ + model::{LogLevel, LogWsMessage, Permission, ROLE_ADMIN, WsCloseCode}, + utils::concat_path_leading_slash, +}; +use std::sync::Arc; + +#[derive(Debug, Deserialize, Default)] +pub struct LogWsQuery { + pub token: Option, + pub min_level: Option, +} + +fn get_secret_key(app_state: &AppState, auth_required: bool) -> Option> { + if !auth_required { + return None; + } + app_state.app_config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()).map(|c| { + let secret_key: &[u8] = c.secret.as_ref(); + secret_key.to_vec() + }) +} + +fn check_token_auth(token: &str, secret_key: Option<&[u8]>) -> bool { + let Some(secret_key) = secret_key else { + return false; + }; + if let Some(token_data) = verify_token(token, secret_key) { + token_data.claims.permissions.contains(Permission::SystemRead) + || token_data.claims.roles.iter().any(|r| r == ROLE_ADMIN) + } else { + false + } +} + +async fn wait_for_socket_auth(socket: &mut WebSocket, secret_key: Option<&[u8]>) -> bool { + let auth_timeout = tokio::time::sleep(tokio::time::Duration::from_secs(10)); + tokio::pin!(auth_timeout); + + loop { + tokio::select! { + () = &mut auth_timeout => { + let _ = socket.send(Message::Close(Some(CloseFrame { + code: WsCloseCode::Protocol.code(), + reason: "Auth timeout".into(), + }))).await; + return false; + } + msg = socket.recv() => { + let Some(Ok(msg)) = msg else { + return false; + }; + match msg { + Message::Text(text) => { + if let Ok(LogWsMessage::Auth(token)) = serde_json::from_str::(&text) { + if check_token_auth(&token, secret_key) { + let auth_ok = serde_json::to_string(&LogWsMessage::Authorized).unwrap_or_default(); + let _ = socket.send(Message::Text(auth_ok.into())).await; + return true; + } + let auth_fail = serde_json::to_string(&LogWsMessage::Unauthorized).unwrap_or_default(); + let _ = socket.send(Message::Text(auth_fail.into())).await; + let _ = socket.send(Message::Close(Some(CloseFrame { + code: 1008, // Policy Violation + reason: "Unauthorized".into(), + }))).await; + return false; + } + } + Message::Ping(p) => { + let _ = socket.send(Message::Pong(p)).await; + } + Message::Close(_) => return false, + _ => {} + } + } + } + } +} + +async fn handle_socket( + mut socket: WebSocket, + app_state: Arc, + auth_required: bool, + query: LogWsQuery, +) { + let secret_key = get_secret_key(&app_state, auth_required); + let mut is_authorized = !auth_required; + let mut min_level: Option = query.min_level.as_deref().and_then(|s| s.parse().ok()); + + if !is_authorized { + if let Some(token) = query.token.as_deref() { + if check_token_auth(token, secret_key.as_deref()) { + is_authorized = true; + } + } + } + + if !is_authorized && !wait_for_socket_auth(&mut socket, secret_key.as_deref()).await { + return; + } + + // Send history + let history = get_log_history(); + let filtered_history: Vec<_> = history + .into_iter() + .filter(|e| min_level.is_none_or(|lvl| e.level.matches(lvl))) + .collect(); + + if let Ok(history_json) = serde_json::to_string(&LogWsMessage::History(filtered_history)) { + if socket.send(Message::Text(history_json.into())).await.is_err() { + return; + } + } + + let mut rx = subscribe_logs(); + + loop { + tokio::select! { + broadcast_res = rx.recv() => { + match broadcast_res { + Ok(entry) => { + if min_level.is_none_or(|lvl| entry.level.matches(lvl)) { + if let Ok(entry_json) = serde_json::to_string(&LogWsMessage::Entry(entry)) { + if socket.send(Message::Text(entry_json.into())).await.is_err() { + break; + } + } + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + warn!("Log stream client lagged, dropped {skipped} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + } + } + msg = socket.recv() => { + let Some(msg) = msg else { + break; + }; + match msg { + Ok(Message::Text(text)) => { + if let Ok(LogWsMessage::Filter { min_level: new_level }) = serde_json::from_str::(&text) { + min_level = new_level; + } + } + Ok(Message::Ping(payload)) => { + if socket.send(Message::Pong(payload)).await.is_err() { + break; + } + } + Ok(Message::Close(_)) | Err(_) => break, + _ => {} + } + } + } + } +} + +async fn log_websocket_handler( + Query(query): Query, + State(app_state): State>, + ws: WebSocketUpgrade, +) -> impl IntoResponse { + trace!("Log Websocket connected (no auth)"); + ws.on_upgrade(move |socket| handle_socket(socket, app_state, false, query)) +} + +async fn log_websocket_handler_auth( + Query(query): Query, + State(app_state): State>, + ws: WebSocketUpgrade, +) -> impl IntoResponse { + trace!("Log Websocket connected (auth required)"); + ws.on_upgrade(move |socket| handle_socket(socket, app_state, true, query)) +} + +pub fn log_ws_api_register(web_auth_enabled: bool, web_ui_path: &str) -> axum::Router> { + let path = concat_path_leading_slash(web_ui_path, "ws/logs"); + if web_auth_enabled { + axum::Router::new().route(&path, axum::routing::get(log_websocket_handler_auth)) + } else { + axum::Router::new().route(&path, axum::routing::get(log_websocket_handler)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::permission::PermissionSet; + use crate::model::WebAuthConfig; + use crate::auth::{create_jwt_admin, create_jwt_web_user}; + + #[test] + fn test_log_ws_query_parsing() { + let query: LogWsQuery = serde_html_form::from_str("token=secret123&min_level=warn").unwrap(); + assert_eq!(query.token.as_deref(), Some("secret123")); + assert_eq!(query.min_level.as_deref(), Some("warn")); + } + + #[test] + fn test_check_token_auth_permissions() { + let auth_config = WebAuthConfig { + enabled: true, + issuer: "tuliprox".to_string(), + secret: "01234567890123456789012345678901".to_string(), + token_ttl_mins: 60, + userfile: Some("user.txt".to_string()), + groupfile: None, + t_users: None, + t_groups: None, + }; + let secret = auth_config.secret.as_bytes(); + + let admin_token = create_jwt_admin(&auth_config, "admin", 0).unwrap(); + assert!(check_token_auth(&admin_token, Some(secret))); + + let mut perms = PermissionSet::new(); + perms.set(Permission::SystemRead); + let user_token = create_jwt_web_user(&auth_config, "user", perms, 0).unwrap(); + assert!(check_token_auth(&user_token, Some(secret))); + + // Missing system read + let empty_perms = PermissionSet::new(); + let token_no_perm = create_jwt_web_user(&auth_config, "user2", empty_perms, 0).unwrap(); + assert!(!check_token_auth(&token_no_perm, Some(secret))); + + // Invalid secret + assert!(!check_token_auth(&admin_token, Some(b"wrongsecretwrongsecretwrongsecret"))); + } +} diff --git a/backend/src/api/endpoints/m3u_api.rs b/backend/src/api/endpoints/m3u_api.rs index 50bb34b0d..bb5ff91d9 100644 --- a/backend/src/api/endpoints/m3u_api.rs +++ b/backend/src/api/endpoints/m3u_api.rs @@ -159,7 +159,9 @@ pub(in crate::api) async fn m3u_api_stream_loaded( let is_hls_manifest_request = effective_playback_extension(pli.item_type, &pli.url, stream_ext) == Some(HLS_EXT); - if !user.allows_item_type(pli.item_type) { + if !user.allows_item_type(pli.item_type) + || !(user.t_filter.is_none() || user.allows_content(&shared::model::PlaylistItem::from(&pli))) + { if is_hls_manifest_request { return hls_custom_video_manifest_response( app_state, @@ -898,7 +900,9 @@ async fn m3u_api_resource( } }; - if !user.allows_item_type(m3u_item.item_type) { + if !user.allows_item_type(m3u_item.item_type) + || !(user.t_filter.is_none() || user.allows_content(&shared::model::PlaylistItem::from(&m3u_item))) + { return axum::http::StatusCode::NOT_FOUND.into_response(); } diff --git a/backend/src/api/endpoints/mod.rs b/backend/src/api/endpoints/mod.rs index 08d66ab4c..112f38f74 100644 --- a/backend/src/api/endpoints/mod.rs +++ b/backend/src/api/endpoints/mod.rs @@ -6,14 +6,18 @@ pub(in crate::api) mod hdhomerun_api; pub(in crate::api) mod hls_api; mod hls_terminal_response; mod library_api; +pub(in crate::api) mod log_ws_api; pub(in crate::api) mod m3u_api; pub(in crate::api) mod provider_resolve_api; mod rbac_api; +pub(in crate::api) mod recording_api; +pub(in crate::api) mod recording_media_api; mod stream_history_api; mod user_api; +mod user_visibility; pub(in crate::api) mod v1_api; mod v1_api_config; -mod v1_api_playlist; +pub(in crate::api) mod v1_api_playlist; mod v1_api_user; pub(in crate::api) mod web_index; pub(in crate::api) mod websocket_api; diff --git a/backend/src/api/endpoints/provider_resolve_api.rs b/backend/src/api/endpoints/provider_resolve_api.rs index 4d83fdb4c..0a527e68a 100644 --- a/backend/src/api/endpoints/provider_resolve_api.rs +++ b/backend/src/api/endpoints/provider_resolve_api.rs @@ -103,6 +103,8 @@ struct ProviderResolveLoadedItem { cluster: XtreamCluster, url: Arc, input_name: Arc, + /// Whether the user's compiled content filter permits this item. + content_allowed: bool, } async fn load_provider_resolve_item( @@ -111,6 +113,7 @@ async fn load_provider_resolve_item( decoded_cluster: XtreamCluster, app_state: &Arc, target: &ConfigTarget, + user: &ProxyUserCredentials, ) -> Result { match output_kind { ProviderResolveOutputKind::Xtream => xtream_get_item_for_stream_id( @@ -124,6 +127,8 @@ async fn load_provider_resolve_item( virtual_id: item.virtual_id, item_type: item.item_type, cluster: item.xtream_cluster, + content_allowed: user.t_filter.is_none() + || user.allows_content(&shared::model::PlaylistItem::from(&item)), url: item.url, input_name: item.input_name, }) @@ -142,6 +147,8 @@ async fn load_provider_resolve_item( virtual_id: item.virtual_id, item_type: item.item_type, cluster: decoded_cluster, + content_allowed: user.t_filter.is_none() + || user.allows_content(&shared::model::PlaylistItem::from(&item)), url: item.url, input_name: item.input_name, }) @@ -185,14 +192,14 @@ async fn provider_resolve( return axum::http::StatusCode::BAD_REQUEST.into_response(); }; - let item = match load_provider_resolve_item(output_kind, decoded.virtual_id, decoded.cluster, &app_state, &target).await { + let item = match load_provider_resolve_item(output_kind, decoded.virtual_id, decoded.cluster, &app_state, &target, &user).await { Ok(item) => item, Err(err) => { debug!("Provider resolve item lookup failed: {err}"); return axum::http::StatusCode::NOT_FOUND.into_response(); } }; - if !user.allows_item_type(item.item_type) { + if !user.allows_item_type(item.item_type) || !item.content_allowed { return axum::http::StatusCode::FORBIDDEN.into_response(); } let Some(input) = app_state.app_config.get_input_by_name(&item.input_name) else { diff --git a/backend/src/api/endpoints/recording_api.rs b/backend/src/api/endpoints/recording_api.rs new file mode 100644 index 000000000..12ae2a17b --- /dev/null +++ b/backend/src/api/endpoints/recording_api.rs @@ -0,0 +1,1325 @@ +//! Recording REST routes. + +use std::sync::Arc; + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json, Router}; +use serde::{Deserialize, Serialize}; + +use crate::api::endpoints::recording_media_api::AuthClaims; +use crate::api::model::recording_service::{ + CreateRecordingInput, EditRecordingPatch, RecordingService, RecordingSourceInput, ServiceError, +}; +use crate::api::model::recording_rule_service::{DeleteFuture, RuleServiceError}; +use crate::api::model::{event_manager::EventMessage, recording_quota, recording_ws, FileDownload}; +use crate::api::model::AppState; +use crate::repository::recording_rule_repository::RecordingRuleRepository; +use shared::model::{ + recording::{RecordingProvenance, RecordingVisibility}, + recording_rule::{RecordingRule, RuleBody, RuleSource, RuleVisibility}, + FileDownloadDto, Permission, UserId, XtreamCluster, ROLE_ADMIN, +}; +use axum::routing::{get, patch, post}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: &'static str, +} + +fn error_response(status: StatusCode, error: &'static str) -> axum::response::Response { + (status, Json(ErrorResponse { error })).into_response() +} + +fn service_error_response(err: &ServiceError) -> axum::response::Response { + error_response(service_error_status(err), err.code()) +} + +/// HTTP status for a service error. The wire code always comes from +/// `ServiceError::code`, so it is not duplicated here. +fn service_error_status(err: &ServiceError) -> StatusCode { + match err { + ServiceError::UnknownOwner => StatusCode::UNAUTHORIZED, + ServiceError::InvalidSource + | ServiceError::InvalidInterval + | ServiceError::PaddingLimitExceeded + | ServiceError::InvalidState + | ServiceError::ProvenanceImmutable + | ServiceError::Duplicate + | ServiceError::InvalidPath + | ServiceError::QuotaExceeded => StatusCode::BAD_REQUEST, + ServiceError::DiskFull => StatusCode::INSUFFICIENT_STORAGE, + ServiceError::Forbidden | ServiceError::SharedCreationNotAdministrator => StatusCode::FORBIDDEN, + ServiceError::UnknownRecording => StatusCode::NOT_FOUND, + ServiceError::PersistenceFailed | ServiceError::IoError(_) => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +/// GET /api/v1/recording/tasks +pub async fn list_recording_tasks( + axum::extract::Query(params): axum::extract::Query, + State(app_state): State>, + AuthClaims(claims): AuthClaims, +) -> impl IntoResponse { + if !claims.permissions.contains(Permission::RecordingRead) { + return error_response(StatusCode::FORBIDDEN, "recording_forbidden"); + } + // Filtering by an arbitrary owner is an administrator capability. + // The visibility filter below already prevents a regular user from + // *seeing* another owner's private tasks, but accepting the + // parameter and silently returning an empty list made the API read + // as if cross-owner queries were supported. Reject it explicitly. + if params.owner.is_some() && !is_admin(&claims) { + return error_response(StatusCode::FORBIDDEN, "recording_forbidden"); + } + let (revision, mut tasks) = recording_ws::recording_snapshot(&app_state.downloads, &claims).await; + if let Some(owner) = params.owner.as_deref() { + tasks.retain(|task| { + task.recording + .as_ref() + .and_then(|recording| recording.owner_id.as_ref()) + .is_some_and(|id| id.0 == owner) + }); + } + if let Some(visibility) = params.visibility.as_deref() { + tasks.retain(|task| { + task.recording.as_ref().is_some_and(|recording| { + matches!( + (visibility, recording.visibility), + ("private", RecordingVisibility::Private) | ("shared", RecordingVisibility::Shared) + ) + }) + }); + } + Json(RecordingSnapshotResponse { revision: revision.0, tasks }).into_response() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ListTasksParams { + #[serde(default)] + pub owner: Option, + #[serde(default)] + pub visibility: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordingSnapshotResponse { + pub revision: u64, + pub tasks: Vec, +} + +/// POST /api/v1/recording/tasks +pub async fn create_recording_task( + State(app_state): State>, + AuthClaims(claims): AuthClaims, + Json(body): Json, +) -> impl IntoResponse { + let mut source = body.source; + if !resolve_recording_source( + &app_state, + &source.target_id, + &mut source.virtual_id, + &mut source.input_name, + source.cluster, + ) + .await + { + return service_error_response(&ServiceError::InvalidSource); + } + let service = RecordingService::new(app_state.downloads.clone(), app_state.app_config.clone()); + let input = CreateRecordingInput { + source: RecordingSourceInput { + target_id: source.target_id, + virtual_id: source.virtual_id, + cluster: source.cluster, + input_name: source.input_name, + }, + program_title: body.program_title, + program_start: body.program_start, + program_end: body.program_end, + pre_roll_secs: body.pre_roll_secs, + post_roll_secs: body.post_roll_secs, + visibility: body.visibility, + channel_id: body.channel_id, + channel_name: body.channel_name, + provenance: RecordingProvenance::default(), + epg: body.epg, + }; + match service.create_recording(&claims, &input).await { + Ok(view) => { + let _ = app_state.event_manager.send_event(EventMessage::RecordingChanged); + Json(CreateRecordingTaskResponse { + id: view.uuid, + title: view.filename_preview, + recording: None, + }) + .into_response() + } + Err(err) => service_error_response(&err), + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateRecordingTaskBody { + pub source: CreateRecordingSourceBody, + pub program_title: String, + pub program_start: i64, + pub program_end: i64, + pub pre_roll_secs: u64, + pub post_roll_secs: u64, + pub visibility: RecordingVisibility, + #[serde(default)] + pub channel_id: Option, + #[serde(default)] + pub channel_name: Option, + #[serde(default)] + pub epg: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateRecordingSourceBody { + pub target_id: String, + pub virtual_id: String, + pub cluster: XtreamCluster, + pub input_name: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CreateRecordingTaskResponse { + pub id: String, + pub title: String, + pub recording: Option, +} + +/// PATCH /api/v1/recording/tasks/{id} +pub async fn edit_recording_task( + axum::extract::Path(id): axum::extract::Path, + State(app_state): State>, + AuthClaims(claims): AuthClaims, + Json(body): Json, +) -> impl IntoResponse { + let service = RecordingService::new(app_state.downloads.clone(), app_state.app_config.clone()); + match service.edit_recording(&claims, &id, body.into()).await { + Ok(_view) => { + let _ = app_state.event_manager.send_event(EventMessage::RecordingChanged); + StatusCode::NO_CONTENT.into_response() + } + Err(err) => service_error_response(&err), + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct EditRecordingTaskBody { + #[serde(default)] + pub program_start: Option, + #[serde(default)] + pub program_end: Option, + #[serde(default)] + pub pre_roll_secs: Option, + #[serde(default)] + pub post_roll_secs: Option, + #[serde(default)] + pub program_title: Option, + #[serde(default)] + pub channel_id: Option, + #[serde(default)] + pub channel_name: Option, +} + +impl From for EditRecordingPatch { + fn from(value: EditRecordingTaskBody) -> Self { + Self { + program_start: value.program_start, + program_end: value.program_end, + pre_roll_secs: value.pre_roll_secs, + post_roll_secs: value.post_roll_secs, + program_title: value.program_title, + channel_id: value.channel_id, + channel_name: value.channel_name, + } + } +} + +/// POST /api/v1/recording/tasks/{id}/cancel +pub async fn cancel_recording_task( + axum::extract::Path(id): axum::extract::Path, + State(app_state): State>, + AuthClaims(claims): AuthClaims, +) -> impl IntoResponse { + let service = RecordingService::new(app_state.downloads.clone(), app_state.app_config.clone()); + match service.cancel_recording(&claims, &id).await { + Ok(()) => { + let _ = app_state.event_manager.send_event(EventMessage::RecordingChanged); + StatusCode::NO_CONTENT.into_response() + } + Err(err) => service_error_response(&err), + } +} + +/// DELETE /api/v1/recording/tasks/{id} +pub async fn delete_recording_task( + axum::extract::Path(id): axum::extract::Path, + State(app_state): State>, + AuthClaims(claims): AuthClaims, +) -> impl IntoResponse { + let service = RecordingService::new(app_state.downloads.clone(), app_state.app_config.clone()); + match service.delete_recording(&claims, &id).await { + Ok(()) => { + let _ = app_state.event_manager.send_event(EventMessage::RecordingChanged); + StatusCode::NO_CONTENT.into_response() + } + Err(err) => service_error_response(&err), + } +} + +/// POST /api/v1/recording/conflicts/preview +/// +/// Errors use the same `{"error": ""}` envelope as every other +/// recording route. It used to return a bare status/string pair, so a +/// client could not map a preview failure through the shared error +/// handling the rest of the API uses. +pub async fn preview_recording_conflicts( + AuthClaims(claims): AuthClaims, + axum::extract::State(state): axum::extract::State>, + axum::extract::Json(body): axum::extract::Json, +) -> axum::response::Response { + let source = RecordingSourceInput::from(&body.source); + let request = crate::api::model::recording_service::ConflictPreviewRequest { + source, + padded_start: body.candidate.padded_start, + padded_end: body.candidate.padded_end, + pre_roll_secs: body.candidate.pre_roll_secs, + post_roll_secs: body.candidate.post_roll_secs, + priority: body.candidate.priority, + }; + let service = crate::api::model::recording_service::RecordingService::from_app_state(&state); + let preview = match service.preview_conflicts(&claims, &request).await { + Ok(preview) => preview, + Err(err) => return service_error_response(&err), + }; + Json(PreviewConflictsResponse { + severity: preview.severity.as_wire().to_string(), + provider_scope: preview.provider_scope, + overlap_segments: preview + .overlap_segments + .into_iter() + .map(|s| OverlapSegmentDto { start: s.start, end: s.end, peak_demand: s.peak_demand }) + .collect(), + }) + .into_response() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PreviewConflictsBody { + /// Server-owned source identifiers. The caller never submits + /// another recording's padded interval, capacity, or provider + /// identifier — those are derived server-side. + pub source: PreviewSourceDto, + pub candidate: PreviewCandidateDto, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PreviewSourceDto { + pub target_name: String, + pub virtual_id: String, + pub input_name: String, +} + +impl From<&PreviewSourceDto> for RecordingSourceInput { + /// `PreviewConflictsBody::source` is the only DTO that carries a + /// source across the preview endpoint, so the field mapping lives + /// here rather than at the call site: adding a field to + /// `RecordingSourceInput` then fails the compile in exactly one + /// place, and the cluster choice stays consistent with whatever the + /// preview service decides to support in the future. + /// + /// `XtreamCluster::Live` is the only cluster the preview surface + /// currently accepts; widening it is a deliberate, single-site + /// change rather than a quiet drift in the handler. + fn from(value: &PreviewSourceDto) -> Self { + Self { + target_id: value.target_name.clone(), + virtual_id: value.virtual_id.clone(), + cluster: shared::model::XtreamCluster::Live, + input_name: value.input_name.clone(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct PreviewCandidateDto { + pub padded_start: i64, + pub padded_end: i64, + #[serde(default)] + pub pre_roll_secs: u64, + #[serde(default)] + pub post_roll_secs: u64, + #[serde(default)] + pub priority: i32, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PreviewConflictsResponse { + pub severity: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_scope: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub overlap_segments: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct OverlapSegmentDto { + pub start: i64, + pub end: i64, + pub peak_demand: u32, +} + +/// GET /api/v1/recording/quota +pub async fn get_recording_quota( + State(app_state): State>, + AuthClaims(claims): AuthClaims, +) -> impl IntoResponse { + if !claims.permissions.contains(Permission::RecordingRead) { + return error_response(StatusCode::FORBIDDEN, "recording_forbidden"); + } + let Some(subject_id) = claims.subject_id.as_ref() else { + return error_response(StatusCode::UNAUTHORIZED, "recording_token_refresh_required"); + }; + let tasks = all_recording_tasks(&app_state).await; + let totals = recording_quota::compute_totals(&tasks); + let config = app_state.app_config.config.load(); + let limits = quota_limits_from_config(config + .video + .as_ref() + .and_then(|v| v.download.as_ref()) + .and_then(|d| d.recording.as_ref()) + .and_then(|r| r.quota.as_ref())); + let quota = recording_quota::regular_user_dto(subject_id, &totals, &limits, &tasks); + Json(RecordingQuotaResponse { + private_used_bytes: quota.private.measured_bytes.saturating_add(quota.private.reserved_bytes), + private_limit_bytes: quota.private.limit_bytes, + shared_used_bytes: quota.shared.used_bytes, + shared_limit_bytes: quota.shared.limit_bytes, + revision: app_state.downloads.revision.load(std::sync::atomic::Ordering::SeqCst), + }) + .into_response() +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordingQuotaResponse { + pub private_used_bytes: u64, + pub private_limit_bytes: Option, + pub shared_used_bytes: u64, + pub shared_limit_bytes: Option, + pub revision: u64, +} + +/// GET /api/v1/recording/health +/// +/// Liveness of the DVR supervisors. Administrator-only: the tick +/// timestamps and the outbox depth describe server internals, not the +/// caller's recordings. +/// +/// A supervisor whose `last_*` field is `null` has never completed a +/// pass. Combined with the configured interval, an operator can tell a +/// healthy supervisor from one that died without reading the log. +pub async fn get_recording_health( + State(app_state): State>, + AuthClaims(claims): AuthClaims, +) -> impl IntoResponse { + if !is_admin(&claims) { + return error_response(StatusCode::FORBIDDEN, "recording_forbidden"); + } + let health = crate::api::model::recording::recording_supervisor::supervisor_health(); + let config = app_state.app_config.config.load(); + let recording = config + .video + .as_ref() + .and_then(|video| video.download.as_ref()) + .and_then(|download| download.recording.as_ref()); + Json(RecordingHealthResponse { + enabled: recording.is_none_or(|cfg| cfg.enabled), + server_time: chrono::Utc::now().timestamp(), + reconciliation_last_run: health.reconciliation_last_run(), + retention_last_tick: health.retention_last_tick(), + retention_sweep_interval_secs: recording + .and_then(|cfg| cfg.retention.as_ref().map(|r| r.sweep_interval_secs)), + notification_last_drain: health.notification_last_drain(), + notification_outbox_depth: health.notification_outbox_depth(), + notification_dead_lettered: health.notification_dead_lettered(), + queue_revision: app_state.downloads.revision.load(std::sync::atomic::Ordering::SeqCst), + }) + .into_response() +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordingHealthResponse { + pub enabled: bool, + pub server_time: i64, + pub reconciliation_last_run: Option, + pub retention_last_tick: Option, + pub retention_sweep_interval_secs: Option, + pub notification_last_drain: Option, + pub notification_outbox_depth: i64, + pub notification_dead_lettered: i64, + pub queue_revision: u64, +} + +fn rule_error_response(err: &crate::api::model::recording_rule_service::RuleServiceError) -> axum::response::Response { + use crate::api::model::recording_rule_service::RuleServiceError; + let status = match err { + RuleServiceError::Forbidden + | RuleServiceError::SharedManagementNotAdministrator + | RuleServiceError::NotOwner => StatusCode::FORBIDDEN, + RuleServiceError::InvalidRule + | RuleServiceError::InvalidFuture + | RuleServiceError::Unsupported { .. } => StatusCode::BAD_REQUEST, + RuleServiceError::UnknownRule => StatusCode::NOT_FOUND, + RuleServiceError::PersistenceFailed | RuleServiceError::PartialOperation { .. } => StatusCode::INTERNAL_SERVER_ERROR, + }; + error_response(status, err.code()) +} + +fn recording_rule_repo(app_state: &AppState) -> RecordingRuleRepository { + RecordingRuleRepository::new(&app_state.app_config.config.load().storage_dir) +} + +fn can_write_rules(claims: &shared::model::Claims) -> bool { + claims.permissions.contains(Permission::RecordingWrite) +} + +fn is_admin(claims: &shared::model::Claims) -> bool { + claims.roles.iter().any(|role| role == ROLE_ADMIN) +} + +fn quota_limits_from_config(config: Option<&crate::model::RecordingQuotaConfig>) -> recording_quota::QuotaLimits { + let mut per_user_bytes = std::collections::HashMap::new(); + if let Some(config) = config { + for (user_id, bytes) in &config.per_user_bytes { + per_user_bytes.insert(UserId::from(user_id.clone()), *bytes); + } + recording_quota::QuotaLimits { + default_private_bytes: config.default_private_bytes, + per_user_bytes, + shared_bytes: config.shared_bytes, + } + } else { + recording_quota::QuotaLimits::default() + } +} + +async fn all_recording_tasks(app_state: &AppState) -> Vec { + let mut tasks = Vec::new(); + let q = app_state.downloads.queue.lock().await; + tasks.extend(q.iter().filter(|d| d.recording.is_some()).cloned()); + drop(q); + let s = app_state.downloads.scheduled.read().await; + tasks.extend(s.iter().filter(|d| d.recording.is_some()).cloned()); + drop(s); + let a = app_state.downloads.active.read().await; + tasks.extend(a.iter().filter(|d| d.recording.is_some()).cloned()); + drop(a); + let f = app_state.downloads.finished.read().await; + tasks.extend(f.iter().filter(|d| d.recording.is_some()).cloned()); + tasks +} + +async fn resolve_recording_source( + app_state: &Arc, + target_name: &str, + virtual_id: &mut String, + input_name: &mut String, + cluster: XtreamCluster, +) -> bool { + if recording_virtual_id(virtual_id).is_none() { + if cluster != XtreamCluster::Live { + return false; + } + let Some(resolved) = + crate::api::endpoints::v1_api_playlist::resolve_target_live_recording_source_by_epg_channel( + &app_state.app_config, + target_name, + virtual_id, + ) + .await + else { + return false; + }; + if !accept_resolved_recording_source(virtual_id, input_name, resolved) { + return false; + } + } + let Some(virtual_id_value) = recording_virtual_id(virtual_id) else { + return false; + }; + let Some(resolved) = crate::api::endpoints::v1_api_playlist::resolve_target_recording_source( + &app_state.app_config, + target_name, + input_name, + virtual_id_value, + cluster, + ) + .await + else { + return false; + }; + accept_resolved_recording_source(virtual_id, input_name, resolved) +} + +/// GET /api/v1/recording/rules +pub async fn list_recording_rules( + State(app_state): State>, + AuthClaims(claims): AuthClaims, +) -> impl IntoResponse { + if !claims.permissions.contains(Permission::RecordingRead) { + return error_response(StatusCode::FORBIDDEN, "recording_forbidden"); + } + let Some(subject_id) = claims.subject_id.as_ref() else { + return error_response(StatusCode::UNAUTHORIZED, "recording_token_refresh_required"); + }; + let Ok(rules) = recording_rule_repo(&app_state).list().await else { + return error_response(StatusCode::INTERNAL_SERVER_ERROR, "recording_persistence_failed"); + }; + let admin = is_admin(&claims); + let revision = app_state.downloads.revision.load(std::sync::atomic::Ordering::SeqCst); + Json( + rules + .into_iter() + .filter(|rule| rule.visibility == RuleVisibility::Shared || admin || &rule.owner_id == subject_id) + .map(|rule| RecordingRuleResponse { revision, rule }) + .collect::>(), + ) + .into_response() +} + +#[derive(Debug, Clone, Serialize)] +pub struct RecordingRuleResponse { + /// Latest queue revision observed when the response was assembled. + /// The frontend uses this to detect stale snapshots when polling. + pub revision: u64, + #[serde(flatten)] + pub rule: RecordingRule, +} + +/// POST /api/v1/recording/rules +pub async fn create_recording_rule( + State(app_state): State>, + AuthClaims(claims): AuthClaims, + Json(mut body): Json, +) -> impl IntoResponse { + let Some(owner_id) = claims.subject_id.clone() else { + return error_response(StatusCode::UNAUTHORIZED, "recording_token_refresh_required"); + }; + if !resolve_recording_source( + &app_state, + &body.target_id, + &mut body.virtual_id, + &mut body.input_name, + XtreamCluster::Live, + ) + .await + { + return rule_error_response(&RuleServiceError::InvalidRule); + } + let now = chrono::Utc::now().timestamp(); + let rule = recording_rule_from_create(owner_id, body, now); + if let Err(err) = crate::api::model::recording_rule_service::validate_rule(&rule) + .and_then(|()| { + crate::api::model::recording_rule_service::authorize_rule_action( + can_write_rules(&claims), + is_admin(&claims), + &rule.owner_id, + &rule, + ) + }) + { + return rule_error_response(&err); + } + match recording_rule_repo(&app_state).create(rule).await { + Ok(rule) => { + let _ = app_state.event_manager.send_event(EventMessage::RecordingRulesChanged); + Json(RecordingRuleResponse { + revision: app_state.downloads.revision.load(std::sync::atomic::Ordering::SeqCst), + rule, + }) + .into_response() + } + Err(_) => error_response(StatusCode::INTERNAL_SERVER_ERROR, "recording_persistence_failed"), + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CreateRecordingRuleBody { + pub target_id: String, + pub virtual_id: String, + pub input_name: String, + pub body: RuleBody, + #[serde(default)] + pub visibility: Option, + #[serde(default)] + pub channel_id: Option, + #[serde(default)] + pub pre_roll_secs: u64, + #[serde(default)] + pub post_roll_secs: u64, +} + +fn recording_rule_from_create(owner_id: UserId, body: CreateRecordingRuleBody, now: i64) -> RecordingRule { + RecordingRule { + id: format!("rule-{}-{}", now, shared::utils::generate_random_string(8)), + owner_id, + visibility: body.visibility.unwrap_or_default(), + enabled: true, + source: RuleSource::new(body.target_id, body.virtual_id, body.input_name), + channel_id: body.channel_id, + body: body.body, + pre_roll_secs: body.pre_roll_secs, + post_roll_secs: body.post_roll_secs, + created_at: now, + updated_at: now, + } +} + +/// PATCH /api/v1/recording/rules/{id} +pub async fn edit_recording_rule( + axum::extract::Path(id): axum::extract::Path, + State(app_state): State>, + AuthClaims(claims): AuthClaims, + Json(body): Json, +) -> impl IntoResponse { + if claims.subject_id.is_none() { + return error_response(StatusCode::UNAUTHORIZED, "recording_token_refresh_required"); + } + let repo = recording_rule_repo(&app_state); + let mut rule = match repo.load().await { + Ok(file) => match file.rules.into_iter().find(|rule| rule.id == id) { + Some(rule) => rule, + None => return rule_error_response(&crate::api::model::recording_rule_service::RuleServiceError::UnknownRule), + }, + Err(_) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, "recording_persistence_failed"), + }; + if let Err(err) = authorize_and_apply_recording_rule_edit(&claims, &mut rule, body, chrono::Utc::now().timestamp()) { + return match err { + EditRuleError::MissingSubject => { + error_response(StatusCode::UNAUTHORIZED, "recording_token_refresh_required") + } + EditRuleError::Rule(err) => rule_error_response(&err), + }; + } + match repo.update(rule).await { + Ok(Some(rule)) => { + let _ = app_state.event_manager.send_event(EventMessage::RecordingRulesChanged); + Json(RecordingRuleResponse { + revision: app_state.downloads.revision.load(std::sync::atomic::Ordering::SeqCst), + rule, + }) + .into_response() + } + Ok(None) => rule_error_response(&crate::api::model::recording_rule_service::RuleServiceError::UnknownRule), + Err(_) => error_response(StatusCode::INTERNAL_SERVER_ERROR, "recording_persistence_failed"), + } +} + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct EditRecordingRuleBody { + #[serde(default)] + pub body: Option, + #[serde(default)] + pub visibility: Option, + #[serde(default)] + pub enabled: Option, + #[serde(default)] + pub channel_id: Option, + #[serde(default)] + pub clear_channel_id: bool, + #[serde(default)] + pub pre_roll_secs: Option, + #[serde(default)] + pub post_roll_secs: Option, +} + +#[derive(Debug, PartialEq, Eq)] +enum EditRuleError { + MissingSubject, + Rule(RuleServiceError), +} + +fn authorize_and_apply_recording_rule_edit( + claims: &shared::model::Claims, + rule: &mut RecordingRule, + body: EditRecordingRuleBody, + now: i64, +) -> Result<(), EditRuleError> { + let subject_id = claims.subject_id.as_ref().ok_or(EditRuleError::MissingSubject)?; + crate::api::model::recording_rule_service::authorize_rule_action( + can_write_rules(claims), + is_admin(claims), + subject_id, + rule, + ) + .map_err(EditRuleError::Rule)?; + apply_recording_rule_edit(rule, body, now); + crate::api::model::recording_rule_service::validate_rule(rule).map_err(EditRuleError::Rule)?; + crate::api::model::recording_rule_service::authorize_rule_action( + can_write_rules(claims), + is_admin(claims), + subject_id, + rule, + ) + .map_err(EditRuleError::Rule) +} + +fn apply_recording_rule_edit(rule: &mut RecordingRule, body: EditRecordingRuleBody, now: i64) { + if body.clear_channel_id { + rule.channel_id = None; + } else if let Some(channel_id) = body.channel_id { + rule.channel_id = Some(channel_id); + } + if let Some(pre_roll_secs) = body.pre_roll_secs { + rule.pre_roll_secs = pre_roll_secs; + } + if let Some(post_roll_secs) = body.post_roll_secs { + rule.post_roll_secs = post_roll_secs; + } + if let Some(visibility) = body.visibility { + rule.visibility = visibility; + } + if let Some(enabled) = body.enabled { + rule.enabled = enabled; + } + if let Some(rule_body) = body.body { + rule.body = rule_body; + } + rule.updated_at = now; +} + +fn recording_virtual_id(virtual_id: &str) -> Option { virtual_id.parse::().ok() } + +fn accept_resolved_recording_source( + virtual_id: &mut String, + input_name: &mut String, + resolved: crate::api::endpoints::v1_api_playlist::ResolvedRecordingSource, +) -> bool { + if !input_name.trim().is_empty() && input_name != &resolved.input_name { + return false; + } + *virtual_id = resolved.virtual_id.to_string(); + *input_name = resolved.input_name; + true +} + +/// DELETE /api/v1/recording/rules/{id} +#[derive(Debug, Clone, Deserialize)] +pub struct DeleteRuleParams { + #[serde(default)] + pub future: Option, +} + +pub async fn delete_recording_rule( + axum::extract::Path(id): axum::extract::Path, + axum::extract::Query(params): axum::extract::Query, + State(app_state): State>, + AuthClaims(claims): AuthClaims, +) -> impl IntoResponse { + let future = match crate::api::model::recording_rule_service::validate_delete(params.future.as_deref()) { + Ok(future) => future, + Err(err) => return rule_error_response(&err), + }; + let repo = recording_rule_repo(&app_state); + let rule = match repo.load().await { + Ok(file) => match file.rules.into_iter().find(|rule| rule.id == id) { + Some(rule) => rule, + None => return rule_error_response(&crate::api::model::recording_rule_service::RuleServiceError::UnknownRule), + }, + Err(_) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, "recording_persistence_failed"), + }; + let Some(subject_id) = claims.subject_id.as_ref() else { + return error_response(StatusCode::UNAUTHORIZED, "recording_token_refresh_required"); + }; + if let Err(err) = crate::api::model::recording_rule_service::authorize_rule_action( + can_write_rules(&claims), + is_admin(&claims), + subject_id, + &rule, + ) { + return rule_error_response(&err); + } + // Deleting a rule with `future=cancel` writes to two stores: the + // queue (cancel the upcoming occurrences) and the rule repository + // (drop the rule). They cannot commit together, so the queue side + // hands back everything it cancelled and this handler replays it if + // the rule store then fails. Without the compensation the operator + // was left with the rule still present and its upcoming recordings + // silently gone. + let service = RecordingService::new(app_state.downloads.clone(), app_state.app_config.clone()); + let mut cancelled = Vec::new(); + if future == DeleteFuture::Cancel { + match service + .cancel_future_rule_recordings(&claims, &id, chrono::Utc::now().timestamp()) + .await + { + Ok(tasks) => cancelled = tasks, + Err(_) => { + return rule_error_response(&RuleServiceError::PartialOperation { + primary: "rule_retained".to_string(), + secondary: "future_cancel_failed".to_string(), + }); + } + } + } + match repo.delete(&id).await { + Ok(true) => { + // Cancelling future rule recordings mutates the queue, so the + // frontend needs both the rules change and a snapshot refresh. + let _ = app_state.event_manager.send_event(EventMessage::RecordingChanged); + let _ = app_state.event_manager.send_event(EventMessage::RecordingRulesChanged); + StatusCode::NO_CONTENT.into_response() + } + Ok(false) => { + // Nothing was deleted, so nothing should have been cancelled. + restore_or_report_partial(&service, &cancelled, &app_state, || { + rule_error_response(&RuleServiceError::UnknownRule) + }) + .await + } + Err(_) => { + restore_or_report_partial(&service, &cancelled, &app_state, || { + error_response(StatusCode::INTERNAL_SERVER_ERROR, "recording_persistence_failed") + }) + .await + } + } +} + +/// Undo a rule-delete's queue-side cancel and report `on_restored`. If +/// the undo itself fails there is nothing left to try: report the +/// partial operation so the operator knows the two stores disagree and +/// which side won. +async fn restore_or_report_partial( + service: &RecordingService, + cancelled: &[crate::api::model::recording_service::CancelledRuleRecording], + app_state: &Arc, + on_restored: F, +) -> axum::response::Response +where + F: FnOnce() -> axum::response::Response, +{ + if cancelled.is_empty() { + return on_restored(); + } + match service.restore_cancelled_rule_recordings(cancelled).await { + Ok(()) => { + let _ = app_state.event_manager.send_event(EventMessage::RecordingChanged); + on_restored() + } + Err(err) => { + log::error!( + "failed to restore {} cancelled rule recordings after a failed rule delete: {err}", + cancelled.len() + ); + rule_error_response(&RuleServiceError::PartialOperation { + primary: "future_cancelled".to_string(), + secondary: "rule_delete_failed".to_string(), + }) + } + } +} + +/// Reject every recording route while the DVR is switched off. +/// +/// `video.download.recording.enabled: false` has to mean more than +/// "supervisors idle": a client that keeps calling the routes would +/// otherwise keep creating recordings nothing will ever run. One layer +/// on the nested router covers every route, so a route added later is +/// gated automatically. +/// +/// `501 Not Implemented` with the stable code `recording_disabled` +/// distinguishes "switched off here" from `403` (not allowed) and `404` +/// (does not exist). +pub async fn require_recording_enabled( + State(app_state): State>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + if crate::api::model::recording::recording_supervisor::recording_enabled(app_state.as_ref()) { + next.run(request).await + } else { + error_response(StatusCode::NOT_IMPLEMENTED, "recording_disabled") + } +} + +/// Build the recording router for `/api/v1/recording`. +/// +/// The router is a thin wrapper. The state type is `Arc` +/// (which matches the rest of the v1 router tree). Production wiring +/// in `v1_api::router_v1` calls `recording_api_register(router)` which +/// merges the recording routes into the v1 router tree. +pub fn recording_api_register(router: Router>) -> axum::Router> { + let recording_routes = Router::new() + .route("/tasks", get(list_recording_tasks).post(create_recording_task)) + .route( + "/tasks/{id}", + patch(edit_recording_task).delete(delete_recording_task), + ) + .route("/tasks/{id}/cancel", post(cancel_recording_task)) + .route("/conflicts/preview", post(preview_recording_conflicts)) + .route("/quota", get(get_recording_quota)) + .route("/health", get(get_recording_health)) + .route("/rules", get(list_recording_rules).post(create_recording_rule)) + .route( + "/rules/{id}", + patch(edit_recording_rule).delete(delete_recording_rule), + ); + + router.nest("/recording", recording_routes) +} + +/// The `recording.enabled` gate as a router layer. +/// +/// A macro rather than a function so the caller never has to name the +/// opaque `FromFnLayer<..>` type — the same reason `permission_layer!` +/// is a macro. +#[macro_export] +macro_rules! recording_enabled_layer { + ($app_state:expr) => {{ + let app_state = ::std::sync::Arc::clone($app_state); + ::axum::middleware::from_fn_with_state(app_state, move |state, request, next| { + $crate::api::endpoints::recording_api::require_recording_enabled(state, request, next) + }) + }}; +} +pub use recording_enabled_layer; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn edit_claims(subject_id: Option, admin: bool) -> shared::model::Claims { + shared::model::Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: admin.then(|| ROLE_ADMIN.to_string()).into_iter().collect(), + permissions: Permission::RecordingWrite.into(), + pwd_version: 0, + subject_id, + permission_schema_version: shared::model::CURRENT_PERMISSION_SCHEMA_VERSION, + } + } + + fn editable_rule() -> RecordingRule { + RecordingRule { + id: "rule-1".to_string(), + owner_id: UserId::from("web:alice"), + visibility: RuleVisibility::Private, + enabled: true, + source: RuleSource::new("7", "42", "input-a"), + channel_id: Some("channel-1".to_string()), + body: RuleBody::WeeklyTimeslot { + weekday: 3, + local_start_time: "20:00".to_string(), + duration_secs: 3600, + timezone: "UTC".to_string(), + }, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 1, + updated_at: 1, + } + } + + #[test] + fn edit_rule_requires_subject_id() { + let mut rule = editable_rule(); + let result = authorize_and_apply_recording_rule_edit( + &edit_claims(None, false), + &mut rule, + EditRecordingRuleBody::default(), + 2, + ); + + assert!(matches!(result, Err(EditRuleError::MissingSubject))); + } + + #[test] + fn owner_cannot_promote_private_rule_to_shared() { + let mut rule = editable_rule(); + let patch: EditRecordingRuleBody = serde_json::from_value(json!({"visibility": "shared"})).expect("parse"); + let result = authorize_and_apply_recording_rule_edit( + &edit_claims(Some(UserId::from("web:alice")), false), + &mut rule, + patch, + 2, + ); + + assert!(matches!( + result, + Err(EditRuleError::Rule(RuleServiceError::SharedManagementNotAdministrator)) + )); + } + + #[test] + fn admin_can_promote_private_rule_to_shared() { + let mut rule = editable_rule(); + let patch: EditRecordingRuleBody = serde_json::from_value(json!({"visibility": "shared"})).expect("parse"); + + authorize_and_apply_recording_rule_edit( + &edit_claims(Some(UserId::builtin_admin()), true), + &mut rule, + patch, + 2, + ) + .expect("admin edit"); + + assert_eq!(rule.visibility, RuleVisibility::Shared); + } + + #[test] + fn edit_rule_clear_channel_id_is_distinct_from_unchanged() { + let mut unchanged = editable_rule(); + apply_recording_rule_edit(&mut unchanged, EditRecordingRuleBody::default(), 2); + assert_eq!(unchanged.channel_id.as_deref(), Some("channel-1")); + + let mut cleared = editable_rule(); + let patch: EditRecordingRuleBody = serde_json::from_value(json!({"clear_channel_id": true})).expect("parse"); + apply_recording_rule_edit(&mut cleared, patch, 2); + assert!(cleared.channel_id.is_none()); + } + + #[test] + fn recording_virtual_id_rejects_non_numeric_and_overflow() { + assert_eq!(recording_virtual_id("42"), Some(42)); + assert_eq!(recording_virtual_id("epg-channel"), None); + assert_eq!(recording_virtual_id("4294967296"), None); + } + + #[test] + fn resolved_source_rejects_conflicting_input_and_canonicalizes_id() { + let resolved = crate::api::endpoints::v1_api_playlist::ResolvedRecordingSource { + virtual_id: 42, + input_name: "input-a".to_string(), + }; + let mut virtual_id = "00042".to_string(); + let mut input_name = "input-b".to_string(); + assert!(!accept_resolved_recording_source(&mut virtual_id, &mut input_name, resolved.clone())); + assert_eq!(input_name, "input-b"); + + input_name.clear(); + assert!(accept_resolved_recording_source(&mut virtual_id, &mut input_name, resolved)); + assert_eq!(virtual_id, "42"); + assert_eq!(input_name, "input-a"); + } + + #[test] + fn create_rule_body_accepts_string_target_and_weekly_body() { + let parsed: CreateRecordingRuleBody = serde_json::from_value(json!({ + "target_id": "default", + "virtual_id": "42", + "input_name": "input-a", + "body": { + "kind": "weekly_timeslot", + "weekday": 3, + "local_start_time": "20:00", + "duration_secs": 3600, + "timezone": "Europe/Berlin" + } + })) + .expect("parse weekly rule"); + + assert_eq!(parsed.target_id, "default"); + assert!(matches!(parsed.body, RuleBody::WeeklyTimeslot { weekday: 3, .. })); + } + + #[test] + fn create_rule_body_accepts_new_episode_body() { + let parsed: CreateRecordingRuleBody = serde_json::from_value(json!({ + "target_id": "default", + "virtual_id": "42", + "input_name": "input-a", + "body": { + "kind": "new_episode", + "series_id": "series-1", + "title_pattern": null, + "exclude_repeat": true + } + })) + .expect("parse new episode rule"); + + assert!(matches!( + parsed.body, + RuleBody::NewEpisode { series_id: Some(ref id), exclude_repeat: true, .. } if id == "series-1" + )); + } + + #[test] + fn create_rule_body_rejects_numeric_target() { + let parsed = serde_json::from_value::(json!({ + "target_id": 7, + "virtual_id": "42", + "input_name": "input-a", + "body": { + "kind": "weekly_timeslot", + "weekday": 3, + "local_start_time": "20:00", + "duration_secs": 3600, + "timezone": "UTC" + } + })); + + assert!(parsed.is_err()); + } + + #[test] + fn create_rule_preserves_new_episode_body() { + let body: CreateRecordingRuleBody = serde_json::from_value(json!({ + "target_id": "default", + "virtual_id": "42", + "input_name": "input-a", + "body": { + "kind": "new_episode", + "series_id": "series-1", + "title_pattern": null, + "exclude_repeat": true + } + })) + .expect("parse new episode rule"); + + let rule = recording_rule_from_create(UserId::from("web:alice"), body, 123); + + assert_eq!(rule.source.target_id, "default"); + assert!(matches!(rule.body, RuleBody::NewEpisode { series_id: Some(ref id), .. } if id == "series-1")); + } + + #[test] + fn create_recording_source_rejects_numeric_target() { + let parsed = serde_json::from_value::(json!({ + "target_id": 7, + "virtual_id": "42", + "cluster": "Live", + "input_name": "input-a" + })); + + assert!(parsed.is_err()); + } + + #[test] + fn create_recording_source_accepts_string_target() { + let parsed: CreateRecordingSourceBody = serde_json::from_value(json!({ + "target_id": "default", + "virtual_id": "42", + "cluster": "Live", + "input_name": "input-a" + })) + .expect("parse recording source"); + + assert_eq!(parsed.target_id, "default"); + } + + #[test] + fn edit_rule_body_round_trips_enabled_field() { + let wire = r#"{"enabled":false,"visibility":"shared"}"#; + let parsed: EditRecordingRuleBody = serde_json::from_str(wire).expect("parse"); + assert_eq!(parsed.enabled, Some(false)); + assert!(parsed.visibility.is_some()); + assert!(parsed.body.is_none()); + } + + #[test] + fn edit_rule_body_replaces_variant() { + let parsed: EditRecordingRuleBody = serde_json::from_value(json!({ + "body": { + "kind": "new_episode", + "series_id": null, + "title_pattern": "News", + "exclude_repeat": false + } + })) + .expect("parse edit body"); + + assert!(matches!( + parsed.body, + Some(RuleBody::NewEpisode { title_pattern: Some(ref title), exclude_repeat: false, .. }) if title == "News" + )); + } + + #[test] + fn apply_edit_rule_body_switches_variant() { + let mut rule = RecordingRule { + id: "rule-1".to_string(), + owner_id: UserId::from("web:alice"), + visibility: RuleVisibility::Private, + enabled: true, + source: RuleSource::new("7", "42", "input-a"), + channel_id: None, + body: RuleBody::WeeklyTimeslot { + weekday: 3, + local_start_time: "20:00".to_string(), + duration_secs: 3600, + timezone: "UTC".to_string(), + }, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 1, + updated_at: 1, + }; + let patch: EditRecordingRuleBody = serde_json::from_value(json!({ + "body": { + "kind": "new_episode", + "series_id": null, + "title_pattern": "News", + "exclude_repeat": false + } + })) + .expect("parse edit body"); + + apply_recording_rule_edit(&mut rule, patch, 2); + + assert!(matches!( + rule.body, + RuleBody::NewEpisode { title_pattern: Some(ref title), exclude_repeat: false, .. } if title == "News" + )); + assert_eq!(rule.updated_at, 2); + } + + #[test] + fn error_response_round_trip_serializes_code() { + let body = serde_json::to_value(&ErrorResponse { error: "recording_unknown" }).expect("serialize"); + assert_eq!(body, json!({"error": "recording_unknown"})); + } + + #[test] + fn list_tasks_params_accepts_missing_owner_and_visibility() { + let parsed: ListTasksParams = serde_json::from_value(json!({})).expect("parse empty params"); + assert!(parsed.owner.is_none()); + assert!(parsed.visibility.is_none()); + } + + #[test] + fn list_tasks_params_accepts_explicit_owner_and_visibility() { + let parsed: ListTasksParams = + serde_json::from_value(json!({"owner": "web:alice", "visibility": "private"})) + .expect("parse full params"); + assert_eq!(parsed.owner.as_deref(), Some("web:alice")); + assert_eq!(parsed.visibility.as_deref(), Some("private")); + } + + #[test] + fn delete_rule_params_accepts_missing_future_query() { + let parsed: DeleteRuleParams = serde_json::from_value(json!({})).expect("parse empty"); + assert!(parsed.future.is_none()); + } + + #[test] + fn delete_rule_params_accepts_future_retain() { + let parsed: DeleteRuleParams = + serde_json::from_value(json!({"future": "retain"})).expect("parse retain"); + assert_eq!(parsed.future.as_deref(), Some("retain")); + } + + #[test] + fn delete_rule_params_accepts_future_cancel() { + let parsed: DeleteRuleParams = + serde_json::from_value(json!({"future": "cancel"})).expect("parse cancel"); + assert_eq!(parsed.future.as_deref(), Some("cancel")); + } +} diff --git a/backend/src/api/endpoints/recording_media_api.rs b/backend/src/api/endpoints/recording_media_api.rs new file mode 100644 index 000000000..036b17677 --- /dev/null +++ b/backend/src/api/endpoints/recording_media_api.rs @@ -0,0 +1,568 @@ +//! Authorized recording media routes. +//! +//! Three HTTP routes serve completed/recorded media after the +//! `recording_catalog_access` policy gate authorizes the request +//! (private to owner, shared to anyone with `recording.read`, +//! `LegacyAdmin` to admins, orphans to admins). The relative path +//! is taken from the persisted task metadata — never from the URL — +//! and re-validated at open time with `recording_paths`. +//! +//! The deletion/playback race: an already-opened stream may finish +//! where the OS permits; a new open after `Deleting` is denied. +//! `authorize_open` enforces the `Deleting` check, and the file-open +//! step below catches the actual disappearance (404). +//! + +use crate::{ + api::model::{ + recording_catalog_access::{self, CatalogAccessError}, + AppState, DownloadQueue, + }, + auth::{validate_token_claims, verify_token, AuthBearer, AuthError}, + utils::{no_follow_path_in_root, resolve_recording_dir, RecordingPathError, RecordingVisibility as PathVisibility}, +}; +use axum::{ + body::Body, + extract::{FromRequestParts, Path as AxumPath, State}, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + RequestPartsExt, +}; +use shared::model::{Claims, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, ROLE_ADMIN, TOKEN_NO_AUTH}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tokio_util::io::ReaderStream; + +/// `AuthClaims` extracts the authenticated `Claims` from a bearer +/// token. The recording policy gate (T13) needs the full `Claims`, +/// not just a permission bit, because the visibility/private-owner +/// check runs against `subject_id` and `roles`. +#[derive(Debug)] +pub struct AuthClaims(pub Claims); + +fn builtin_admin_claims() -> Claims { + Claims { + username: "admin".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: i64::MAX, + roles: vec![ROLE_ADMIN.to_string()], + permissions: PERM_ALL, + pwd_version: 0, + subject_id: Some(UserId::builtin_admin()), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + } +} + +fn auth_claims_rejection(error: AuthError) -> Response { + let mut response = (StatusCode::UNAUTHORIZED, "invalid token").into_response(); + if error.is_token_refresh_required() { + response.headers_mut().insert("X-Token-Refresh", header::HeaderValue::from_static("required")); + } + response +} + +impl FromRequestParts> for AuthClaims { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &Arc, + ) -> Result { + let app_state = state.clone(); + let AuthBearer(token) = + parts.extract::().await.map_err(|(_, msg)| (StatusCode::UNAUTHORIZED, msg).into_response())?; + let config = app_state.app_config.config.load(); + match config.web_ui.as_ref().and_then(|w| w.auth.as_ref()).filter(|auth| auth.enabled) { + Some(web_auth) => { + let token_data = verify_token(&token, web_auth.secret.as_bytes()) + .ok_or_else(|| auth_claims_rejection(AuthError::InvalidToken))?; + validate_token_claims(&token_data.claims).map_err(auth_claims_rejection)?; + Ok(Self(token_data.claims)) + } + None if token == TOKEN_NO_AUTH => Ok(Self(builtin_admin_claims())), + None => Err((StatusCode::UNAUTHORIZED, "invalid token").into_response()), + } + } +} + +impl AuthClaims { + // Helper used by the resolve_for_open path. Kept as a free + // function on `AuthClaims` (not a method) to avoid borrowing + // `self` when callers only have a `&Claims` in scope. +} + +/// Resolved target for a media request: the absolute path on disk, +/// the recording root it was resolved against, and the file size for +/// the response headers. The root travels alongside the `abs_path` so +/// every later re-validation (between `File::open` and the actual +/// byte read) can re-check that no intermediate component has been +/// swapped in as a symlink. +struct ResolvedMedia { + abs_path: PathBuf, + recording_root: PathBuf, + size: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RangeSpec { + /// Open-ended suffix: `bytes=-N` (last N bytes) + Suffix(u64), + /// Closed range: `bytes=START-END` + Closed { start: u64, end: u64 }, +} + +impl RangeSpec { + /// `total` is the file size. Returns the absolute `(start, length)` + /// for the partial response, or `None` for "range not satisfiable". + fn resolve(self, total: u64) -> Option<(u64, u64)> { + if total == 0 { + return None; + } + match self { + Self::Suffix(n) => { + if n == 0 { + return None; + } + let n = n.min(total); + Some((total - n, n)) + } + Self::Closed { start, end } => { + if start >= total { + return None; + } + let end = end.min(total - 1); + if end < start { + return None; + } + Some((start, end - start + 1)) + } + } + } +} + +fn parse_range(header_value: &str, total: u64) -> Option { + // Only `bytes=...` is meaningful for media streaming. HTTP + // ranges are not specified for multi-range; we support a single + // range only (multi-range would need a + // multipart/byteranges response, which the frontends do not + // request yet). + let rest = header_value.strip_prefix("bytes=")?; + let mut parts = rest.splitn(2, '-'); + let start = parts.next()?.trim(); + let end = parts.next()?.trim(); + if start.is_empty() { + // Suffix: `bytes=-N`. An oversized N (n > total) is allowed here + // and clamped to `total` inside `RangeSpec::resolve` so the + // client receives the full representation rather than a hard + // error. Zero is handled in `resolve` so that the parser + // remains a structural check only. + let n: u64 = end.parse().ok()?; + Some(RangeSpec::Suffix(n)) + } else { + let s: u64 = start.parse().ok()?; + if end.is_empty() { + // Open-ended: `bytes=START-` → from START to EOF + if s >= total { + return None; + } + Some(RangeSpec::Closed { start: s, end: total - 1 }) + } else { + let e: u64 = end.parse().ok()?; + Some(RangeSpec::Closed { start: s, end: e }) + } + } +} + +fn access_error_to_response(err: &CatalogAccessError) -> Response { + // Prevent path disclosure in errors and logs: every + // CatalogAccessError is mapped to a generic status + the stable + // `recording_*` code; no path or owner id is leaked. + let code = err.code(); + if matches!(err, CatalogAccessError::TokenRefreshRequired) { + // T12 contract: stale schema → 401 + X-Token-Refresh. + let mut resp = (StatusCode::UNAUTHORIZED, axum::Json(serde_json::json!({"error": code}))).into_response(); + resp.headers_mut().insert("X-Token-Refresh", header::HeaderValue::from_static("required")); + return resp; + } + let status = match err { + CatalogAccessError::TokenRefreshRequired => StatusCode::UNAUTHORIZED, + CatalogAccessError::MissingPermission | CatalogAccessError::Forbidden => StatusCode::FORBIDDEN, + CatalogAccessError::NotFound => StatusCode::NOT_FOUND, + CatalogAccessError::InvalidPath => StatusCode::BAD_REQUEST, + CatalogAccessError::InDeletingState => StatusCode::CONFLICT, + CatalogAccessError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, axum::Json(serde_json::json!({"error": code}))).into_response() +} + +/// Find the recording, authorize the open, and resolve the on-disk +/// path. Every step is a security boundary; no step logs the path. +async fn resolve_for_open(app_state: &AppState, claims: &Claims, uuid: &str) -> Result> { + let queue: &DownloadQueue = &app_state.downloads; + let recording = recording_catalog_access::lookup_recording(queue, uuid) + .await + .ok_or_else(|| Box::new(access_error_to_response(&CatalogAccessError::NotFound)))?; + let meta = recording + .recording + .as_ref() + .ok_or_else(|| Box::new(access_error_to_response(&CatalogAccessError::NotFound)))?; + let relative = meta + .relative_path + .as_deref() + .ok_or_else(|| Box::new(access_error_to_response(&CatalogAccessError::InvalidPath)))?; + let owner_dir = match &meta.owner { + shared::model::recording::RecordingOwner::User(user_id) => user_id.0.clone(), + shared::model::recording::RecordingOwner::LegacyAdmin => "legacy".to_string(), + }; + let subject_id = claims + .subject_id + .as_ref() + .ok_or_else(|| Box::new(access_error_to_response(&CatalogAccessError::TokenRefreshRequired)))?; + recording_catalog_access::authorize_open( + queue, + claims, + subject_id, + uuid, + Path::new(relative), + true, // existence/type is re-checked below with no_follow_regular_file + ) + .await + .map_err(|e| Box::new(access_error_to_response(&e)))?; + let config = app_state.app_config.config.load(); + let recording_root = config + .video + .as_ref() + .and_then(|v| v.download.as_ref()) + .and_then(|d| d.recording.as_ref()) + .map(|r| r.directory.clone()) + .ok_or_else(|| { + Box::new( + ( + StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({"error": "recording_not_configured"})), + ) + .into_response(), + ) + })?; + let recording_root = PathBuf::from(recording_root); + let abs_path = resolve_recording_dir( + &recording_root, + match meta.visibility { + shared::model::recording::RecordingVisibility::Private => PathVisibility::Private, + shared::model::recording::RecordingVisibility::Shared => PathVisibility::Shared, + }, + &owner_dir, + Path::new(relative), + ) + .map_err(|_e: RecordingPathError| Box::new(access_error_to_response(&CatalogAccessError::InvalidPath)))?; + // Re-validate the on-disk file is a regular file (no symlink, + // no directory) at every intermediate component between the + // configured root and the leaf — otherwise a swapped-in symlink + // under `/users/alice` would route reads outside the + // recording root. + let file_meta = no_follow_path_in_root(&recording_root, &abs_path) + .await + .ok_or_else(|| Box::new(access_error_to_response(&CatalogAccessError::NotFound)))?; + Ok(ResolvedMedia { + abs_path, + recording_root, + size: file_meta.len(), + }) +} + +/// `GET /library/recording/playback/{uuid}` — supports HTTP Range +/// (RFC 7233 single-range form) and full-stream responses. +pub async fn playback_recording( + State(app_state): State>, + claims: AuthClaims, + AxumPath(uuid): AxumPath, + headers: HeaderMap, +) -> Response { + match resolve_for_open(&app_state, &claims.0, &uuid).await { + Ok(resolved) => serve_range(&app_state, &resolved, &headers, false).await, + Err(response) => *response, + } +} + +/// `GET /library/recording/download/{uuid}` — `Content-Disposition: +/// attachment` with the sanitized filename. +pub async fn download_recording( + State(app_state): State>, + claims: AuthClaims, + AxumPath(uuid): AxumPath, + headers: HeaderMap, +) -> Response { + match resolve_for_open(&app_state, &claims.0, &uuid).await { + Ok(resolved) => serve_range(&app_state, &resolved, &headers, true).await, + Err(response) => *response, + } +} + +/// `GET /library/recording/thumbnail/{uuid}` — not implemented yet; +/// thumbnail generation lands with the dedicated scanner in a later +/// release. Returning 404 (not 501) so legacy clients do not retry +/// forever. +pub async fn thumbnail_recording(_claims: AuthClaims, AxumPath(_uuid): AxumPath) -> Response { + StatusCode::NOT_FOUND.into_response() +} + +async fn serve_range( + _app_state: &Arc, + resolved: &ResolvedMedia, + headers: &HeaderMap, + attachment: bool, +) -> Response { + let total = resolved.size; + let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); + let filename = resolved.abs_path.file_name().and_then(|s| s.to_str()).unwrap_or("recording"); + let mut base_headers = vec![ + (header::CONTENT_TYPE, "application/octet-stream".to_string()), + (header::ACCEPT_RANGES, "bytes".to_string()), + ]; + if attachment { + base_headers.push((header::CONTENT_DISPOSITION, format!("attachment; filename=\"{filename}\""))); + } + if let Some(rh) = range_header { + let Some(spec) = parse_range(rh, total) else { + // RFC 7233 §4.4: 416 with `Content-Range: bytes */`. + return (StatusCode::RANGE_NOT_SATISFIABLE, [(header::CONTENT_RANGE, format!("bytes */{total}"))]) + .into_response(); + }; + let Some((start, length)) = spec.resolve(total) else { + return (StatusCode::RANGE_NOT_SATISFIABLE, [(header::CONTENT_RANGE, format!("bytes */{total}"))]) + .into_response(); + }; + let Ok(file) = tokio::fs::File::open(&resolved.abs_path).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + // Race rule: re-validate no component between root and the + // leaf has been swapped in as a symlink since `resolve_for_open` + // approved the open. + if no_follow_path_in_root(&resolved.recording_root, &resolved.abs_path).await.is_none() { + return access_error_to_response(&CatalogAccessError::NotFound); + } + let mut seeked = file; + if seeked.seek(std::io::SeekFrom::Start(start)).await.is_err() { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + let limited = seeked.take(length); + let stream = ReaderStream::new(limited); + let mut hdrs = base_headers.clone(); + hdrs.push((header::CONTENT_RANGE, format!("bytes {start}-{}/{total}", start + length - 1))); + hdrs.push((header::CONTENT_LENGTH, length.to_string())); + build_response(StatusCode::PARTIAL_CONTENT, hdrs, Body::from_stream(stream)) + } else { + // No Range header → full body. The file was already + // re-validated at open time in `resolve_for_open`. The byte + // limit mirrors the range path: it caps the stream at the + // advertised Content-Length so a concurrent append or symlink + // swap cannot overshoot the response. + let Ok(file) = tokio::fs::File::open(&resolved.abs_path).await else { + return StatusCode::NOT_FOUND.into_response(); + }; + if no_follow_path_in_root(&resolved.recording_root, &resolved.abs_path).await.is_none() { + return access_error_to_response(&CatalogAccessError::NotFound); + } + let limited = file.take(total); + let stream = ReaderStream::new(limited); + let mut hdrs = base_headers.clone(); + hdrs.push((header::CONTENT_LENGTH, total.to_string())); + build_response(StatusCode::OK, hdrs, Body::from_stream(stream)) + } +} + +fn build_response(status: StatusCode, headers: Vec<(header::HeaderName, String)>, body: Body) -> Response { + let mut builder = axum::http::Response::builder().status(status); + { + let Some(map) = builder.headers_mut() else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + for (k, v) in headers { + if let Ok(value) = axum::http::HeaderValue::from_str(&v) { + map.insert(k, value); + } + } + } + builder.body(body).unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +/// Register the recording media routes under `/library/recording/...`. +/// +/// Auth is enforced by the `AuthClaims` extractor inside each +/// handler. The recording policy gate +/// (`recording_catalog_access::authorize_open`) is the second gate +/// that enforces ownership and visibility. +pub fn recording_media_api_register(router: axum::Router>) -> axum::Router> { + router + .route("/library/recording/playback/{uuid}", get(playback_recording)) + .route("/library/recording/download/{uuid}", get(download_recording)) + .route("/library/recording/thumbnail/{uuid}", get(thumbnail_recording)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{api::model::create_test_app_state, auth::create_jwt_admin, model::Config}; + use axum::http::Request; + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use shared::model::{Permission, UserId, WebUiConfigDto, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, ROLE_ADMIN, TOKEN_NO_AUTH}; + + fn config_with_web_auth(enabled: bool, secret: &str) -> Config { + let web_ui = WebUiConfigDto { + auth: Some(shared::model::WebAuthConfigDto { + enabled, + issuer: "test".to_string(), + secret: secret.to_string(), + ..shared::model::WebAuthConfigDto::default() + }), + ..shared::model::WebUiConfigDto::default() + }; + Config { web_ui: Some((&web_ui).into()), ..Config::default() } + } + + async fn extract_auth_claims( + state: &Arc, + authorization: Option<&str>, + ) -> Result> { + let mut builder = Request::builder(); + if let Some(value) = authorization { + builder = builder.header(header::AUTHORIZATION, value); + } + let request = builder.body(()).expect("request"); + let (mut parts, ()) = request.into_parts(); + AuthClaims::from_request_parts(&mut parts, state) + .await + .map_err(Box::new) + } + + #[tokio::test] + async fn auth_claims_accepts_only_dummy_bearer_without_web_auth() { + let state = create_test_app_state(Config::default()); + let AuthClaims(claims) = + extract_auth_claims(&state, Some(&format!("Bearer {TOKEN_NO_AUTH}"))).await.expect("builtin claims"); + + assert_eq!(claims.subject_id, Some(UserId::builtin_admin())); + assert_eq!(claims.roles, vec![ROLE_ADMIN]); + assert_eq!(claims.permissions, PERM_ALL); + assert_eq!(claims.permission_schema_version, CURRENT_PERMISSION_SCHEMA_VERSION); + assert!(claims.permissions.contains(Permission::RecordingWrite)); + + for authorization in [None, Some("Basic authorized"), Some("Bearer wrong")] { + let response = extract_auth_claims(&state, authorization).await.expect_err("rejected"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(!response.headers().contains_key("X-Token-Refresh")); + } + } + + #[tokio::test] + async fn auth_claims_accepts_dummy_bearer_with_web_auth_disabled() { + let state = create_test_app_state(config_with_web_auth(false, "unused")); + + let AuthClaims(claims) = + extract_auth_claims(&state, Some(&format!("Bearer {TOKEN_NO_AUTH}"))).await.expect("builtin claims"); + + assert_eq!(claims.subject_id, Some(UserId::builtin_admin())); + assert_eq!(claims.permissions, PERM_ALL); + } + + #[tokio::test] + async fn auth_claims_rejects_dummy_bearer_with_web_auth_enabled() { + let state = create_test_app_state(config_with_web_auth(true, "secret")); + + let response = + extract_auth_claims(&state, Some(&format!("Bearer {TOKEN_NO_AUTH}"))).await.expect_err("dummy rejected"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(!response.headers().contains_key("X-Token-Refresh")); + } + + #[tokio::test] + async fn auth_claims_enabled_jwt_truth_table() { + let config = config_with_web_auth(true, "secret"); + let web_auth = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()).expect("web auth").clone(); + let state = create_test_app_state(config); + let valid = create_jwt_admin(&web_auth, "admin", 0).expect("jwt"); + + let AuthClaims(claims) = + extract_auth_claims(&state, Some(&format!("Bearer {valid}"))).await.expect("valid claims"); + assert_eq!(claims.subject_id, Some(UserId::builtin_admin())); + + let mut stale_claims = builtin_admin_claims(); + stale_claims.permission_schema_version = 0; + let mut missing_subject = builtin_admin_claims(); + missing_subject.subject_id = None; + for claims in [stale_claims, missing_subject] { + let token = + encode(&Header::new(Algorithm::HS256), &claims, &EncodingKey::from_secret(b"secret")).expect("jwt"); + let response = + extract_auth_claims(&state, Some(&format!("Bearer {token}"))).await.expect_err("refresh required"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + response.headers().get("X-Token-Refresh").and_then(|value| value.to_str().ok()), + Some("required") + ); + } + } + + #[test] + fn parse_range_closed_with_both_bounds() { + let s = parse_range("bytes=100-199", 1000).expect("parse"); + assert_eq!(s.resolve(1000), Some((100, 100))); + } + + #[test] + fn parse_range_open_ended_to_eof() { + let s = parse_range("bytes=500-", 1000).expect("parse"); + assert_eq!(s.resolve(1000), Some((500, 500))); + } + + #[test] + fn parse_range_suffix_last_n() { + let s = parse_range("bytes=-200", 1000).expect("parse"); + assert_eq!(s.resolve(1000), Some((800, 200))); + } + + #[test] + fn parse_range_clamps_overflow_end() { + let s = parse_range("bytes=900-9999", 1000).expect("parse"); + // end clamped to total-1, so 900..=999 + assert_eq!(s.resolve(1000), Some((900, 100))); + } + + #[test] + fn parse_range_rejects_start_past_eof() { + // `bytes=2000-` is past the file end (total=1000); the + // parser must reject it before `resolve` is even called. + assert!(parse_range("bytes=2000-", 1000).is_none()); + } + + #[test] + fn parse_range_rejects_zero_suffix() { + let s = parse_range("bytes=-0", 1000).expect("parse"); + assert_eq!(s.resolve(1000), None); + } + + #[test] + fn parse_range_rejects_non_bytes_unit() { + assert!(parse_range("items=0-10", 1000).is_none()); + } + + #[test] + fn parse_range_rejects_malformed() { + assert!(parse_range("bytes=abc-def", 1000).is_none()); + assert!(parse_range("bytes=", 1000).is_none()); + } + + #[test] + fn resolved_media_size_is_passed_through() { + // Sanity: RangeSpec::Closed maps total=0 → None (no + // satisfiable range for an empty file). + let s = parse_range("bytes=0-0", 0).expect("parse"); + assert_eq!(s.resolve(0), None); + } +} diff --git a/backend/src/api/endpoints/stream_history_api.rs b/backend/src/api/endpoints/stream_history_api.rs index ce39379af..9574e9065 100644 --- a/backend/src/api/endpoints/stream_history_api.rs +++ b/backend/src/api/endpoints/stream_history_api.rs @@ -2,6 +2,7 @@ use std::cmp::Reverse; use std::collections::{BinaryHeap, HashMap}; use std::io; use std::path::Path; +use std::str::FromStr; use std::sync::Arc; use crate::api::api_utils::json_or_bin_response; @@ -18,81 +19,20 @@ use regex::{Regex, RegexBuilder}; use serde::{Deserialize, Serialize}; use shared::model::{ PageRequestDto, PagedResponseDto, SearchMode, StreamHistoryEventType, StreamHistoryPageRequestDto, - StreamHistoryProviderSummaryDto, QosSnapshotRecordDto, StreamHistoryQueryRequestDto, StreamHistoryRecordDto, + StreamHistoryProviderSummaryDto, StreamHistorySearchField, QosSnapshotRecordDto, StreamHistoryQueryRequestDto, + StreamHistoryRecordDto, }; - -// TODO make shared Search fields +use strum::IntoEnumIterator; const MAX_STREAM_HISTORY_PAGE_HEAP_CAPACITY: usize = 100_000; -#[derive(Clone, Copy)] -enum SearchField { - EventTsUtc, - EventType, - Title, - Group, - ApiUsername, - ProviderName, - ProviderId, - BytesSent, - FirstByteLatencyMs, - UserAgent, - ItemType, - Container, - DisconnectReason, - SourceAddr, - Country, - Cluster, -} - -impl SearchField { - const ALL: [Self; 16] = [ - Self::EventTsUtc, - Self::EventType, - Self::Title, - Self::Group, - Self::ApiUsername, - Self::ProviderName, - Self::ProviderId, - Self::BytesSent, - Self::FirstByteLatencyMs, - Self::UserAgent, - Self::ItemType, - Self::Container, - Self::DisconnectReason, - Self::SourceAddr, - Self::Country, - Self::Cluster, - ]; - - fn parse(value: &str) -> Option { - match value { - "event_ts_utc" => Some(Self::EventTsUtc), - "event_type" => Some(Self::EventType), - "title" => Some(Self::Title), - "group" => Some(Self::Group), - "api_username" => Some(Self::ApiUsername), - "provider_name" => Some(Self::ProviderName), - "provider_id" => Some(Self::ProviderId), - "bytes_sent" => Some(Self::BytesSent), - "first_byte_latency_ms" => Some(Self::FirstByteLatencyMs), - "user_agent" => Some(Self::UserAgent), - "item_type" => Some(Self::ItemType), - "container" => Some(Self::Container), - "disconnect_reason" => Some(Self::DisconnectReason), - "source_addr" => Some(Self::SourceAddr), - "country" => Some(Self::Country), - "cluster" => Some(Self::Cluster), - _ => None, - } - } -} - -fn compile_search_fields(fields: Option>) -> Result, String> { +fn compile_search_fields(fields: Option>) -> Result, String> { fields .unwrap_or_default() .into_iter() - .map(|field| SearchField::parse(&field).ok_or_else(|| format!("Unknown search_field: {field}"))) + .map(|field| { + StreamHistorySearchField::from_str(&field).map_err(|_| format!("Unknown search_field: {field}")) + }) .collect() } @@ -152,39 +92,59 @@ fn compile_search_matcher(search: Option<&str>, mode: SearchMode) -> Result bool { +fn record_field_matches(record: &StreamHistoryRecord, field: StreamHistorySearchField, matcher: &Regex) -> bool { match field { - SearchField::EventTsUtc => matcher.is_match(&record.event_ts_utc.to_string()), - SearchField::EventType => matcher.is_match(&record.event_type.to_string()), - SearchField::Title => record.title.as_deref().is_some_and(|value| matcher.is_match(value)), - SearchField::Group => record.group.as_deref().is_some_and(|value| matcher.is_match(value)), - SearchField::ApiUsername => record.api_username.as_deref().is_some_and(|value| matcher.is_match(value)), - SearchField::ProviderName => record.provider_name.as_deref().is_some_and(|value| matcher.is_match(value)), - SearchField::ProviderId => record.provider_id.is_some_and(|value| matcher.is_match(&value.to_string())), - SearchField::BytesSent => record.bytes_sent.is_some_and(|value| matcher.is_match(&value.to_string())), - SearchField::FirstByteLatencyMs => record + StreamHistorySearchField::EventTsUtc => matcher.is_match(&record.event_ts_utc.to_string()), + StreamHistorySearchField::EventType => matcher.is_match(&record.event_type.to_string()), + StreamHistorySearchField::Title => record.title.as_deref().is_some_and(|value| matcher.is_match(value)), + StreamHistorySearchField::Group => record.group.as_deref().is_some_and(|value| matcher.is_match(value)), + StreamHistorySearchField::ApiUsername => { + record.api_username.as_deref().is_some_and(|value| matcher.is_match(value)) + } + StreamHistorySearchField::ProviderName => { + record.provider_name.as_deref().is_some_and(|value| matcher.is_match(value)) + } + StreamHistorySearchField::ProviderId => { + record.provider_id.is_some_and(|value| matcher.is_match(&value.to_string())) + } + StreamHistorySearchField::BytesSent => { + record.bytes_sent.is_some_and(|value| matcher.is_match(&value.to_string())) + } + StreamHistorySearchField::FirstByteLatencyMs => record .first_byte_latency_ms .is_some_and(|value| matcher.is_match(&value.to_string())), - SearchField::UserAgent => record.user_agent.as_deref().is_some_and(|value| matcher.is_match(value)), - SearchField::ItemType => record.item_type.as_ref().is_some_and(|value| matcher.is_match(&value.to_string())), - SearchField::Container => record.container.as_deref().is_some_and(|value| matcher.is_match(value)), - SearchField::DisconnectReason => record + StreamHistorySearchField::UserAgent => { + record.user_agent.as_deref().is_some_and(|value| matcher.is_match(value)) + } + StreamHistorySearchField::ItemType => { + record.item_type.as_ref().is_some_and(|value| matcher.is_match(&value.to_string())) + } + StreamHistorySearchField::Container => { + record.container.as_deref().is_some_and(|value| matcher.is_match(value)) + } + StreamHistorySearchField::DisconnectReason => record .disconnect_reason .as_ref() .is_some_and(|value| matcher.is_match(&value.to_string())), - SearchField::SourceAddr => record.source_addr.as_deref().is_some_and(|value| matcher.is_match(value)), - SearchField::Country => record.country.as_deref().is_some_and(|value| matcher.is_match(value)), - SearchField::Cluster => record.cluster.as_deref().is_some_and(|value| matcher.is_match(value)), + StreamHistorySearchField::SourceAddr => { + record.source_addr.as_deref().is_some_and(|value| matcher.is_match(value)) + } + StreamHistorySearchField::Country => record.country.as_deref().is_some_and(|value| matcher.is_match(value)), + StreamHistorySearchField::Cluster => record.cluster.as_deref().is_some_and(|value| matcher.is_match(value)), } } -fn record_matches_search(record: &StreamHistoryRecord, matcher: Option<&Regex>, fields: &[SearchField]) -> bool { +fn record_matches_search( + record: &StreamHistoryRecord, + matcher: Option<&Regex>, + fields: &[StreamHistorySearchField], +) -> bool { let Some(matcher) = matcher else { return true; }; if fields.is_empty() { - return SearchField::ALL.into_iter().any(|field| record_field_matches(record, field, matcher)); + return StreamHistorySearchField::iter().any(|field| record_field_matches(record, field, matcher)); } fields.iter().copied().any(|field| record_field_matches(record, field, matcher)) @@ -353,7 +313,7 @@ impl TopHistoryPageCollector { }) } - fn push(&mut self, record: StreamHistoryRecord, matcher: Option<&Regex>, fields: &[SearchField]) { + fn push(&mut self, record: StreamHistoryRecord, matcher: Option<&Regex>, fields: &[StreamHistorySearchField]) { if !record_matches_search(&record, matcher, fields) { return; } @@ -397,7 +357,7 @@ fn push_hls_or_non_hls( hls_sessions: &mut HashMap, collector: &mut TopHistoryPageCollector, matcher: Option<&Regex>, - fields: &[SearchField], + fields: &[StreamHistorySearchField], ) { let is_hls = matches!(record.container.as_deref(), Some("mpegts" | "fmp4" | "hls")); let is_hls_session_event = matches!( @@ -422,7 +382,7 @@ fn paginate_stream_history_records( records: I, batch_records: Vec, matcher: Option<&Regex>, - fields: &[SearchField], + fields: &[StreamHistorySearchField], page: u32, page_size: u16, ) -> Result, String> @@ -463,7 +423,7 @@ fn aggregate_hls_session(records: &[&StreamHistoryRecord]) -> Vec, matcher: Option<&Regex>, - fields: &[SearchField], + fields: &[StreamHistorySearchField], page: u32, page_size: u16, ) -> PagedResponseDto { @@ -1040,8 +1000,8 @@ mod tests { .expect("text matcher should exist"); assert!(record_matches_search(&record, Some(&matcher), &[])); - assert!(record_matches_search(&record, Some(&matcher), &[SearchField::Group])); - assert!(!record_matches_search(&record, Some(&matcher), &[SearchField::Title])); + assert!(record_matches_search(&record, Some(&matcher), &[StreamHistorySearchField::Group])); + assert!(!record_matches_search(&record, Some(&matcher), &[StreamHistorySearchField::Title])); } #[test] diff --git a/backend/src/api/endpoints/user_api.rs b/backend/src/api/endpoints/user_api.rs index d174a9f89..3627b2ffe 100644 --- a/backend/src/api/endpoints/user_api.rs +++ b/backend/src/api/endpoints/user_api.rs @@ -168,6 +168,7 @@ async fn save_playlist_bouquet( } Err(err) => { error!("Saving bouquet for {username} failed: {err}"); + return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); } } } diff --git a/backend/src/api/endpoints/user_visibility.rs b/backend/src/api/endpoints/user_visibility.rs new file mode 100644 index 000000000..d1bb4f2f9 --- /dev/null +++ b/backend/src/api/endpoints/user_visibility.rs @@ -0,0 +1,77 @@ +use crate::model::{AppConfig, ConfigTarget, ProxyUserCredentials}; +use crate::repository::{iter_raw_m3u_target_playlist, iter_raw_xtream_target_playlist}; +use shared::model::{PlaylistItem, TargetType, XtreamCluster}; +use std::collections::HashSet; +use tokio_stream::StreamExt; + +/// Category ids that contain at least one item visible to the user's content +/// filter. `None` when the user has no filter or the playlist is unavailable +/// (callers must not thin in that case). +pub(in crate::api) async fn collect_visible_category_ids( + app_config: &AppConfig, + target: &ConfigTarget, + cluster: XtreamCluster, + user: &ProxyUserCredentials, +) -> Option> { + user.t_filter.as_ref()?; + let mut iterator = iter_raw_xtream_target_playlist(app_config, target, cluster).await?; + let mut visible = HashSet::new(); + while let Some(entry) = iterator.next().await { + if let Ok(item) = entry { + if visible.contains(&item.category_id) { + continue; + } + let pli = PlaylistItem::from(&item); + if user.allows_content(&pli) { + visible.insert(item.category_id); + } + } + } + Some(visible) +} + +/// Lowercased EPG channel ids of items visible to the user's content filter, +/// across all clusters of the target's primary output. `None` when the user +/// has no filter (callers must not thin in that case). +pub(in crate::api) async fn collect_visible_epg_channel_ids( + app_config: &AppConfig, + target: &ConfigTarget, + user: &ProxyUserCredentials, +) -> Option> { + user.t_filter.as_ref()?; + let mut visible = HashSet::new(); + if target.has_output(TargetType::Xtream) { + for cluster in [XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series] { + let Some(mut iterator) = iter_raw_xtream_target_playlist(app_config, target, cluster).await else { + continue; + }; + while let Some(entry) = iterator.next().await { + if let Ok(item) = entry { + let Some(epg_id) = item.epg_channel_id.as_ref() else { + continue; + }; + let pli = PlaylistItem::from(&item); + if user.allows_content(&pli) { + visible.insert(epg_id.to_lowercase()); + } + } + } + } + } else if target.has_output(TargetType::M3u) { + let Some(mut iterator) = iter_raw_m3u_target_playlist(app_config, target, None).await else { + return Some(visible); + }; + while let Some(entry) = iterator.next().await { + if let Ok(item) = entry { + let Some(epg_id) = item.epg_channel_id.as_ref() else { + continue; + }; + let pli = PlaylistItem::from(&item); + if user.allows_content(&pli) { + visible.insert(epg_id.to_lowercase()); + } + } + } + } + Some(visible) +} diff --git a/backend/src/api/endpoints/v1_api.rs b/backend/src/api/endpoints/v1_api.rs index 40f31f80e..ee6cdab65 100644 --- a/backend/src/api/endpoints/v1_api.rs +++ b/backend/src/api/endpoints/v1_api.rs @@ -2,31 +2,33 @@ use crate::{ api::{ api_utils::{internal_server_error, json_or_bin_response, try_unwrap_body}, endpoints::{ - download_api, extract_accept_header::ExtractAcceptHeader, library_api::library_api_register, - rbac_api::rbac_api_register, - user_api::user_api_register, v1_api_config::v1_api_config_register, - v1_api_config::v1_api_config_register_with_permissions, v1_api_playlist::{ - v1_api_playlist_register_public, - v1_api_playlist_register_protected, + download_api, + extract_accept_header::ExtractAcceptHeader, + library_api::library_api_register, + rbac_api::{rbac_api_register, rbac_api_register_unprotected}, + recording_api::{recording_api_register, recording_enabled_layer}, + recording_media_api::recording_media_api_register, + user_api::user_api_register, + v1_api_config::{v1_api_config_register, v1_api_config_register_with_permissions}, + v1_api_playlist::{ + v1_api_playlist_register_protected, v1_api_playlist_register_public, v1_api_playlist_register_with_permissions, }, v1_api_user::{v1_api_user_register, v1_api_user_register_with_permissions}, }, model::AppState, }, + auth::permission_layer, processing::geoip::{update_geoip_db, GeoIpUpdateError}, utils::ip_checker::get_ips, VERSION, }; use axum::response::IntoResponse; -use crate::auth::permission_layer; use shared::{ - model::permission::Permission, - model::{IpCheckDto, StatusCheck}, + model::{permission::Permission, IpCheckDto, StatusCheck}, utils::concat_path_leading_slash, }; use std::{collections::BTreeMap, sync::Arc}; -use crate::api::endpoints::rbac_api::rbac_api_register_unprotected; pub const API_V1_PATH: &str = "api/v1"; @@ -59,6 +61,7 @@ pub async fn create_status_check(app_state: &Arc) -> StatusCheck { version: VERSION.to_string(), build_time: crate::api::api_utils::get_build_time(), server_time: crate::api::api_utils::get_server_time(), + uptime_secs: crate::api::api_utils::get_uptime_secs(), active_users, active_user_connections, active_provider_connections, @@ -133,11 +136,10 @@ pub fn v1_api_register( axum::routing::get(super::stream_history_api::qos_snapshot_detail_query), ); - let system_write = axum::routing::Router::new() - .route("/geoip/update", axum::routing::get(geoip_update)); + let system_write = axum::routing::Router::new().route("/geoip/update", axum::routing::get(geoip_update)); - let download_read = axum::routing::Router::new() - .route("/file/download/info", axum::routing::get(download_api::download_file_info)); + let download_read = + axum::routing::Router::new().route("/file/download/info", axum::routing::get(download_api::download_file_info)); let download_write = axum::routing::Router::new() .route("/file/download", axum::routing::post(download_api::queue_download_file)) @@ -160,7 +162,16 @@ pub fn v1_api_register( .merge(v1_api_user_register_with_permissions(axum::routing::Router::new(), app_state)) .merge(v1_api_playlist_register_with_permissions(axum::routing::Router::new(), app_state)) .merge(library_api_register(axum::routing::Router::new(), Some(app_state))) - .merge(rbac_api_register(Arc::clone(app_state))); + .merge(rbac_api_register(Arc::clone(app_state))) + // `recording.enabled: false` turns the DVR off end to end: + // the supervisors idle and the routes answer + // `501 recording_disabled` instead of queueing work nothing + // will run. + .merge( + recording_api_register(axum::routing::Router::new()) + .merge(recording_media_api_register(axum::routing::Router::new())) + .layer(recording_enabled_layer!(app_state)), + ); } else { router = router .merge(system_read) @@ -171,7 +182,12 @@ pub fn v1_api_register( .merge(v1_api_user_register(axum::routing::Router::new())) .merge(v1_api_playlist_register_protected(axum::routing::Router::new())) .merge(library_api_register(axum::routing::Router::new(), None)) - .merge(rbac_api_register_unprotected(Arc::clone(app_state))); + .merge(rbac_api_register_unprotected(Arc::clone(app_state))) + .merge( + recording_api_register(axum::routing::Router::new()) + .merge(recording_media_api_register(axum::routing::Router::new())) + .layer(recording_enabled_layer!(app_state)), + ); } let config = app_state.app_config.config.load(); @@ -182,24 +198,100 @@ pub fn v1_api_register( } let api_prefix = concat_path_leading_slash(web_ui_path, API_V1_PATH); - base_router - .nest(&api_prefix, public_router) - .nest(&api_prefix, router) + base_router.nest(&api_prefix, public_router).nest(&api_prefix, router) } #[cfg(test)] mod tests { - use super::create_status_check; + use super::{create_status_check, v1_api_register}; use crate::{ api::model::{create_test_app_state, ConnectionKind, ConnectionParams}, auth::Fingerprint, model::Config, }; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; use shared::{ model::{PlaylistItemType, StreamChannel, XtreamCluster}, utils::Internable, }; use std::{borrow::Cow, net::SocketAddr}; + use tower::ServiceExt; + + /// A config whose DVR block is present and explicitly on or off. + /// Built through the DTO so the `enabled` flag travels the same + /// deserialize → domain path it does in production. + fn config_with_recording_enabled(enabled: bool) -> Config { + let recording = shared::model::RecordingConfigDto { enabled, ..Default::default() }; + let download = + shared::model::VideoDownloadConfigDto { recording: Some(recording), ..Default::default() }; + let video = shared::model::VideoConfigDto { download: Some(download), ..Default::default() }; + Config { video: Some((&video).into()), ..Config::default() } + } + + #[tokio::test] + async fn recording_routes_answer_not_implemented_when_the_dvr_is_disabled() { + // `recording.enabled: false` has to be visible at the API edge, + // not just in the schedulers: a client that keeps calling would + // otherwise queue recordings nothing will ever run. 501 also + // distinguishes "switched off here" from 403 and 404. + let app_state = create_test_app_state(config_with_recording_enabled(false)); + let router = v1_api_register(false, &app_state, "").with_state(app_state); + + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/recording/tasks") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); + } + + #[tokio::test] + async fn recording_routes_are_reachable_when_the_dvr_is_enabled() { + // The mirror of the test above: the gate must not be a blanket + // block. An explicitly enabled DVR reaches the handler, which + // then rejects the unauthenticated call on its own terms — + // anything other than 501 proves the layer let the request past. + let app_state = create_test_app_state(config_with_recording_enabled(true)); + let router = v1_api_register(false, &app_state, "").with_state(app_state); + + let response = router + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/recording/tasks") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_ne!(response.status(), StatusCode::NOT_IMPLEMENTED); + } + + #[tokio::test] + async fn no_auth_router_exposes_recording_routes() { + let app_state = create_test_app_state(Config::default()); + let router = v1_api_register(false, &app_state, "").with_state(app_state); + + for path in ["/api/v1/recording/tasks", "/api/v1/library/recording/playback/missing"] { + let response = router + .clone() + .oneshot(Request::builder().method("OPTIONS").uri(path).body(Body::empty()).expect("request")) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED, "missing route: {path}"); + } + } #[tokio::test] async fn status_snapshot_removes_released_direct_series_stream() { @@ -259,13 +351,6 @@ mod tests { assert_eq!(clean.active_users, 0); assert_eq!(clean.active_user_connections, 0); assert!(clean.active_user_streams.is_empty()); - assert_eq!( - clean - .active_provider_connections - .unwrap_or_default() - .values() - .sum::(), - 0 - ); + assert_eq!(clean.active_provider_connections.unwrap_or_default().values().sum::(), 0); } } diff --git a/backend/src/api/endpoints/v1_api_config.rs b/backend/src/api/endpoints/v1_api_config.rs index 5f527a21b..18b989f5a 100644 --- a/backend/src/api/endpoints/v1_api_config.rs +++ b/backend/src/api/endpoints/v1_api_config.rs @@ -2,9 +2,9 @@ use crate::{api::{ api_utils::{internal_server_error, try_unwrap_body}, config_file::ConfigFile, model::AppState, -}, auth::{permission_layer, verify_token, AuthBearer}, iptv::xtream::{get_xtream_stream_url_base, xtream_login}, model::{validate_library_paths_from_dto, ApiProxyConfig, InputSource}, utils, utils::{ - persist_messaging_templates, prepare_sources_batch, prepare_users, read_api_proxy_file, - request::download_text_content, +}, auth::{permission_layer, verify_token, AuthBearer}, iptv::xtream::{get_xtream_stream_url_base, xtream_login}, model::{validate_library_paths_from_dto, ApiProxyConfig, InputSource, UserPlan}, utils, utils::{ + persist_messaging_templates, plans_file_path, prepare_sources_batch, prepare_users, read_api_proxy_file, + read_plans_file, save_plans, request::download_text_content, }}; use axum::{ http::{header::IF_MATCH, HeaderMap, HeaderName, HeaderValue, StatusCode}, @@ -17,7 +17,7 @@ use shared::model::InputFetchMethod; use shared::{ error::TuliproxError, model::permission::{Permission, PermissionSet}, - model::{ApiProxyConfigDto, ConfigDto, SourcesConfigDto, XtreamLoginRequest}, + model::{ApiProxyConfigDto, ConfigDto, PlansConfigDto, SourcesConfigDto, XtreamLoginRequest}, utils::{ parse_provider_scheme_url_parts, HEADER_CONFIG_API_PROXY_REVISION, HEADER_CONFIG_MAIN_REVISION, HEADER_CONFIG_SOURCES_REVISION, HEADER_IF_MATCH, PROVIDER_SCHEME_PREFIX, @@ -349,9 +349,18 @@ async fn save_config_api_proxy_config( ..base }; + // Full-config validation: catches duplicate server names, duplicate usernames/tokens + // and users referencing missing servers, which per-row validate() cannot see + let stored_plans = updated_api_proxy.plans.clone(); + let mut updated_api_proxy_dto = ApiProxyConfigDto::from(&updated_api_proxy); + if let Err(err) = updated_api_proxy_dto.prepare() { + return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))) + .into_response(); + } + if let Some(err) = intern_save_config_api_proxy( &backup_dir, - &ApiProxyConfigDto::from(&updated_api_proxy), + &updated_api_proxy_dto, &api_proxy_file_path, ) .await @@ -359,8 +368,11 @@ async fn save_config_api_proxy_config( return (axum::http::StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))) .into_response(); } - // Persist succeeded — now update in‑memory state - app_state.app_config.api_proxy.store(Some(Arc::new(updated_api_proxy))); + // Persist succeeded — now update in‑memory state with the prepared config. + // Plans live in plans.yml, so re-inject them (the DTO round-trip drops them). + let mut stored_api_proxy = ApiProxyConfig::from(&updated_api_proxy_dto); + stored_api_proxy.set_plans(stored_plans); + app_state.app_config.api_proxy.store(Some(Arc::new(stored_api_proxy))); let updated_revision = match read_file_revision(&api_proxy_file_path).await { Ok(revision) => revision, @@ -603,6 +615,53 @@ fn build_xtream_login_input_source( }) } +async fn get_config_plans( + axum::extract::State(app_state): axum::extract::State>, +) -> impl IntoResponse + Send { + let plans_path = { + let paths = app_state.app_config.paths.load(); + plans_file_path(paths.api_proxy_file_path.as_str()) + }; + let plans_path_str = plans_path.to_string_lossy().to_string(); + match read_plans_file(&plans_path_str, true) { + Ok(Some(dto)) => axum::response::Json(dto).into_response(), + Ok(None) => axum::response::Json(PlansConfigDto::default()).into_response(), + Err(err) => { + error!("Failed to read plans config: {err}"); + internal_server_error!() + } + } +} + +async fn save_config_plans( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Json(mut req_plans): axum::extract::Json, +) -> impl IntoResponse + Send { + if let Err(err) = req_plans.prepare() { + return (StatusCode::BAD_REQUEST, axum::Json(json!({"error": err.to_string()}))).into_response(); + } + let (plans_path_str, backup_dir) = { + let paths = app_state.app_config.paths.load(); + let config = app_state.app_config.config.load(); + ( + plans_file_path(paths.api_proxy_file_path.as_str()).to_string_lossy().to_string(), + config.get_backup_dir().to_string(), + ) + }; + let _lock = app_state.app_config.file_locks.write_lock(Path::new(&plans_path_str)).await; + if let Err(err) = save_plans(&plans_path_str, &backup_dir, &req_plans).await { + return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(json!({"error": err.to_string()}))).into_response(); + } + // Re-resolve users against the new plans without a restart. + if let Some(api_proxy) = app_state.app_config.api_proxy.load().as_deref() { + let mut updated = api_proxy.clone(); + let plans = req_plans.plans.iter().map(|plan| Arc::new(UserPlan::from(plan))).collect(); + updated.set_plans(plans); + app_state.app_config.api_proxy.store(Some(Arc::new(updated))); + } + StatusCode::OK.into_response() +} + pub fn v1_api_config_register(router: Router>) -> axum::Router> { router .route("/config", axum::routing::get(config_unprotected)) @@ -617,6 +676,10 @@ pub fn v1_api_config_register_with_permissions(app_state: &Arc) -> Rou .route("/config", axum::routing::get(config)) .route("/config/apiproxy", axum::routing::get(get_config_api_proxy_config)); + let config_read = Router::new() + .route("/config/plans", axum::routing::get(get_config_plans)) + .layer(permission_layer!(app_state, Permission::ConfigRead)); + // 2. Source Domain (Read & Write) let source_read = Router::new() .route("/config/batchContent/{input_id}", axum::routing::get(config_batch_content)) @@ -630,10 +693,12 @@ pub fn v1_api_config_register_with_permissions(app_state: &Arc) -> Rou let config_write = Router::new() .route("/config/main", axum::routing::post(save_config_main)) .route("/config/apiproxy", axum::routing::put(save_config_api_proxy_config)) + .route("/config/plans", axum::routing::put(save_config_plans)) .layer(permission_layer!(app_state, Permission::ConfigWrite)); Router::new() .merge(base_read) + .merge(config_read) .merge(source_read) .merge(source_write) .merge(config_write) diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index 4f51cbcb7..4ae1d2bd2 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -13,8 +13,8 @@ use crate::{ m3u_api::m3u_api_stream_loaded, xmltv_api::{rewrite_epg_channel_resource_url, serve_epg_web_ui, stream_epg_api}, xtream_api::{ - xtream_get_stream_info_response, xtream_player_api_stream_with_token, ApiStreamContext, - ApiStreamRequest, + xtream_get_stream_info_response, xtream_player_api_stream_with_resolved_target, + xtream_player_api_stream_with_token, ApiStreamContext, ApiStreamRequest, }, }, model::AppState, @@ -32,7 +32,10 @@ use crate::{ }, processor::re_resolve_stalker_url, }, - repository::{m3u_get_item_for_stream_id, xtream_get_item_for_stream_id}, + repository::{ + iter_raw_m3u_target_playlist, iter_raw_xtream_target_playlist, m3u_get_item_for_stream_id, + xtream_get_item_for_stream_id, + }, utils::{ epg::get_input_raw_epg_file_path, file_exists_async, @@ -41,17 +44,20 @@ use crate::{ }; use axum::{response::IntoResponse, Router}; use log::{debug, error}; +use serde::Deserialize; use serde_json::json; use shared::{ error::TuliproxError, + foundation::{get_filter_detailed, Filter, ValueProvider}, model::{ permission::Permission, stalker::StalkerStreamKind, EpgChannel, InputType, OperationRunAccepted, - PlaylistEpgRequest, PlaylistRequest, PlaylistUrlResolveRequest, ProxyType, TargetType, UiPlaylistItem, - XtreamCluster, + PlaylistEpgRequest, PlaylistItem, PlaylistRequest, PlaylistUrlResolveRequest, ProxyType, TargetType, + UiPlaylistItem, XtreamCluster, }, utils::{concat_path_leading_slash, deobfuscate_text, sanitize_sensitive_info, Internable}, }; use std::{path::Path, str::FromStr, sync::Arc}; +use tokio_stream::StreamExt; use url::Url; fn create_config_input_for_m3u(url: &str) -> ConfigInput { @@ -147,6 +153,216 @@ fn build_playlist_webplayer_url( ) } +fn build_recording_stream_url( + base_url: &str, + access_token: &str, + target_name: &str, + input_name: &str, + virtual_id: u32, + cluster: XtreamCluster, +) -> Option { + let mut url = Url::parse(base_url).ok()?; + url.path_segments_mut().ok()?.pop_if_empty().extend([ + "api", + "v1", + "playlist", + "recording", + access_token, + cluster.as_stream_type(), + &virtual_id.to_string(), + ]); + url.query_pairs_mut().append_pair("target_name", target_name).append_pair("input_name", input_name); + Some(url.into()) +} + +pub(in crate::api) fn build_recording_source_descriptor( + target_name: &str, + input_name: &str, + virtual_id: u32, + cluster: XtreamCluster, +) -> Option { + let mut url = Url::parse("tuliprox-recording://source").ok()?; + url.query_pairs_mut() + .append_pair("target_name", target_name) + .append_pair("input_name", input_name) + .append_pair("virtual_id", &virtual_id.to_string()) + .append_pair("cluster", cluster.as_stream_type()); + Some(url.into()) +} + +pub(in crate::api) fn build_webplayer_recording_url( + app_config: &crate::model::AppConfig, + target_id: u16, + virtual_id: u32, + cluster: XtreamCluster, +) -> Option { + let access_token = create_access_token(&app_config.access_token_secret, 30); + let config = app_config.config.load(); + let server_name = config + .web_ui + .as_ref() + .and_then(|web_ui| web_ui.player_server.as_ref()) + .map_or("default", |server_name| server_name.as_str()); + let server_info = app_config.get_server_info(server_name)?; + Some(build_playlist_webplayer_url( + &server_info.get_base_url(), + &access_token, + target_id, + virtual_id, + cluster, + )) +} + +pub(in crate::api) fn build_stable_recording_url( + app_config: &crate::model::AppConfig, + target_name: &str, + input_name: &str, + virtual_id: u32, + cluster: XtreamCluster, +) -> Option { + let access_token = create_access_token(&app_config.access_token_secret, 30); + let config = app_config.config.load(); + let server_name = config + .web_ui + .as_ref() + .and_then(|web_ui| web_ui.player_server.as_ref()) + .map_or("default", |server_name| server_name.as_str()); + let server_info = app_config.get_server_info(server_name)?; + build_recording_stream_url( + &server_info.get_base_url(), + &access_token, + target_name, + input_name, + virtual_id, + cluster, + ) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::api) struct ResolvedRecordingSource { + pub virtual_id: u32, + pub input_name: String, +} + +#[derive(Debug, Clone)] +pub(in crate::api) struct ResolvedRecordingConfig { + pub target: Arc, + pub input: Arc, +} + +pub(in crate::api) fn resolve_recording_config( + sources: &crate::model::SourcesConfig, + target_name: &str, + input_name: &str, +) -> Option { + let source = sources.sources.iter().find(|source| { + source.inputs.iter().any(|configured_input| configured_input.as_ref() == input_name) + && source.targets.iter().any(|target| target.name == target_name) + })?; + let target = source.targets.iter().find(|target| target.name == target_name)?.clone(); + let input = sources.inputs.iter().find(|input| input.name.as_ref() == input_name)?.clone(); + Some(ResolvedRecordingConfig { target, input }) +} + +pub(in crate::api) fn resolve_recording_target( + app_config: &crate::model::AppConfig, + target_name: &str, + input_name: &str, +) -> Option> { + resolve_recording_config(app_config.sources.load().as_ref(), target_name, input_name) + .map(|resolved| resolved.target) +} + +pub(in crate::api) async fn resolve_target_recording_source( + app_config: &crate::model::AppConfig, + target_name: &str, + input_name: &str, + virtual_id: u32, + cluster: XtreamCluster, +) -> Option { + let target = resolve_recording_target(app_config, target_name, input_name)?; + let mut resolved = None; + if target.has_output(TargetType::Xtream) { + if let Some(mut items) = iter_raw_xtream_target_playlist(app_config, &target, cluster).await { + while let Some(entry) = items.next().await { + let Ok(item) = entry else { continue }; + if item.virtual_id == virtual_id { + resolved = Some(ResolvedRecordingSource { + virtual_id: item.virtual_id, + input_name: item.input_name.to_string(), + }); + break; + } + } + } + } + if resolved.is_none() && target.has_output(TargetType::M3u) { + if let Some(mut items) = iter_raw_m3u_target_playlist(app_config, &target, Some(cluster)).await { + while let Some(entry) = items.next().await { + let Ok(item) = entry else { continue }; + if item.virtual_id == virtual_id { + resolved = Some(ResolvedRecordingSource { + virtual_id: item.virtual_id, + input_name: item.input_name.to_string(), + }); + break; + } + } + } + } + let resolved = resolved?; + (resolved.input_name == input_name).then_some(resolved) +} + +pub(in crate::api) async fn resolve_target_live_recording_source_by_epg_channel( + app_config: &crate::model::AppConfig, + target_name: &str, + epg_channel_id: &str, +) -> Option { + let targets = app_config + .sources + .load() + .sources + .iter() + .flat_map(|source| source.targets.iter()) + .filter(|target| target.name == target_name) + .cloned() + .collect::>(); + let mut resolved = None; + for target in targets { + if target.has_output(TargetType::Xtream) { + let mut items = iter_raw_xtream_target_playlist(app_config, &target, XtreamCluster::Live).await?; + while let Some(entry) = items.next().await { + let Ok(item) = entry else { continue }; + if item.epg_channel_id.as_deref() == Some(epg_channel_id) { + let candidate = ResolvedRecordingSource { + virtual_id: item.virtual_id, + input_name: item.input_name.to_string(), + }; + if resolved.replace(candidate).is_some() { + return None; + } + } + } + } else if target.has_output(TargetType::M3u) { + let mut items = iter_raw_m3u_target_playlist(app_config, &target, Some(XtreamCluster::Live)).await?; + while let Some(entry) = items.next().await { + let Ok(item) = entry else { continue }; + if item.epg_channel_id.as_deref() == Some(epg_channel_id) { + let candidate = ResolvedRecordingSource { + virtual_id: item.virtual_id, + input_name: item.input_name.to_string(), + }; + if resolved.replace(candidate).is_some() { + return None; + } + } + } + } + } + resolved +} + #[cfg(test)] fn merge_epg_channels(mut channels_by_source: Vec<(i16, Vec)>) -> Vec { channels_by_source.sort_by_key(|(priority, _)| *priority); @@ -507,19 +723,10 @@ fn playlist_webplayer( virtual_id: u32, cluster: XtreamCluster, ) -> impl axum::response::IntoResponse + Send { - let access_token = create_access_token(&app_state.app_config.access_token_secret, 30); - let config = app_state.app_config.config.load(); - let server_name = config - .web_ui - .as_ref() - .and_then(|web_ui| web_ui.player_server.as_ref()) - .map_or("default", |server_name| server_name.as_str()); - let server_info = app_state.app_config.get_server_info(server_name); - let Some(server_info) = server_info else { + let Some(url) = build_webplayer_recording_url(&app_state.app_config, target_id, virtual_id, cluster) else { return axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response(); }; - let base_url = server_info.get_base_url(); - build_playlist_webplayer_url(&base_url, &access_token, target_id, virtual_id, cluster).into_response() + url.into_response() } async fn playlist_webplayer_stream( @@ -571,6 +778,72 @@ async fn playlist_webplayer_stream( .into_response() } +#[derive(Debug, Deserialize)] +struct RecordingStreamQuery { + target_name: String, + input_name: String, +} + +async fn playlist_recording_stream( + fingerprint: crate::auth::Fingerprint, + axum::extract::Path((token, cluster, virtual_id)): axum::extract::Path<(String, String, u32)>, + axum::extract::Query(query): axum::extract::Query, + axum::extract::State(app_state): axum::extract::State>, + req_headers: axum::http::HeaderMap, +) -> impl IntoResponse + Send { + if !verify_access_token(&token, &app_state.app_config.access_token_secret) { + return axum::http::StatusCode::FORBIDDEN.into_response(); + } + let ctxt = try_result_bad_request!(ApiStreamContext::from_str(cluster.as_str())); + let resolved = { + let sources = app_state.app_config.sources.load(); + resolve_recording_config(sources.as_ref(), &query.target_name, &query.input_name) + }; + let Some(resolved) = resolved else { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + }; + + if resolved.target.has_output(TargetType::Xtream) { + let stream_id = virtual_id.to_string(); + return xtream_player_api_stream_with_resolved_target( + &fingerprint, + &req_headers, + &app_state, + resolved.target, + Some(resolved.input), + ApiStreamRequest::from_access_token(ctxt, &token, &stream_id, ""), + ) + .await + .into_response(); + } + if !resolved.target.has_output(TargetType::M3u) { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + + let pli = try_result_bad_request!( + m3u_get_item_for_stream_id(virtual_id, &app_state, &resolved.target).await, + true, + format!("Failed to read m3u item for stream id {virtual_id}") + ); + if pli.input_name != resolved.input.name || XtreamCluster::try_from(pli.item_type).ok() != Some(ctxt.cluster()) { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + let user = Arc::new(create_api_proxy_user(&app_state)); + m3u_api_stream_loaded( + user, + resolved.target, + &fingerprint, + &req_headers, + &app_state, + pli, + resolved.input, + None, + None, + ) + .await + .into_response() +} + async fn playlist_epg( ExtractAcceptHeader(accept): ExtractAcceptHeader, axum::extract::State(app_state): axum::extract::State>, @@ -730,6 +1003,171 @@ async fn playlist_resolve_url( } } +const FILTER_PREVIEW_DEFAULT_SAMPLES: u16 = 25; +const FILTER_PREVIEW_MAX_SAMPLES: u16 = 50; + +#[derive(serde::Deserialize)] +struct FilterPreviewRequest { + target: u16, + filter: String, + #[serde(default)] + limit: Option, + #[serde(default)] + match_as_ascii: bool, +} + +#[derive(serde::Serialize)] +struct FilterPreviewItem { + name: String, + group: String, + item_type: String, +} + +impl From<&PlaylistItem> for FilterPreviewItem { + fn from(pli: &PlaylistItem) -> Self { + let header = &pli.header; + Self { + name: header.name.to_string(), + group: header.group.to_string(), + item_type: header.item_type.as_str().to_string(), + } + } +} + +#[derive(serde::Serialize, Default)] +struct FilterPreviewClusterStats { + total: usize, + matched: usize, +} + +#[derive(serde::Serialize, Default)] +struct FilterPreviewResponse { + total: usize, + matched: usize, + live: FilterPreviewClusterStats, + vod: FilterPreviewClusterStats, + series: FilterPreviewClusterStats, + sample_matched: Vec, + sample_excluded: Vec, +} + +impl FilterPreviewResponse { + fn observe(&mut self, pli: &PlaylistItem, filter: &Filter, match_as_ascii: bool, sample_limit: usize) { + let cluster_stats = match pli.header.xtream_cluster { + XtreamCluster::Live => &mut self.live, + XtreamCluster::Video => &mut self.vod, + XtreamCluster::Series => &mut self.series, + }; + self.total += 1; + cluster_stats.total += 1; + let provider = ValueProvider { pli, match_as_ascii }; + if filter.filter(&provider) { + self.matched += 1; + cluster_stats.matched += 1; + if self.sample_matched.len() < sample_limit { + self.sample_matched.push(FilterPreviewItem::from(pli)); + } + } else if self.sample_excluded.len() < sample_limit { + self.sample_excluded.push(FilterPreviewItem::from(pli)); + } + } +} + +/// Dry-run a filter DSL expression against a target's stored playlist +/// without touching processing or provider fetches. +async fn playlist_filter_preview( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Json(req): axum::extract::Json, +) -> impl IntoResponse + Send { + let filter = { + let sources = app_state.app_config.sources.load(); + match get_filter_detailed(&req.filter, sources.templates.as_deref()) { + Ok(filter) => filter, + Err((err, position)) => { + return ( + axum::http::StatusCode::UNPROCESSABLE_ENTITY, + axum::Json(json!({ + "error": err.to_string(), + "line": position.map(|p| p.line), + "column": position.map(|p| p.column), + })), + ) + .into_response() + } + } + }; + let Some(target) = app_state.app_config.get_target_by_id(req.target) else { + return (axum::http::StatusCode::BAD_REQUEST, axum::Json(json!({"error": "Unknown target"}))).into_response(); + }; + let sample_limit = usize::from(req.limit.unwrap_or(FILTER_PREVIEW_DEFAULT_SAMPLES).min(FILTER_PREVIEW_MAX_SAMPLES)); + + let mut response = FilterPreviewResponse::default(); + if target.has_output(TargetType::Xtream) { + let mut any_cluster_read = false; + for cluster in [XtreamCluster::Live, XtreamCluster::Video, XtreamCluster::Series] { + if let Some(mut iterator) = iter_raw_xtream_target_playlist(&app_state.app_config, &target, cluster).await + { + any_cluster_read = true; + while let Some(entry) = iterator.next().await { + match entry { + Ok(item) => { + let pli = PlaylistItem::from(&item); + response.observe(&pli, &filter, req.match_as_ascii, sample_limit); + } + Err(err) => { + error!("Filter preview failed to read stored {cluster} playlist: {err}"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": "Failed to read stored playlist"})), + ) + .into_response(); + } + } + } + } + } + if !any_cluster_read { + return ( + axum::http::StatusCode::NOT_FOUND, + axum::Json(json!({"error": "Stored playlist is not available, update the playlist first"})), + ) + .into_response(); + } + } else if target.has_output(TargetType::M3u) { + let Some(mut iterator) = iter_raw_m3u_target_playlist(&app_state.app_config, &target, None).await else { + return ( + axum::http::StatusCode::NOT_FOUND, + axum::Json(json!({"error": "Stored playlist is not available, update the playlist first"})), + ) + .into_response(); + }; + while let Some(entry) = iterator.next().await { + match entry { + Ok(item) => { + let pli = PlaylistItem::from(&item); + response.observe(&pli, &filter, req.match_as_ascii, sample_limit); + } + Err(err) => { + error!("Filter preview failed to read stored m3u playlist: {err}"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(json!({"error": "Failed to read stored playlist"})), + ) + .into_response(); + } + } + } + } else { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(json!({"error": "Target has no xtream or m3u output to preview"})), + ) + .into_response(); + } + + axum::Json(response).into_response() +} + pub fn v1_api_playlist_register_protected(router: Router>) -> axum::Router> { router .route("/playlist/resolve_url", axum::routing::post(playlist_resolve_url)) @@ -741,13 +1179,20 @@ pub fn v1_api_playlist_register_protected(router: Router>) -> axum .route("/playlist/series", axum::routing::post(playlist_content_series)) .route("/playlist/series_info/{virtual_id}/{provider_id}", axum::routing::post(playlist_series_info)) .route("/playlist/series/episode/{virtual_id}", axum::routing::post(playlist_episode_item)) + .route("/playlist/filter/preview", axum::routing::post(playlist_filter_preview)) } pub fn v1_api_playlist_register_public(router: Router>) -> axum::Router> { - router.route("/playlist/resource/{resource}", axum::routing::get(playlist_resource)).route( - "/playlist/webplayer/{token}/{target_id}/{cluster}/{stream_id}", - axum::routing::get(playlist_webplayer_stream), - ) + router + .route("/playlist/resource/{resource}", axum::routing::get(playlist_resource)) + .route( + "/playlist/webplayer/{token}/{target_id}/{cluster}/{stream_id}", + axum::routing::get(playlist_webplayer_stream), + ) + .route( + "/playlist/recording/{token}/{cluster}/{virtual_id}", + axum::routing::get(playlist_recording_stream), + ) } pub fn v1_api_playlist_register_with_permissions( @@ -761,6 +1206,7 @@ pub fn v1_api_playlist_register_with_permissions( .route("/resolve_url", axum::routing::post(playlist_resolve_url)) .route("/series_info/{virtual_id}/{provider_id}", axum::routing::post(playlist_series_info)) .route("/series/episode/{virtual_id}", axum::routing::post(playlist_episode_item)) + .route("/filter/preview", axum::routing::post(playlist_filter_preview)) .layer(permission_layer!(app_state, Permission::PlaylistRead)); let write_routes = Router::new() @@ -798,15 +1244,15 @@ async fn playlist_episode_item( #[cfg(test)] mod tests { - use super::resolve_provider_url_for_request; + use super::{resolve_provider_url_for_request, resolve_recording_config}; use crate::{ api::model::{ - ActiveProviderManager, ActiveUserManager, AppState, ConnectionManager, EventManager, MetadataUpdateManager, - PlaylistStorageState, SharedStreamManager, + ActiveProviderManager, ActiveUserManager, AppState, ConnectionManager, DownloadQueue, EventManager, + MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, }, model::{ AppConfig, Config, ConfigInput, ConfigProvider, ConfigSource, ConfigTarget, SourcesConfig, - StreamHistoryConfig, + StreamHistoryConfig, VideoDownloadConfig, }, utils::{ epg::{get_input_raw_epg_file_path, get_input_raw_xmltv_file_path}, @@ -815,7 +1261,7 @@ mod tests { }; use arc_swap::{ArcSwap, ArcSwapOption}; use axum::{ - extract::Query, + extract::{Path as AxumPath, Query, State}, body::Body, http::{Request, StatusCode}, response::IntoResponse, @@ -837,6 +1283,55 @@ mod tests { use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use tower::ServiceExt; + use url::Url; + + #[tokio::test] + async fn stable_recording_route_rejects_target_and_input_from_different_sources() { + let app_config = Arc::new(test_app_config( + Arc::new(ConfigInput { id: 7, name: "input-a".intern(), ..Default::default() }), + ConfigSource { + inputs: vec!["input-a".intern()], + targets: vec![Arc::new(ConfigTarget { + id: 11, + enabled: true, + name: "stable-target".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: vec![], + rename: None, + mapping_ids: None, + mapping: Arc::default(), + favourites: None, + processing_order: ProcessingOrder::default(), + watch: None, + use_memory_cache: false, + })], + }, + )); + let app_state = test_app_state(Arc::clone(&app_config)); + let token = crate::auth::create_access_token(&app_config.access_token_secret, 1); + let fingerprint = crate::auth::Fingerprint::new( + "test".to_string(), + "127.0.0.1".to_string(), + "127.0.0.1:1234".parse().expect("test socket address"), + ); + + let response = super::playlist_recording_stream( + fingerprint, + AxumPath((token, "live".to_string(), 42)), + Query(super::RecordingStreamQuery { + target_name: "stable-target".to_string(), + input_name: "input-b".to_string(), + }), + State(app_state), + axum::http::HeaderMap::new(), + ) + .await + .into_response(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } /// Generate an XMLTV datetime string in the format `YYYYMMDDHHmmss +0000` /// offset by `hours_from_now` hours from the current time. @@ -896,6 +1391,60 @@ mod tests { } } + #[test] + fn recording_target_resolution_uses_stable_name_and_input_across_runtime_ids() { + let input_a = Arc::new(ConfigInput { id: 7, name: "input-a".intern(), ..Default::default() }); + let input_b = Arc::new(ConfigInput { id: 8, name: "input-b".intern(), ..Default::default() }); + let target = |id, name: &str| Arc::new(ConfigTarget { + id, + enabled: true, + name: name.to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: vec![], + rename: None, + mapping_ids: None, + mapping: Arc::default(), + favourites: None, + processing_order: ProcessingOrder::default(), + watch: None, + use_memory_cache: false, + }); + let sources = |later_target_id| SourcesConfig { + batch_files: vec![], + provider: vec![], + inputs: vec![Arc::clone(&input_a), Arc::clone(&input_b)], + sources: vec![ + ConfigSource { inputs: vec!["input-a".intern()], targets: vec![target(11, "target-a")] }, + ConfigSource { + inputs: vec!["input-b".intern()], + targets: vec![target(later_target_id, "stable-target")], + }, + ], + templates: None, + }; + let mut app_config = test_app_config( + Arc::new(ConfigInput { id: 0, name: "unused".intern(), ..Default::default() }), + ConfigSource { inputs: vec![], targets: vec![] }, + ); + app_config.sources = Arc::new(ArcSwap::from_pointee(sources(12))); + + let snapshot = app_config.sources.load(); + let resolved = resolve_recording_config(snapshot.as_ref(), "stable-target", "input-b") + .expect("stable source in first snapshot"); + assert_eq!(resolved.target.id, 12); + assert_eq!(resolved.input.name.as_ref(), "input-b"); + assert!(resolve_recording_config(snapshot.as_ref(), "stable-target", "input-a").is_none()); + drop(snapshot); + + app_config.sources.store(Arc::new(sources(37))); + let snapshot = app_config.sources.load(); + let resolved = resolve_recording_config(snapshot.as_ref(), "stable-target", "input-b") + .expect("stable source after reload"); + assert_eq!(resolved.target.id, 37); + } + fn test_app_state(app_cfg: Arc) -> Arc { let event_manager = Arc::new(EventManager::new()); let active_provider = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager)); @@ -1229,6 +1778,93 @@ mod tests { assert_eq!(series, "http://player.example/api/v1/playlist/webplayer/token123/1/series/42"); } + #[test] + fn build_recording_stream_url_encodes_stable_names_without_runtime_id() { + let url = super::build_recording_stream_url( + "http://player.example/base", + "token123", + "News/HD &+", + "input/name ?+", + 42, + XtreamCluster::Live, + ) + .expect("valid recording url"); + let parsed = Url::parse(&url).expect("parse recording url"); + let query = parsed.query_pairs().collect::>(); + + assert_eq!(parsed.path(), "/base/api/v1/playlist/recording/token123/live/42"); + assert_eq!(query.get("target_name").map(std::convert::AsRef::as_ref), Some("News/HD &+")); + assert_eq!(query.get("input_name").map(std::convert::AsRef::as_ref), Some("input/name ?+")); + assert!(!parsed.path().contains("/11/")); + } + + #[test] + fn recording_source_descriptor_is_token_free_and_percent_encodes_names() { + let url = super::build_recording_source_descriptor( + "News/HD &+", + "input/name ?+", + 42, + XtreamCluster::Video, + ) + .expect("valid recording source descriptor"); + let parsed = Url::parse(&url).expect("parse recording source descriptor"); + let query = parsed.query_pairs().collect::>(); + + assert_eq!(parsed.scheme(), "tuliprox-recording"); + assert_eq!(parsed.host_str(), Some("source")); + assert_eq!(query.get("target_name").map(std::convert::AsRef::as_ref), Some("News/HD &+")); + assert_eq!(query.get("input_name").map(std::convert::AsRef::as_ref), Some("input/name ?+")); + assert_eq!(query.get("virtual_id").map(std::convert::AsRef::as_ref), Some("42")); + assert_eq!(query.get("cluster").map(std::convert::AsRef::as_ref), Some("movie")); + assert!(!url.contains("token")); + } + + #[test] + fn future_scheduled_recording_descriptor_round_trips_without_token() { + let url = super::build_recording_source_descriptor( + "stable-target", + "input-a", + 42, + XtreamCluster::Live, + ) + .expect("valid recording url"); + let download_cfg = VideoDownloadConfig { + directory: "/tmp".to_string(), + organize_into_directories: false, + episode_pattern: None, + headers: HashMap::new(), + download_priority: 0, + recording_priority: 0, + reserve_slots_for_users: 0, + max_background_per_provider: 0, + retry_backoff_initial_secs: 3, + retry_backoff_multiplier: 3.0, + retry_backoff_max_secs: 30, + retry_backoff_jitter_percent: 0, + retry_max_attempts: 5, + recording: None, + }; + let recording = crate::api::model::FileDownload::new_recording( + &url, + "recording.ts", + &download_cfg, + 1_700_000_000, + 3600, + Some("input-a".intern()), + 0, + ) + .expect("valid recording task"); + let persisted = DownloadQueue::to_persisted(&recording); + let restored = DownloadQueue::from_persisted(persisted.clone()).expect("restore recording task"); + + assert_eq!(persisted.url, url); + assert_eq!(restored.url.as_str(), url); + assert!(persisted.url.starts_with("tuliprox-recording://source?")); + assert!(!persisted.url.contains("token")); + assert!(!persisted.url.contains("/11/")); + assert!(persisted.url.contains("target_name=stable-target")); + } + #[test] fn merge_epg_channels_prefers_higher_priority_metadata_and_fills_lower_priority_gaps() { let low_priority = EpgChannel { diff --git a/backend/src/api/endpoints/v1_api_user.rs b/backend/src/api/endpoints/v1_api_user.rs index 535305133..aeeda3982 100644 --- a/backend/src/api/endpoints/v1_api_user.rs +++ b/backend/src/api/endpoints/v1_api_user.rs @@ -38,6 +38,37 @@ async fn save_config_api_proxy_user( ApiProxyConfig::default() }; + if let Some(plan) = credential.plan.as_ref() { + if !api_proxy.plans.iter().any(|p| p.name == *plan) { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(json!({"error": format!("Unknown user plan {plan}")})), + ) + .into_response(); + } + } + // Trial plans: new users without an explicit expiry get the trial window. + if !is_update { + if let Some(plan) = + credential.plan.as_ref().and_then(|name| api_proxy.plans.iter().find(|p| p.name == *name)) + { + if let Some(trial_secs) = plan.t_trial_duration_secs { + if credential.exp_date.is_none() { + let expires = chrono::Utc::now().timestamp().saturating_add(i64::try_from(trial_secs).unwrap_or(i64::MAX)); + credential.exp_date = Some(expires); + } + if credential.status.is_none() { + credential.status = Some(shared::model::ProxyUserStatus::Trial); + } + } + } + } + let new_user = { + let mut user = ProxyUserCredentials::from(&credential); + user.resolve_plan(&api_proxy.plan_map()); + Arc::new(user) + }; + // ---------- Search for existing Target and existing User ---------- let mut existing_target_index: Option = None; // index of target (target_name), if present let mut existing_user_target_index: Option = None; // index of existing users target @@ -99,11 +130,11 @@ async fn save_config_api_proxy_user( if user_target_idx == target_idx { // Update - api_proxy.user[user_target_idx].credentials[user_idx] = Arc::new(ProxyUserCredentials::from(&credential)); + api_proxy.user[user_target_idx].credentials[user_idx] = Arc::clone(&new_user); } else { // Move: remove from old target and insert into new target api_proxy.user[user_target_idx].credentials.remove(user_idx); - api_proxy.user[target_idx].credentials.push(Arc::new(ProxyUserCredentials::from(&credential))); + api_proxy.user[target_idx].credentials.push(Arc::clone(&new_user)); remove_empty_target = api_proxy.user[user_target_idx].credentials.is_empty(); } @@ -112,7 +143,7 @@ async fn save_config_api_proxy_user( } } else { // new user - api_proxy.user[target_idx].credentials.push(Arc::new(ProxyUserCredentials::from(&credential))); + api_proxy.user[target_idx].credentials.push(Arc::clone(&new_user)); } let new_api_proxy = Arc::new(api_proxy); diff --git a/backend/src/api/endpoints/web_index.rs b/backend/src/api/endpoints/web_index.rs index eddb9cde4..ec5d958dd 100644 --- a/backend/src/api/endpoints/web_index.rs +++ b/backend/src/api/endpoints/web_index.rs @@ -48,17 +48,13 @@ async fn token( if verify_password(hash, password.as_bytes()) { let pwd_version = WebAuthConfig::pwd_version_from_hash(hash); let permissions = web_auth.resolve_permissions(username); - let is_admin = web_auth + let user_entry = web_auth .t_users .as_ref() - .and_then(|users| users.iter().find(|user| user.username.eq_ignore_ascii_case(username))) - .is_some_and(|user| user.groups.iter().any(|group| group.eq_ignore_ascii_case("admin"))); - let user_groups = web_auth - .t_users - .as_ref() - .and_then(|users| users.iter().find(|user| user.username.eq_ignore_ascii_case(username))) - .map(|user| user.groups.clone()) - .unwrap_or_default(); + .and_then(|users| users.iter().find(|user| user.username.eq_ignore_ascii_case(username))); + let is_admin = + user_entry.is_some_and(|user| user.groups.iter().any(|group| group.eq_ignore_ascii_case("admin"))); + let user_groups = user_entry.map(|user| user.groups.clone()).unwrap_or_default(); debug!( "Web login success candidate: username='{username}', groups={user_groups:?}, is_admin={is_admin}, permissions={permissions}", ); @@ -74,7 +70,7 @@ async fn token( } } if let Some(credentials) = app_state.app_config.get_user_credentials(username) { - if credentials.password == password { + if crate::auth::constant_time_eq(credentials.password.as_bytes(), password.as_bytes()) { if !api_user_can_access_web_ui(credentials.ui_enabled) { req.zeroize(); return axum::http::StatusCode::FORBIDDEN.into_response(); @@ -369,6 +365,9 @@ pub fn index_register_with_path(web_dir_path: &Path, web_ui_path: &str) -> axum: if path.starts_with(&path_prefix) { path = path[path_prefix.len()..].to_string(); } + if path.is_empty() { + path = "/".to_string(); + } let mut builder = axum::http::Uri::builder(); if let Some(scheme) = req.uri().scheme() { @@ -377,10 +376,22 @@ pub fn index_register_with_path(web_dir_path: &Path, web_ui_path: &str) -> axum: if let Some(authority) = req.uri().authority() { builder = builder.authority(authority.clone()); } - let new_uri = builder.path_and_query(path).build().unwrap(); - - let new_req = - axum::http::Request::builder().method(req.method()).uri(new_uri).body(req.into_body()).unwrap(); + // A malformed rewritten path must not panic the connection task; serve the original request instead + let new_req = match builder.path_and_query(path).build() { + Ok(new_uri) => { + match axum::http::Request::builder().method(req.method()).uri(new_uri).body(req.into_body()) { + Ok(new_req) => new_req, + Err(err) => { + log::warn!("Failed to rebuild web ui fallback request: {err}"); + return serve_dir.call(axum::http::Request::new(axum::body::Body::empty())); + } + } + } + Err(err) => { + log::warn!("Failed to rebuild web ui fallback uri: {err}"); + return serve_dir.call(axum::http::Request::new(axum::body::Body::empty())); + } + }; serve_dir.call(new_req) } diff --git a/backend/src/api/endpoints/websocket_api.rs b/backend/src/api/endpoints/websocket_api.rs index 7135547c9..6cd3dd918 100644 --- a/backend/src/api/endpoints/websocket_api.rs +++ b/backend/src/api/endpoints/websocket_api.rs @@ -3,7 +3,7 @@ use crate::{ endpoints::{download_api::download_queue_snapshot, v1_api::create_status_check}, model::{AppState, EventMessage}, }, - auth::verify_token, + auth::{validate_token_claims, verify_token}, }; use axum::{ extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}, @@ -11,12 +11,12 @@ use axum::{ }; use log::{error, trace}; use shared::{ + defaults::default_kick_secs, model::{ - Permission, ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage, UserCommand, UserRole, WsCloseCode, - PERM_ALL, PROTOCOL_VERSION, ROLE_ADMIN, + Claims, Permission, ProtocolHandler, ProtocolHandlerMemory, ProtocolMessage, UserCommand, UserId, UserRole, + WsCloseCode, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, PROTOCOL_VERSION, ROLE_ADMIN, TOKEN_NO_AUTH, }, - utils::{concat_path_leading_slash}, - defaults::{default_kick_secs}, + utils::concat_path_leading_slash, }; use std::{fmt, io, sync::Arc}; @@ -86,14 +86,37 @@ pub fn ws_api_register(web_auth_enabled: bool, web_ui_path: &str) -> axum::Route } #[inline] -fn set_websocket_auth(mem: &mut ProtocolHandlerMemory, auth_token: String, claims: &shared::model::Claims) { +fn set_websocket_auth(mem: &mut ProtocolHandlerMemory, auth_token: String, claims: &Claims) -> bool { + if validate_token_claims(claims).is_err() { + return false; + } mem.permissions = claims.permissions; - mem.role = if claims.roles.iter().any(|role| role == ROLE_ADMIN) { - UserRole::Admin - } else { - UserRole::User - }; + mem.role = if claims.roles.iter().any(|role| role == ROLE_ADMIN) { UserRole::Admin } else { UserRole::User }; + mem.subject_id = claims.subject_id.as_ref().map(|u| u.0.clone()); mem.token = Some(auth_token); + true +} + +fn set_no_auth_websocket_identity(mem: &mut ProtocolHandlerMemory, auth_token: Option) { + mem.permissions = PERM_ALL; + mem.role = UserRole::Admin; + mem.subject_id = Some(UserId::BUILTIN_ADMIN_NAMESPACE.to_string()); + mem.token = auth_token; +} + +fn websocket_claims(mem: &ProtocolHandlerMemory) -> Option { + let subject_id = mem.subject_id.as_ref().map(|subject| UserId::from(subject.clone()))?; + Some(Claims { + username: "__ws__".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: i64::MAX, + roles: if mem.role == UserRole::Admin { vec![ROLE_ADMIN.to_string()] } else { Vec::new() }, + permissions: mem.permissions, + pwd_version: 0, + subject_id: Some(subject_id), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + }) } #[inline] @@ -111,6 +134,9 @@ fn websocket_can_receive_runtime_events(mem: &ProtocolHandlerMemory, event: &Eve EventMessage::DownloadsUpdate(_) | EventMessage::DownloadsDeltaUpdate(_) => { mem.permissions.contains(Permission::DownloadRead) } + EventMessage::RecordingChanged | EventMessage::RecordingRulesChanged => { + mem.permissions.contains(Permission::RecordingRead) + } EventMessage::PlaylistUpdateProgress(_) | EventMessage::PlaylistUpdate(_) => { mem.permissions.contains(Permission::PlaylistWrite) } @@ -183,9 +209,7 @@ async fn handle_protocol_message( match ProtocolMessage::from_bytes(bytes) { Ok(ProtocolMessage::Auth(auth_token)) => { if !auth_required { - mem.permissions = PERM_ALL; - mem.role = UserRole::Admin; - mem.token = Some(auth_token); + set_no_auth_websocket_identity(mem, Some(TOKEN_NO_AUTH.to_string())); return Some(ProtocolMessage::Authorized); } @@ -197,8 +221,11 @@ async fn handle_protocol_message( return Some(ProtocolMessage::Unauthorized); }; - set_websocket_auth(mem, auth_token, &token_data.claims); - Some(ProtocolMessage::Authorized) + if set_websocket_auth(mem, auth_token, &token_data.claims) { + Some(ProtocolMessage::Authorized) + } else { + Some(ProtocolMessage::Unauthorized) + } } Ok(ProtocolMessage::StatusRequest(auth_token)) => { if auth_required { @@ -214,7 +241,9 @@ async fn handle_protocol_message( return Some(ProtocolMessage::Unauthorized); } - set_websocket_auth(mem, auth_token, &token_data.claims); + if !set_websocket_auth(mem, auth_token, &token_data.claims) { + return Some(ProtocolMessage::Unauthorized); + } } let status = create_status_check(app_state).await; @@ -238,6 +267,13 @@ async fn handle_protocol_message( Some(ProtocolMessage::Unauthorized) } } + Ok(ProtocolMessage::RecordingSnapshotRequest) => { + if let Some(claims) = websocket_claims(mem) { + Some(recording_frame_for_session(app_state, &claims).await) + } else { + Some(ProtocolMessage::Unauthorized) + } + } Ok(ProtocolMessage::StreamMeterSubscribe) => { handle_stream_meter_subscribe(mem, app_state, auth_required); None @@ -264,7 +300,10 @@ async fn handle_protocol_message( } fn handle_stream_meter_subscribe(mem: &mut ProtocolHandlerMemory, app_state: &Arc, auth_required: bool) { - if websocket_requires_system_read(auth_required, mem) && (!auth_required || mem.token.is_some()) && !mem.stream_meter_subscribed { + if websocket_requires_system_read(auth_required, mem) + && (!auth_required || mem.token.is_some()) + && !mem.stream_meter_subscribed + { mem.stream_meter_subscribed = true; app_state.event_manager.stream_meter_subscriber_connected(); } @@ -291,17 +330,16 @@ async fn handle_active_provider_count_request( let Some(token_data) = verify_token(&auth_token, secret_key.as_slice()) else { return Some(ProtocolMessage::Unauthorized); }; - if token_data.claims.permissions.contains(Permission::SystemRead) { - set_websocket_auth(mem, auth_token, &token_data.claims); + if token_data.claims.permissions.contains(Permission::SystemRead) + && set_websocket_auth(mem, auth_token, &token_data.claims) + { let connections = app_state.active_provider.get_provider_connections_count().await; Some(ProtocolMessage::ActiveProviderCountResponse(connections)) } else { Some(ProtocolMessage::Unauthorized) } } else { - mem.permissions = PERM_ALL; - mem.role = UserRole::Admin; - mem.token = Some(auth_token); + set_no_auth_websocket_identity(mem, Some(TOKEN_NO_AUTH.to_string())); let connections = app_state.active_provider.get_provider_connections_count().await; Some(ProtocolMessage::ActiveProviderCountResponse(connections)) } @@ -322,8 +360,7 @@ async fn handle_incoming_message( handle_handshake(msg, socket, *version).await?; let mut mem = ProtocolHandlerMemory::default(); if !auth_required { - mem.permissions = PERM_ALL; - mem.role = UserRole::Admin; + set_no_auth_websocket_identity(&mut mem, Some(TOKEN_NO_AUTH.to_string())); } *handler = ProtocolHandler::Default(mem); Ok(()) @@ -344,7 +381,51 @@ async fn handle_incoming_message( } } +/// The frame to send a session that asked for recordings. +/// +/// Three outcomes, and the distinction is the point: +/// +/// - the DVR is switched off, or the token is too old to trust → +/// `RecordingWsError { code }`, so the client can act. Both used to +/// come back as an empty task list, indistinguishable from "you have +/// no recordings"; +/// - the principal has no `recording.read` → an empty snapshot, which is +/// the honest answer and needs no error; +/// - otherwise → the session-filtered snapshot. +async fn recording_frame_for_session(app_state: &AppState, claims: &Claims) -> ProtocolMessage { + use crate::api::model::recording::recording_ws::{recording_view_denial, RecordingViewDenial}; + + // `recording.enabled: false` gates the REST routes and the + // schedulers; the socket has to agree, or a client would keep + // receiving live recording data while every REST call answered + // `501 recording_disabled`. + if !crate::api::model::recording::recording_supervisor::recording_enabled(app_state) { + return ProtocolMessage::RecordingWsError { code: "recording_disabled".to_string() }; + } + if let Some(RecordingViewDenial::TokenRefreshRequired) = recording_view_denial(claims) { + return ProtocolMessage::RecordingWsError { + code: RecordingViewDenial::TokenRefreshRequired.code().to_string(), + }; + } + let (revision, tasks) = + crate::api::model::recording::recording_ws::recording_snapshot(&app_state.downloads, claims).await; + ProtocolMessage::RecordingSnapshotResponse { revision, tasks } +} + +async fn send_recording_snapshot_event( + app_state: &AppState, + socket: &mut WebSocket, + mem: &ProtocolHandlerMemory, +) -> Result<(), WebSocketApiError> { + if let Some(claims) = websocket_claims(mem) { + let frame = recording_frame_for_session(app_state, &claims).await; + send_event_response(socket, frame, "Recording snapshot event").await?; + } + Ok(()) +} + async fn handle_event_message( + app_state: &Arc, socket: &mut WebSocket, event: EventMessage, handler: &ProtocolHandler, @@ -425,8 +506,20 @@ async fn handle_event_message( ) .await?; } - EventMessage::InputMetadataUpdatesCompleted(_) - | EventMessage::InputMetadataUpdatesStarted(_) => { + EventMessage::RecordingChanged => { + // Re-fetch the per-session filtered snapshot so the + // visibility contract is enforced by `recording_ws`. + send_recording_snapshot_event(app_state, socket, mem).await?; + } + EventMessage::RecordingRulesChanged => { + // The rule repository is per-process; the + // session-filter is enforced server-side by + // `list_recording_rules`. Just notify the + // frontend — it will re-fetch. + send_event_response(socket, ProtocolMessage::RecordingRulesChanged, "Recording rules changed") + .await?; + } + EventMessage::InputMetadataUpdatesCompleted(_) | EventMessage::InputMetadataUpdatesStarted(_) => { // Internal events or already handled above } } @@ -442,10 +535,7 @@ async fn send_event_response( context: &'static str, ) -> Result<(), WebSocketApiError> { let msg = message.to_bytes()?; - socket - .send(Message::Binary(msg)) - .await - .map_err(|source| WebSocketApiError::EventSend { context, source }) + socket.send(Message::Binary(msg)).await.map_err(|source| WebSocketApiError::EventSend { context, source }) } // WebSocket communication logic @@ -472,7 +562,7 @@ async fn handle_socket(mut socket: WebSocket, app_state: Arc, auth_req event_result = event_rx.recv() => { match event_result { Ok(event) => { - if let Err(e) = handle_event_message(&mut socket, event, &handler).await { + if let Err(e) = handle_event_message(&app_state, &mut socket, event, &handler).await { trace!("Failed to send ws event: {e}"); break; } @@ -554,15 +644,85 @@ async fn handle_user_action(app_state: &Arc, cmd: UserCommand) -> bool #[cfg(test)] mod tests { - use super::{main_event_receive_error_action, websocket_can_receive_runtime_events, MainEventReceiveErrorAction}; + use super::{ + main_event_receive_error_action, set_no_auth_websocket_identity, set_websocket_auth, + websocket_can_receive_runtime_events, websocket_claims, MainEventReceiveErrorAction, + }; use crate::api::model::EventMessage; use shared::model::{ - DownloadsDelta, DownloadsResponse, FileDownloadDto, LibraryScanProgressEvent, LibraryScanSummary, + Claims, DownloadsDelta, DownloadsResponse, FileDownloadDto, LibraryScanProgressEvent, LibraryScanSummary, LibraryScanSummaryStatus, Permission, PlaylistUpdateProgressEvent, ProtocolHandler, ProtocolHandlerMemory, - TaskKindDto, TaskPriorityDto, TransferStatusDto, UserRole, PROTOCOL_VERSION, + TaskKindDto, TaskPriorityDto, TransferStatusDto, UserId, UserRole, CURRENT_PERMISSION_SCHEMA_VERSION, PERM_ALL, + PROTOCOL_VERSION, ROLE_ADMIN, TOKEN_NO_AUTH, }; use tokio::sync::broadcast::error::RecvError; + #[test] + fn no_auth_websocket_identity_is_builtin_admin() { + let mut mem = ProtocolHandlerMemory::default(); + + set_no_auth_websocket_identity(&mut mem, Some(TOKEN_NO_AUTH.to_string())); + let claims = websocket_claims(&mem).expect("claims"); + + assert_eq!(mem.subject_id.as_deref(), Some("builtin:admin")); + assert_eq!(mem.role, UserRole::Admin); + assert_eq!(mem.permissions, PERM_ALL); + assert_eq!(claims.subject_id, Some(UserId::builtin_admin())); + assert_eq!(claims.roles, vec![ROLE_ADMIN]); + assert_eq!(claims.permission_schema_version, CURRENT_PERMISSION_SCHEMA_VERSION); + } + + fn recording_claims(subject_id: Option, permission_schema_version: u16) -> Claims { + Claims { + username: "alice".to_string(), + iss: "test".to_string(), + iat: 1, + exp: i64::MAX, + roles: vec![ROLE_ADMIN.to_string()], + permissions: Permission::RecordingRead.into(), + pwd_version: 0, + subject_id, + permission_schema_version, + } + } + + #[test] + fn websocket_auth_rejects_stale_permission_schema() { + let mut mem = ProtocolHandlerMemory::default(); + let claims = recording_claims(Some(UserId::from("web:alice")), CURRENT_PERMISSION_SCHEMA_VERSION - 1); + + assert!(!set_websocket_auth(&mut mem, "token".to_string(), &claims)); + assert!(mem.token.is_none()); + assert!(mem.subject_id.is_none()); + } + + #[test] + fn websocket_auth_rejects_missing_subject() { + let mut mem = ProtocolHandlerMemory::default(); + let claims = recording_claims(None, CURRENT_PERMISSION_SCHEMA_VERSION); + + assert!(!set_websocket_auth(&mut mem, "token".to_string(), &claims)); + assert!(mem.token.is_none()); + assert!(mem.subject_id.is_none()); + } + + #[test] + fn current_websocket_auth_preserves_recording_authorization_context() { + let mut mem = ProtocolHandlerMemory::default(); + let subject_id = UserId::from("web:alice"); + let claims = recording_claims(Some(subject_id.clone()), CURRENT_PERMISSION_SCHEMA_VERSION); + + assert!(set_websocket_auth(&mut mem, "token".to_string(), &claims)); + let Some(snapshot_claims) = websocket_claims(&mem) else { + unreachable!("authenticated websocket must expose recording claims"); + }; + + assert_eq!(snapshot_claims.subject_id, Some(subject_id)); + assert_eq!(snapshot_claims.permissions, claims.permissions); + assert_eq!(snapshot_claims.roles, claims.roles); + assert_eq!(snapshot_claims.permission_schema_version, CURRENT_PERMISSION_SCHEMA_VERSION); + } + #[test] fn lagged_main_event_receiver_resyncs_authorized_system_reader() { let handler = ProtocolHandler::Default(ProtocolHandlerMemory { @@ -608,65 +768,42 @@ mod tests { #[test] fn test_websocket_runtime_events_allowed_for_admin() { - let mut mem = ProtocolHandlerMemory { - permissions: Permission::SystemRead.into(), - ..ProtocolHandlerMemory::default() - }; + let mut mem = + ProtocolHandlerMemory { permissions: Permission::SystemRead.into(), ..ProtocolHandlerMemory::default() }; mem.role = UserRole::Admin; - assert!(websocket_can_receive_runtime_events( - &mem, - &EventMessage::ServerError("err".to_string()) - )); + assert!(websocket_can_receive_runtime_events(&mem, &EventMessage::ServerError("err".to_string()))); } #[test] fn test_websocket_runtime_events_allowed_for_system_read_user() { - let mut mem = ProtocolHandlerMemory { - permissions: Permission::SystemRead.into(), - ..ProtocolHandlerMemory::default() - }; + let mut mem = + ProtocolHandlerMemory { permissions: Permission::SystemRead.into(), ..ProtocolHandlerMemory::default() }; mem.role = UserRole::User; - assert!(websocket_can_receive_runtime_events( - &mem, - &EventMessage::ServerError("err".to_string()) - )); + assert!(websocket_can_receive_runtime_events(&mem, &EventMessage::ServerError("err".to_string()))); } #[test] fn test_websocket_runtime_events_denied_without_system_read() { - let mut mem = ProtocolHandlerMemory { - permissions: Permission::ConfigRead.into(), - ..ProtocolHandlerMemory::default() - }; + let mut mem = + ProtocolHandlerMemory { permissions: Permission::ConfigRead.into(), ..ProtocolHandlerMemory::default() }; mem.role = UserRole::User; - assert!(!websocket_can_receive_runtime_events( - &mem, - &EventMessage::ServerError("err".to_string()) - )); + assert!(!websocket_can_receive_runtime_events(&mem, &EventMessage::ServerError("err".to_string()))); } #[test] fn test_websocket_runtime_events_denied_for_default_permissions() { - let mem = ProtocolHandlerMemory { - role: UserRole::User, - ..ProtocolHandlerMemory::default() - }; + let mem = ProtocolHandlerMemory { role: UserRole::User, ..ProtocolHandlerMemory::default() }; - assert!(!websocket_can_receive_runtime_events( - &mem, - &EventMessage::ServerError("err".to_string()) - )); + assert!(!websocket_can_receive_runtime_events(&mem, &EventMessage::ServerError("err".to_string()))); } #[test] fn test_websocket_playlist_progress_allowed_for_playlist_write_without_system_read() { - let mut mem = ProtocolHandlerMemory { - permissions: Permission::PlaylistWrite.into(), - ..ProtocolHandlerMemory::default() - }; + let mut mem = + ProtocolHandlerMemory { permissions: Permission::PlaylistWrite.into(), ..ProtocolHandlerMemory::default() }; mem.role = UserRole::User; assert!(websocket_can_receive_runtime_events( @@ -680,10 +817,8 @@ mod tests { #[test] fn test_websocket_library_progress_allowed_for_library_write_without_system_read() { - let mut mem = ProtocolHandlerMemory { - permissions: Permission::LibraryWrite.into(), - ..ProtocolHandlerMemory::default() - }; + let mut mem = + ProtocolHandlerMemory { permissions: Permission::LibraryWrite.into(), ..ProtocolHandlerMemory::default() }; mem.role = UserRole::User; assert!(websocket_can_receive_runtime_events( @@ -700,10 +835,8 @@ mod tests { #[test] fn test_websocket_download_updates_allowed_for_download_read_user() { - let mut mem = ProtocolHandlerMemory { - permissions: Permission::DownloadRead.into(), - ..ProtocolHandlerMemory::default() - }; + let mut mem = + ProtocolHandlerMemory { permissions: Permission::DownloadRead.into(), ..ProtocolHandlerMemory::default() }; mem.role = UserRole::User; assert!(websocket_can_receive_runtime_events( @@ -718,10 +851,8 @@ mod tests { #[test] fn test_websocket_download_updates_denied_without_download_read() { - let mut mem = ProtocolHandlerMemory { - permissions: Permission::SystemRead.into(), - ..ProtocolHandlerMemory::default() - }; + let mut mem = + ProtocolHandlerMemory { permissions: Permission::SystemRead.into(), ..ProtocolHandlerMemory::default() }; mem.role = UserRole::User; assert!(!websocket_can_receive_runtime_events( @@ -736,10 +867,8 @@ mod tests { #[test] fn test_websocket_download_delta_updates_allowed_for_download_read_user() { - let mut mem = ProtocolHandlerMemory { - permissions: Permission::DownloadRead.into(), - ..ProtocolHandlerMemory::default() - }; + let mut mem = + ProtocolHandlerMemory { permissions: Permission::DownloadRead.into(), ..ProtocolHandlerMemory::default() }; mem.role = UserRole::User; assert!(websocket_can_receive_runtime_events( @@ -757,6 +886,7 @@ mod tests { scheduled_start_at: None, duration_secs: None, error: None, + recording: None, })) )); } diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index 156952e82..8f38a39cf 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -263,6 +263,11 @@ async fn serve_epg_with_rewrites( let limit = limit.unwrap_or_default(); + // EPG ids visible under the user's content filter; None = no filtering. + let visible_epg_ids = + crate::api::endpoints::user_visibility::collect_visible_epg_channel_ids(&app_state.app_config, target, user) + .await; + let bg_lock = app_state.app_config.file_locks.read_lock(epg_path).await; let epg_path = epg_path.to_path_buf(); let (channel_tx, mut channel_rx) = mpsc::channel::>(256); @@ -327,6 +332,11 @@ async fn serve_epg_with_rewrites( return; } }; + if let Some(visible) = &visible_epg_ids { + if !visible.contains(&channel.id.to_lowercase()) { + continue; + } + } let programmes = if limit > 0 { channel.get_programme_with_limit(limit) } else { diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index 04103c3b4..f13ff82fb 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -33,7 +33,7 @@ use crate::{ xtream::{self, create_vod_info_from_item}, }, model::{ - xtream_mapping_option_from_target_options, Config, ConfigInput, ConfigInputFlags, ConfigTarget, InputSource, + xtream_mapping_option_from_target_options, ConfigInput, ConfigInputFlags, ConfigTarget, InputSource, ProxyUserCredentials, }, repository::{ @@ -88,6 +88,14 @@ impl ApiStreamContext { const MOVIE: &'static str = "movie"; const SERIES: &'static str = "series"; const TIMESHIFT: &'static str = "timeshift"; + + pub(in crate::api) const fn cluster(self) -> XtreamCluster { + match self { + Self::LiveAlt | Self::Live | Self::Timeshift => XtreamCluster::Live, + Self::Movie => XtreamCluster::Video, + Self::Series => XtreamCluster::Series, + } + } } impl Display for ApiStreamContext { @@ -307,11 +315,11 @@ async fn xtream_player_api_stream( .map_or(stream_ext, |input| override_live_hls_extension(stream_req.context, input, stream_ext)); let is_hls_manifest_request = stream_ext == Some(HLS_EXT); - let output_allowed = if stream_req.context == ApiStreamContext::Timeshift { + let output_allowed = (if stream_req.context == ApiStreamContext::Timeshift { user.allows_cluster(XtreamCluster::Live) } else { user.allows_item_type(pli.item_type) - }; + }) && (user.t_filter.is_none() || user.allows_content(&shared::model::PlaylistItem::from(&pli))); if !output_allowed { if is_hls_manifest_request { return hls_custom_video_manifest_response( @@ -827,6 +835,10 @@ fn override_live_hls_extension<'a>( } } +fn recording_input_matches(expected_input: Option<&ConfigInput>, actual_input_name: &str) -> bool { + expected_input.is_none_or(|input| input.name.as_ref() == actual_input_name) +} + #[allow(clippy::too_many_lines)] // Used by webui pub(in crate::api) async fn xtream_player_api_stream_with_token( @@ -835,12 +847,29 @@ pub(in crate::api) async fn xtream_player_api_stream_with_token( app_state: &Arc, target_id: u16, stream_req: ApiStreamRequest<'_>, +) -> impl IntoResponse + Send { + let Some(target) = app_state.app_config.get_target_by_id(target_id) else { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + }; + xtream_player_api_stream_with_resolved_target(fingerprint, req_headers, app_state, target, None, stream_req) + .await + .into_response() +} + +#[allow(clippy::too_many_lines)] +pub(in crate::api) async fn xtream_player_api_stream_with_resolved_target( + fingerprint: &Fingerprint, + req_headers: &HeaderMap, + app_state: &Arc, + target: Arc, + expected_input: Option>, + stream_req: ApiStreamRequest<'_>, ) -> impl IntoResponse + Send { if stream_req.access_token && !verify_access_token(stream_req.password, &app_state.app_config.access_token_secret) { return axum::http::StatusCode::FORBIDDEN.into_response(); } - if let Some(target) = app_state.app_config.get_target_by_id(target_id) { + { let target_name = &target.name; if !target.has_output(TargetType::Xtream) { debug!("Target has no xtream output {target_name}"); @@ -849,12 +878,15 @@ pub(in crate::api) async fn xtream_player_api_stream_with_token( let (action_stream_id, stream_ext) = separate_number_and_remainder(stream_req.stream_id); let req_virtual_id: u32 = try_result_bad_request!(action_stream_id.trim().parse()); let mut pli = try_result_bad_request!( - xtream_get_item_for_stream_id(req_virtual_id, app_state, &target, None).await, + xtream_get_item_for_stream_id(req_virtual_id, app_state, &target, Some(stream_req.context.cluster())).await, true, format!("Failed to read xtream item for stream id {req_virtual_id}") ); let virtual_id = pli.virtual_id; - let input_option = app_state.app_config.get_input_by_name(&pli.input_name); + if !recording_input_matches(expected_input.as_deref(), pli.input_name.as_ref()) { + return axum::http::StatusCode::BAD_REQUEST.into_response(); + } + let input_option = expected_input.or_else(|| app_state.app_config.get_input_by_name(&pli.input_name)); let stream_ext = input_option .as_deref() .map_or(stream_ext, |input| override_live_hls_extension(stream_req.context, input, stream_ext)); @@ -985,8 +1017,6 @@ pub(in crate::api) async fn xtream_player_api_stream_with_token( ) .await .into_response() - } else { - axum::http::StatusCode::BAD_REQUEST.into_response() } } @@ -1019,7 +1049,9 @@ async fn xtream_player_api_resource( format!("Failed to read xtream item for stream id {req_virtual_id}") ); - if !user.allows_item_type(pli.item_type) { + if !user.allows_item_type(pli.item_type) + || !(user.t_filter.is_none() || user.allows_content(&shared::model::PlaylistItem::from(&pli))) + { return axum::http::StatusCode::NOT_FOUND.into_response(); } @@ -1249,6 +1281,11 @@ pub async fn xtream_get_stream_info_response( return empty_stream_info_response(cluster); }; + // Content filter: hidden items expose no metadata either + if !(user.t_filter.is_none() || user.allows_content(&shared::model::PlaylistItem::from(&pli))) { + return empty_stream_info_response(cluster); + } + let input = app_state.app_config.get_input_by_name(&pli.input_name); let is_media_server = input.as_ref().is_some_and(|i| i.input_type.is_media_server()); // handle local items and media server @@ -1345,6 +1382,10 @@ async fn xtream_get_short_epg( }; if let Ok(pli) = xtream_get_item_for_stream_id(virtual_id, app_state, target, None).await { + // Content filter: hidden items expose no EPG either + if !(user.t_filter.is_none() || user.allows_content(&shared::model::PlaylistItem::from(&pli))) { + return axum::Json(json!(ShortEpgResultDto::default())).into_response(); + } let config = &app_state.app_config.config.load(); let has_archive = pli_supports_archive(app_state, &pli); if let (Some(epg_path), Some(channel_id)) = ( @@ -1419,8 +1460,8 @@ async fn xtream_get_short_epg( } async fn xtream_player_api_handle_content_action( - config: &Config, - target_name: &str, + app_state: &Arc, + target: &ConfigTarget, action: &str, category_id: Option, user: &ProxyUserCredentials, @@ -1435,17 +1476,32 @@ async fn xtream_player_api_handle_content_action( if !user.allows_cluster(cluster) { return Some(api_utils::empty_json_list_response().into_response()); } - if let Ok(file_path) = xtream_get_collection_path(config, target_name, collection) { + let config = app_state.app_config.config.load(); + let target_name = target.name.as_str(); + if let Ok(file_path) = xtream_get_collection_path(&config, target_name, collection) { match tokio::fs::read_to_string(&file_path).await { Ok(content) => { let filter = - user_get_bouquet_filter(config, &user.username, category_id, TargetType::Xtream, cluster).await; + user_get_bouquet_filter(&config, &user.username, category_id, TargetType::Xtream, cluster).await; match serde_json::from_str::>(&content) { Ok(mut categories) => { if let Some(fltr) = filter { categories.retain(|c| fltr.contains(&c.category_id)); } + // Hide categories fully filtered out by the user's content filter. + if let Some(visible) = crate::api::endpoints::user_visibility::collect_visible_category_ids( + &app_state.app_config, + target, + cluster, + user, + ) + .await + { + categories.retain(|c| { + c.category_id.parse::().is_ok_and(|id| visible.contains(&id)) + }); + } return Some(axum::Json(categories).into_response()); } Err(err) => error!("Failed to parse json file {}: {err}", file_path.display()), @@ -1715,8 +1771,8 @@ async fn xtream_player_api( let category_id = api_req.category_id.trim().parse::().ok(); // Handle general content actions if let Some(response) = xtream_player_api_handle_content_action( - &app_state.app_config.config.load(), - &target.name, + app_state, + &target, action, category_id, &user, @@ -1893,7 +1949,7 @@ pub fn xtream_api_register() -> axum::Router> { mod tests { use super::{ empty_stream_info_response, get_xtream_player_api_stream_url, resolve_m3u_xtream_timeshift, - is_hls_playback_request, override_live_hls_extension, resolve_xtream_playback_extension, + is_hls_playback_request, override_live_hls_extension, recording_input_matches, resolve_xtream_playback_extension, xtream_get_short_epg, xtream_player_api_stream, xtream_player_api_stream_with_token, ApiStreamContext, ApiStreamRequest, XtreamApiTimeShiftRequest, }; @@ -1926,6 +1982,14 @@ mod tests { use std::sync::Arc; use tempfile::tempdir; + #[test] + fn recording_input_must_match_canonical_playlist_item_input() { + let expected = ConfigInput { name: "input-a".intern(), ..Default::default() }; + assert!(recording_input_matches(Some(&expected), "input-a")); + assert!(!recording_input_matches(Some(&expected), "input-b")); + assert!(recording_input_matches(None, "input-b")); + } + #[test] fn live_hls_override_is_scoped_to_enabled_xtream_live_requests() { let enabled_xtream = provider_input_with_flag(InputType::Xtream, true); diff --git a/backend/src/api/main_api.rs b/backend/src/api/main_api.rs index cac5c5ca7..e36f82e3c 100644 --- a/backend/src/api/main_api.rs +++ b/backend/src/api/main_api.rs @@ -12,6 +12,7 @@ use crate::{ v1_api::v1_api_register, web_index::{index_register_with_path, index_register_without_path}, websocket_api::ws_api_register, + log_ws_api::log_ws_api_register, xmltv_api::xmltv_api_register, xtream_api::xtream_api_register, }, @@ -25,6 +26,8 @@ use crate::{ ConnectionManager, DownloadQueue, EventManager, EventMessage, HdHomerunAppState, HlsProvisioningState, HlsProxyManager, ManualPlaylistUpdateRequest, MetadataUpdateManager, PlaylistStorageState, SharedStreamManager, UpdateGuard, exec_qos_aggregation, + recording_rule_scheduler::spawn_recording_rule_scheduler, + recording_supervisor::start_recording_supervisors, }, panel_api::sync_panel_api_exp_dates_on_boot, tasks::{exec_interner_prune, exec_scheduler, exec_xtream_expiry_sync}, @@ -113,6 +116,17 @@ async fn recover_persisted_downloads_state_for_startup(downloads: &DownloadQueue async fn resume_downloads_after_bind(app_state: &Arc, download_cfg: &crate::model::VideoDownloadConfig) { spawn_download_services(app_state.as_ref(), &app_state.cancel_tokens.load().downloads); + // Reconcile the DVR state the previous process left behind *before* + // the rule scheduler can plan against it, then start the retention + // and notification supervisors. Without this the queue keeps tasks + // stuck in `Deleting` forever, retention never runs so the recording + // disk grows unbounded, and a lifecycle notification lost to a + // transient provider error is never retried. + // Cloned out of the `ArcSwap` guard first: the guard must not be held + // across the await below. + let downloads_cancel = app_state.cancel_tokens.load().downloads.clone(); + start_recording_supervisors(app_state, &downloads_cancel).await; + spawn_recording_rule_scheduler(app_state, &downloads_cancel); if let Err(err) = resume_download_worker_if_needed(app_state.as_ref(), download_cfg).await { error!("Failed to resume persisted downloads during startup; continuing with downloads paused: {err}"); } @@ -649,7 +663,8 @@ pub async fn start_server(app_config: Arc, targets: Arc { @@ -75,7 +76,18 @@ struct TargetChanges { target: Arc, } -create_bitset!(u8, UpdateChangesFlags, Scheduler, Hdhomerun, FileWatch, Geoip, ProviderDns, Metadata, QosAggregation, Downloads); +create_bitset!( + u8, + UpdateChangesFlags, + Scheduler, + Hdhomerun, + FileWatch, + Geoip, + ProviderDns, + Metadata, + QosAggregation, + Downloads +); pub(in crate::api) struct UpdateChanges { flags: UpdateChangesFlagsSet, @@ -223,13 +235,8 @@ fn start_services(app_state: &Arc, changes: &UpdateChanges) { } if changes.flags.contains(UpdateChangesFlags::QosAggregation) { exec_qos_aggregation(app_state, &app_state.cancel_tokens.load().qos_aggregation); - let history_cfg = app_state - .app_config - .config - .load() - .reverse_proxy - .as_ref() - .and_then(|rp| rp.stream_history.clone()); + let history_cfg = + app_state.app_config.config.load().reverse_proxy.as_ref().and_then(|rp| rp.stream_history.clone()); let connection_manager = Arc::clone(&app_state.connection_manager); tokio::spawn(async move { connection_manager.reload_history_writer(history_cfg.as_ref()).await; @@ -237,6 +244,7 @@ fn start_services(app_state: &Arc, changes: &UpdateChanges) { } if changes.flags.contains(UpdateChangesFlags::Downloads) { spawn_download_services(app_state, &app_state.cancel_tokens.load().downloads); + spawn_recording_rule_scheduler(app_state, &app_state.cancel_tokens.load().downloads); let config = app_state.app_config.config.load(); if let Some(download_cfg) = config.video.as_ref().and_then(|video| video.download.as_ref()).cloned() { let app_state = Arc::clone(app_state); @@ -309,9 +317,7 @@ pub fn create_public_http_client_no_redirect(app_config: &AppConfig) -> Result 0 { builder = builder.connect_timeout(Duration::from_secs(u64::from(config.connect_timeout_secs))); } - builder - .build() - .map_err(|err| TuliproxError::Config(format!("Failed to create public-only HTTP client: {err}"))) + builder.build().map_err(|err| TuliproxError::Config(format!("Failed to create public-only HTTP client: {err}"))) } fn build_http_client_with_fallback( @@ -476,13 +482,8 @@ pub(crate) fn create_test_app_state(config: Config) -> Arc { let geoip = Arc::new(ArcSwapOption::::default()); let loaded_config = app_config.config.load(); let active_users = Arc::new(ActiveUserManager::new(&loaded_config, &geoip, &event_manager)); - let connection_manager = Arc::new(ConnectionManager::new( - &active_users, - &active_provider, - &shared_stream_manager, - &event_manager, - None, - )); + let connection_manager = + Arc::new(ConnectionManager::new(&active_users, &active_provider, &shared_stream_manager, &event_manager, None)); let tokens = CancelTokens::default(); let metadata_manager = Arc::new(MetadataUpdateManager::new(tokens.metadata.clone())); let (manual_update_sender, _) = mpsc::channel::(1); @@ -518,7 +519,15 @@ pub(crate) fn create_test_app_state(config: Config) -> Arc { impl AppState { pub(in crate::api::model) async fn set_config(&self, config: Config) -> Result { - let old_storage_dir = self.app_config.config.load().storage_dir.clone(); + let current_config = self.app_config.config.load(); + let current_web_auth = + current_config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()).map(WebAuthConfigDto::from); + let new_web_auth = config.web_ui.as_ref().and_then(|web_ui| web_ui.auth.as_ref()).map(WebAuthConfigDto::from); + if current_web_auth != new_web_auth { + return Err(TuliproxError::ConfigWebUi("web auth changes require a server restart".to_string())); + } + let old_storage_dir = current_config.storage_dir.clone(); + drop(current_config); let changes = self.detect_changes_for_config(&config); let config_log_level = config.log.as_ref().and_then(|log| log.log_level.clone()); config.update_runtime(); @@ -679,7 +688,10 @@ impl AppState { ); let mut changes = UpdateChanges { flags: UpdateChangesFlagsSet::new(), targets: None }; - changes.set_flag_if(changed_schedules || changed_library_enabled || geoip_enabled != geoip_enabled_old, UpdateChangesFlags::Scheduler); + changes.set_flag_if( + changed_schedules || changed_library_enabled || geoip_enabled != geoip_enabled_old, + UpdateChangesFlags::Scheduler, + ); changes.set_flag_if(changed_hdhomerun, UpdateChangesFlags::Hdhomerun); changes.set_flag_if(changed_file_watch, UpdateChangesFlags::FileWatch); changes.set_flag_if(geoip_enabled != geoip_enabled_old, UpdateChangesFlags::Geoip); @@ -769,9 +781,7 @@ impl AppState { } pub fn get_encrypt_secret(&self) -> [u8; 16] { - self.app_config - .get_reverse_proxy_rewrite_secret() - .unwrap_or(self.app_config.encrypt_secret) + self.app_config.get_reverse_proxy_rewrite_secret().unwrap_or(self.app_config.encrypt_secret) } } @@ -838,10 +848,7 @@ fn schedules_changed(a: &[ScheduleConfig], b: &[ScheduleConfig]) -> bool { for schedule in a { let Some(found_idx) = b.iter().enumerate().find_map(|(idx, candidate)| { - if used[idx] - || candidate.schedule != schedule.schedule - || candidate.task_type != schedule.task_type - { + if used[idx] || candidate.schedule != schedule.schedule || candidate.task_type != schedule.task_type { return None; } let targets_match = match (schedule.targets.as_ref(), candidate.targets.as_ref()) { @@ -930,9 +937,64 @@ mod tests { should_use_manual_redirects_for_env_vars, video_download_changed, }; use crate::model::{Config, ScheduleConfig, VideoDownloadConfig}; - use shared::model::{QosAggregationConfigDto, ReverseProxyConfigDto, ScheduleTaskType, StreamHistoryConfigDto}; + use shared::model::{ + QosAggregationConfigDto, ReverseProxyConfigDto, ScheduleTaskType, StreamHistoryConfigDto, WebAuthConfigDto, + WebUiConfigDto, + }; use std::{collections::HashMap, sync::Arc}; + fn config_with_web_auth(secret: &str) -> Config { + let web_ui = WebUiConfigDto { + auth: Some(WebAuthConfigDto { + enabled: true, + issuer: "test".to_string(), + secret: secret.to_string(), + ..WebAuthConfigDto::default() + }), + ..WebUiConfigDto::default() + }; + Config { web_ui: Some((&web_ui).into()), ..Config::default() } + } + + #[tokio::test] + async fn config_reload_rejects_enabling_web_auth_before_swap() { + let state = super::create_test_app_state(Config::default()); + + let result = state.set_config(config_with_web_auth("secret")).await; + + assert!(matches!(result, Err(shared::error::TuliproxError::ConfigWebUi(_)))); + assert!(state.app_config.config.load().web_ui.is_none()); + } + + #[tokio::test] + async fn config_reload_rejects_web_auth_secret_change_before_swap() { + let state = super::create_test_app_state(config_with_web_auth("old-secret")); + + let result = state.set_config(config_with_web_auth("new-secret")).await; + + assert!(matches!(result, Err(shared::error::TuliproxError::ConfigWebUi(_)))); + assert_eq!( + state + .app_config + .config + .load() + .web_ui + .as_ref() + .and_then(|web_ui| web_ui.auth.as_ref()) + .map(|auth| auth.secret.as_str()), + Some("old-secret") + ); + } + + #[tokio::test] + async fn config_reload_allows_unrelated_change() { + let state = super::create_test_app_state(Config::default()); + let config = Config { default_user_agent: Some("changed".to_string()), ..Config::default() }; + + assert!(state.set_config(config).await.is_ok()); + assert_eq!(state.app_config.config.load().default_user_agent.as_deref(), Some("changed")); + } + #[test] fn should_use_manual_redirect_for_proxy_only_http_or_https() { assert!(should_use_manual_redirect_for_proxy("http://proxy.local:8080")); @@ -1029,11 +1091,9 @@ mod tests { retry_backoff_max_secs: 60, retry_backoff_jitter_percent: 5, retry_max_attempts: 5, + recording: None, }; - let changed = VideoDownloadConfig { - retry_backoff_multiplier: 3.0, - ..base.clone() - }; + let changed = VideoDownloadConfig { retry_backoff_multiplier: 3.0, ..base.clone() }; assert!(video_download_changed(&base, &changed)); } @@ -1054,6 +1114,7 @@ mod tests { retry_backoff_max_secs: 60, retry_backoff_jitter_percent: 5, retry_max_attempts: 5, + recording: None, }; assert!(!video_download_changed(&base, &base.clone())); @@ -1069,7 +1130,11 @@ mod tests { stream_history_retention_days: 7, stream_history_directory: "/tmp/history".to_string(), }), - qos_aggregation: Some(QosAggregationConfigDto { enabled: true, interval_secs: 60, ..Default::default() }), + qos_aggregation: Some(QosAggregationConfigDto { + enabled: true, + interval_secs: 60, + ..Default::default() + }), ..Default::default() })), ..Config::default() diff --git a/backend/src/api/model/download.rs b/backend/src/api/model/download.rs index dab5c4b32..7c68238a6 100644 --- a/backend/src/api/model/download.rs +++ b/backend/src/api/model/download.rs @@ -1,7 +1,12 @@ -use crate::{model::VideoDownloadConfig, utils::file_exists_async}; +use crate::{model::VideoDownloadConfig, utils::{file_exists_async, write_json_atomic}}; use chrono::Utc; +use log::error; use serde::{Deserialize, Serialize}; -use shared::model::{FileDownloadDto, TaskKindDto, TaskPriorityDto, TransferStatusDto}; +use shared::model::{ + FileDownloadDto, QueueRevision, RecordingMetadata, RecordingTaskDto, TaskKindDto, TaskPriorityDto, TransferStatusDto, +}; +#[cfg(test)] +use shared::model::UserId; use shared::utils::{deunicode_string, CONSTANTS, FILENAME_TRIM_PATTERNS}; use std::{ collections::VecDeque, @@ -15,6 +20,411 @@ use tokio::{fs, sync::{Mutex, Notify, RwLock}}; const RECORDING_WINDOW_EXPIRED_ERR: &str = "Recording window already expired"; static DOWNLOAD_TASK_ID_COUNTER: AtomicU64 = AtomicU64::new(1); +/// Reason a persisted entry cannot be converted back to its in-memory +/// form during the commit step. Surfaced to the caller so a corrupt +/// persisted file fails closed instead of silently dropping entries. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PersistedError { + /// The persisted URL could not be parsed. + InvalidUrl(String), + /// A plain download claimed a recording metadata block, or a + /// recording was missing its metadata in a way that the legacy + /// normalizer cannot repair. + KindMetadataInvariant { uuid: String }, +} + +impl std::fmt::Display for PersistedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUrl(s) => write!(f, "persisted url is invalid: {s}"), + Self::KindMetadataInvariant { uuid } => write!( + f, + "persisted task {uuid} violates the kind/metadata invariant" + ), + } + } +} + +impl std::error::Error for PersistedError {} + +/// Typed error returned from the queue mutation boundary. Every +/// `mutate` closure that fails must return a known variant; the +/// `Other` variant is an escape hatch for dynamically-formatted +/// messages that have no stable wire code. +#[derive(Debug)] +pub enum QueueMutationError { + UnknownRecording, + StateNotEditable, + Forbidden, + InvalidInterval, + InvalidQuotaPool, + InvalidPath, + PaddingLimitExceeded, + QuotaExceeded, + Duplicate, + NotInTerminalState, + DiskFull, + MutationSkipped, + /// Escape hatch for dynamically-formatted validation messages + /// that have no stable wire code. Prefer the typed variants. + Other(String), + Io(std::io::Error), +} + +impl QueueMutationError { + /// Escape-hatch constructor for messages that cannot be expressed + /// as a typed variant. Prefer the typed `Self::X` constructors. + /// `pub(crate)` because the only callers are download worker + /// actions (`download_api.rs`) that wrap an inner error or carry + /// an action label. + pub(crate) fn new(message: impl Into) -> Self { + Self::Other(message.into()) + } + + pub fn from_io(err: std::io::Error) -> Self { Self::Io(err) } + + /// Stable display message for logging and HTTP error rendering. + pub fn message(&self) -> &'static str { + match self { + Self::UnknownRecording => "recording unknown", + Self::StateNotEditable => "recording state not editable", + Self::Forbidden => "recording forbidden", + Self::InvalidInterval => "recording invalid interval", + Self::InvalidQuotaPool => "recording invalid quota pool", + Self::InvalidPath => "recording invalid path", + Self::PaddingLimitExceeded => "recording_padding_limit_exceeded", + Self::QuotaExceeded => "recording quota exceeded", + Self::Duplicate => "recording duplicate", + Self::NotInTerminalState => "recording not in terminal state", + Self::DiskFull => "disk full", + Self::MutationSkipped => "mutation unexpectedly skipped", + Self::Other(_) => "queue mutation failed", + Self::Io(_) => "queue mutation persistence failed", + } + } + + pub fn source_io(&self) -> Option<&std::io::Error> { + match self { + Self::Io(err) => Some(err), + _ => None, + } + } +} + +impl std::fmt::Display for QueueMutationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Other(s) => f.write_str(s), + Self::Io(e) => std::fmt::Display::fmt(e, f), + other => f.write_str(other.message()), + } + } +} + +impl std::error::Error for QueueMutationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err as &(dyn std::error::Error + 'static)), + _ => None, + } + } +} + +/// Lock ordering for the queue mutation boundary: +/// +/// 1. `mutation_guard` (`Mutex`) — outermost for persisted mutations and ordered control publication. +/// 2. `queue` (`Mutex`) — always taken before persisted state locks. +/// 3. `scheduled` / `active` / `finished` (`RwLock`, write) — taken after the queue. +/// 4. `control_signal` (`RwLock`) and `control_notify` (`Notify`) — taken after the +/// queue locks; they signal runtime state, not persisted state. +/// 5. `revision` (`AtomicU64`) — no lock; swapped atomically with the +/// commit step. +/// +/// The queue mutation boundary (`mutate`) holds the queue locks only while +/// building the candidate snapshot. It does **not** hold any queue lock +/// while running the user closure or while persisting. Persisting holds +/// the `state_file` (filesystem only). +/// +/// Rule repository mutations are acquired strictly after the queue boundary +/// has committed; never inside it. +/// +/// Apply a single transactional queue mutation. The closure receives an +/// owned `PersistedDownloadQueue` candidate cloned from the current state +/// and returns either a value or a [`QueueMutationError`]. On success the +/// candidate is persisted atomically, then swapped into the in-memory state, +/// then the `QueueRevision` is incremented. On any failure — closure error +/// or persist error — the in-memory state, the persisted file, and the +/// revision are all unchanged. +pub async fn mutate(this: &DownloadQueue, op: F) -> Result +where + F: FnOnce(&mut PersistedDownloadQueue) -> Result, +{ + match mutate_optional(this, |candidate| op(candidate).map(Some)).await? { + Some(result) => Ok(result), + None => Err(QueueMutationError::MutationSkipped), + } +} + +pub(in crate::api) async fn mutate_optional( + this: &DownloadQueue, + op: F, +) -> Result, QueueMutationError> +where + F: FnOnce(&mut PersistedDownloadQueue) -> Result, QueueMutationError>, +{ + let _mutation = this.mutation_guard.lock().await; + mutate_optional_locked(this, op).await +} + +async fn mutate_optional_locked( + this: &DownloadQueue, + op: F, +) -> Result, QueueMutationError> +where + F: FnOnce(&mut PersistedDownloadQueue) -> Result, QueueMutationError>, +{ + let next_revision = this.revision.load(Ordering::SeqCst).saturating_add(1); + + // 2. Build candidate under the queue locks. + let mut candidate = this.snapshot_current(QueueRevision(next_revision)).await; + + // 3. Apply the mutation to a candidate snapshot. The closure can do + // arbitrary validation and refer back to the candidate's prior + // state. + let Some(result) = op(&mut candidate)? else { + return Ok(None); + }; + + let content = this + .state_file + .as_ref() + .map(|_| serde_json::to_vec_pretty(&candidate)) + .transpose() + .map_err(|err| QueueMutationError::from_io(std::io::Error::other(err)))?; + + let PersistedDownloadQueue { + queue: candidate_queue, + scheduled: candidate_scheduled, + active: candidate_active, + finished: candidate_finished, + revision: _, + } = candidate; + + // 4. Validate every persisted entry into its in-memory form before + // swapping. A single corrupt entry (invalid URL, kind/metadata + // invariant violation) must abort the commit so the persisted file + // and the in-memory state stay identical. Without this, a bad URL + // would be silently dropped from memory while the file still + // listed it, desyncing the two. + let mut queue: VecDeque = VecDeque::with_capacity(candidate_queue.len()); + for p in candidate_queue { + queue.push_back( + DownloadQueue::from_persisted(p) + .map_err(|e| QueueMutationError::new(format!("persisted queue entry invalid: {e}")))?, + ); + } + let mut scheduled: Vec = Vec::with_capacity(candidate_scheduled.len()); + for p in candidate_scheduled { + scheduled.push( + DownloadQueue::from_persisted(p) + .map_err(|e| QueueMutationError::new(format!("persisted scheduled entry invalid: {e}")))?, + ); + } + let active = match candidate_active { + Some(p) => Some( + DownloadQueue::from_persisted(p) + .map_err(|e| QueueMutationError::new(format!("persisted active entry invalid: {e}")))?, + ), + None => None, + }; + let mut finished: Vec = Vec::with_capacity(candidate_finished.len()); + for p in candidate_finished { + finished.push( + DownloadQueue::from_persisted(p) + .map_err(|e| QueueMutationError::new(format!("persisted finished entry invalid: {e}")))?, + ); + } + + // 5. Persist only after the complete candidate has been validated. + if let (Some(state_file), Some(content)) = (this.state_file.as_ref(), content) { + if let Some(parent) = state_file.parent() { + fs::create_dir_all(parent).await.map_err(QueueMutationError::from_io)?; + } + let tmp_path = state_file.with_extension(format!("json.tmp.{next_revision}")); + fs::write(&tmp_path, &content).await.map_err(QueueMutationError::from_io)?; + fs::rename(&tmp_path, state_file).await.map_err(QueueMutationError::from_io)?; + } + + // 6. Commit. Swap the validated in-memory state from the persisted + // candidate. + let mut queue_lock = this.queue.lock().await; + let mut scheduled_lock = this.scheduled.write().await; + let mut active_lock = this.active.write().await; + let mut finished_lock = this.finished.write().await; + *queue_lock = queue; + *scheduled_lock = scheduled; + *active_lock = active; + *finished_lock = finished; + this.revision.store(next_revision, Ordering::SeqCst); + Ok(Some(result)) +} + +/// Normalize a pre-DVR recording (kind == Recording, metadata == None) to +/// `LegacyAdmin` ownership, private visibility, zero padding, and a scheduled +/// interval derived from the legacy `start_at` + `duration_secs`. Derives +/// `completed_at` from a safe file mtime when the task is in a terminal +/// state and the file exists; otherwise falls back to the scheduled end. +/// Initializes `measured_bytes` from the file size when safe to do so. +/// Only sets `relative_path` when the legacy canonical path is safely +/// contained by `recording_root` or `legacy_root`. +fn normalize_legacy_recording( + task: &mut FileDownload, + recording_root: Option<&Path>, + legacy_root: Option<&Path>, +) { + let start_at = task.start_at.unwrap_or(0); + let duration_secs = task.duration_secs.unwrap_or(0); + let mut meta = RecordingMetadata::for_legacy_admin(start_at, duration_secs); + + if let Some(relative) = derive_legacy_relative_path(&task.file_path, recording_root, legacy_root) { + meta.relative_path = Some(relative); + } + + meta.completed_at = derive_legacy_completed_at(&task.state, &task.file_path, meta.scheduled_end); + meta.measured_bytes = safe_regular_file_size(&task.file_path).unwrap_or(0); + + task.recording = Some(meta); +} + +/// Derive a relative path from the legacy canonical `file_path` if it is +/// safely contained by either the new recording root or the configured +/// legacy download root. The check is a legacy string prefix comparison; +/// new recording paths use stricter containment helpers. +fn derive_legacy_relative_path( + file_path: &Path, + recording_root: Option<&Path>, + legacy_root: Option<&Path>, +) -> Option { + if let Some(root) = recording_root { + if path_is_contained(file_path, root) { + return strip_prefix(file_path, root); + } + } + if let Some(root) = legacy_root { + if path_is_contained(file_path, root) { + return strip_prefix(file_path, root); + } + } + None +} + +fn path_is_contained(path: &Path, root: &Path) -> bool { + if root.as_os_str().is_empty() { + return false; + } + // `Path::starts_with` compares component-by-component, so + // `/data/rec` is correctly rejected as a prefix of `/data/records`. + path.starts_with(root) +} + +fn strip_prefix(path: &Path, root: &Path) -> Option { + if root.as_os_str().is_empty() { + return None; + } + // `Path::strip_prefix` enforces the component-level boundary, but + // `..` is still a valid relative-path component: a legacy path + // like `/../etc/passwd` strips cleanly to `../etc/passwd`, + // and a downstream `join(root)` would resolve back outside the + // root. Reject anything other than `Component::Normal` so a + // traversal in the stored path cannot escape the recording root. + let stripped = path.strip_prefix(root).ok()?; + if !stripped + .components() + .all(|c| matches!(c, std::path::Component::Normal(_))) + { + return None; + } + Some(path_to_unix_string(stripped)) +} + +/// Render a path with `/` as the separator regardless of platform. The +/// callers of `derive_legacy_relative_path` store the result as a +/// portable relative-path string. +fn path_to_unix_string(path: &Path) -> String { + path.components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()) + .collect::>() + .join("/") +} + +fn derive_legacy_completed_at( + state: &DownloadState, + file_path: &Path, + fallback_scheduled_end: Option, +) -> Option { + let safe_mtime = safe_regular_file_mtime(file_path); + let terminal = matches!(state, DownloadState::Completed | DownloadState::Failed | DownloadState::Cancelled); + if terminal { + if let Some(mtime) = safe_mtime { + return Some(mtime); + } + } + fallback_scheduled_end +} + +fn safe_regular_file_mtime(path: &Path) -> Option { + let Ok(meta) = std::fs::metadata(path) else { + return None; + }; + if !meta.is_file() { + return None; + } + meta.modified() + .ok() + .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) +} + +fn safe_regular_file_size(path: &Path) -> Option { + let Ok(meta) = std::fs::metadata(path) else { + return None; + }; + if !meta.is_file() { + return None; + } + Some(meta.len()) +} + +/// Pre-scan a persisted queue state file for `RecordingOwner::User` IDs +/// without requiring the identity registry to be loaded. Returns the +/// unique set of user IDs found across all `recording` blocks. +#[cfg(test)] +pub fn pre_scan_recording_user_ids(path: &Path) -> Vec { + let Ok(bytes) = std::fs::read(path) else { + return Vec::new(); + }; + let Ok(queue) = serde_json::from_slice::(&bytes) else { + return Vec::new(); + }; + let mut found = std::collections::HashSet::new(); + for task in queue + .queue + .iter() + .chain(queue.scheduled.iter()) + .chain(queue.active.iter()) + .chain(queue.finished.iter()) + { + if let Some(meta) = &task.recording { + if let shared::model::RecordingOwner::User(uid) = &meta.owner { + found.insert(uid.clone()); + } + } + } + let mut sorted: Vec = found.into_iter().collect(); + sorted.sort_by(|a, b| a.0.cmp(&b.0)); + sorted +} + /// File-Download information. #[derive(Clone, Debug)] pub struct FileDownload { @@ -54,6 +464,10 @@ pub struct FileDownload { pub retry_attempts: u8, /// Unix timestamp of the next retry attempt while waiting. pub next_retry_at: Option, + /// DVR recording metadata. `Some` iff the task is a recording after + /// legacy normalization; `None` for plain downloads. The kind/metadata + /// invariant is enforced in `FileDownload::new` and `new_recording`. + pub recording: Option, } #[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] @@ -64,37 +478,46 @@ pub enum DownloadKind { } #[derive(Clone, Debug, Serialize, Deserialize)] -struct PersistedFileDownload { - uuid: String, - file_dir: PathBuf, - file_path: PathBuf, - filename: String, - url: String, - finished: bool, - size: u64, - total_size: Option, - paused: bool, - error: Option, - state: DownloadState, - start_at: Option, - duration_secs: Option, - kind: DownloadKind, +pub struct PersistedFileDownload { + pub uuid: String, + pub file_dir: PathBuf, + pub file_path: PathBuf, + pub filename: String, + pub url: String, + pub finished: bool, + pub size: u64, + pub total_size: Option, + pub paused: bool, + pub error: Option, + pub state: DownloadState, + pub start_at: Option, + pub duration_secs: Option, + pub kind: DownloadKind, #[serde(default)] - input_name: Option, + pub input_name: Option, #[serde(default)] - priority: i8, + pub priority: i8, #[serde(default)] - retry_attempts: u8, + pub retry_attempts: u8, #[serde(default)] - next_retry_at: Option, + pub next_retry_at: Option, + /// DVR recording metadata. `Some` iff the task is a recording. Missing + /// older payloads deserialize to `None` and are normalized on load. + #[serde(default)] + pub recording: Option, } #[derive(Debug, Default, Serialize, Deserialize)] -struct PersistedDownloadQueue { - queue: Vec, - scheduled: Vec, - active: Option, - finished: Vec, +pub struct PersistedDownloadQueue { + pub queue: Vec, + pub scheduled: Vec, + pub active: Option, + pub finished: Vec, + /// Monotonic revision. Increments once per committed queue mutation. + /// Defaults to 0 on first read; the in-memory `DownloadQueue` mirrors + /// this counter via an `AtomicU64`. + #[serde(default)] + pub revision: QueueRevision, } #[derive(Clone, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] @@ -180,13 +603,21 @@ impl FileDownload { .trim_matches(FILENAME_TRIM_PATTERNS); let file_ext = filename_path.extension().and_then(OsStr::to_str).unwrap_or(""); - let mut filename = format!("{file_stem}.{file_ext}"); + let mut filename = if file_ext.is_empty() { + file_stem.to_string() + } else { + format!("{file_stem}.{file_ext}") + }; let file_dir = get_download_directory(download_cfg, file_stem); let mut file_path: PathBuf = file_dir.clone(); file_path.push(&filename); let mut x: usize = 1; while file_path.is_file() { - filename = format!("{file_stem}_{x}.{file_ext}"); + filename = if file_ext.is_empty() { + format!("{file_stem}_{x}") + } else { + format!("{file_stem}_{x}.{file_ext}") + }; file_path.clone_from(&file_dir); file_path.push(&filename); x += 1; @@ -213,6 +644,7 @@ impl FileDownload { priority, retry_attempts: 0, next_retry_at: None, + recording: None, }) } Err(_) => None, @@ -233,8 +665,22 @@ impl FileDownload { recording.start_at = Some(start_at); recording.duration_secs = Some(duration_secs); recording.kind = DownloadKind::Recording; + // Legacy records do not carry a server-owned source identifier. The + // kind/metadata invariant is upheld because every new recording + // receives `RecordingMetadata`. + recording.recording = Some(RecordingMetadata::for_legacy_admin(start_at, duration_secs)); Some(recording) } + + /// Invariant: `kind == Recording` iff `recording.is_some()`. Plain + /// downloads never carry metadata. The mirrored persisted form enforces + /// the same check in `from_persisted`. + pub fn kind_metadata_invariant_ok(&self) -> bool { + match self.kind { + DownloadKind::Download => self.recording.is_none(), + DownloadKind::Recording => self.recording.is_some(), + } + } } impl FileDownload { @@ -285,6 +731,7 @@ impl From<&FileDownload> for FileDownloadDto { scheduled_start_at: value.start_at, duration_secs: value.duration_secs, error: value.error.clone(), + recording: value.recording.as_ref().map(RecordingTaskDto::from_metadata), } } } @@ -432,6 +879,10 @@ pub struct DownloadQueue { pub state_file: Option, /// Priority-aware waiter queue for provider connection slots. pub slot_waiters: Arc, + /// In-memory mirror of the persisted queue revision. Incremented + /// once per committed mutation. + pub revision: Arc, + mutation_guard: Arc>, } impl Default for DownloadQueue { @@ -439,6 +890,71 @@ impl Default for DownloadQueue { } impl DownloadQueue { + pub(in crate::api) async fn mutate_optional_and_clear_control( + &self, + expected: DownloadControl, + op: F, + ) -> Result, QueueMutationError> + where + F: FnOnce(&mut PersistedDownloadQueue) -> Result, QueueMutationError>, + { + let _mutation = self.mutation_guard.lock().await; + let result = mutate_optional_locked(self, op).await?; + if result.is_some() { + let mut control = self.control_signal.write().await; + if *control == expected { + *control = DownloadControl::None; + } + } + Ok(result) + } + + async fn snapshot_current(&self, revision: QueueRevision) -> PersistedDownloadQueue { + let queue = self.queue.lock().await; + let scheduled = self.scheduled.read().await; + let active = self.active.read().await; + let finished = self.finished.read().await; + + PersistedDownloadQueue { + queue: queue.iter().map(Self::to_persisted).collect(), + scheduled: scheduled.iter().map(Self::to_persisted).collect(), + active: active.as_ref().map(Self::to_persisted), + finished: finished.iter().map(Self::to_persisted).collect(), + revision, + } + } + + pub async fn committed_snapshot(&self) -> (QueueRevision, Vec) { + let _mutation = self.mutation_guard.lock().await; + let revision = QueueRevision(self.revision.load(Ordering::SeqCst)); + let queue = self.queue.lock().await; + let scheduled = self.scheduled.read().await; + let active = self.active.read().await; + let finished = self.finished.read().await; + let mut tasks = Vec::with_capacity( + queue.len() + scheduled.len() + finished.len() + usize::from(active.is_some()), + ); + tasks.extend(queue.iter().cloned()); + tasks.extend(scheduled.iter().cloned()); + tasks.extend(active.iter().cloned()); + tasks.extend(finished.iter().cloned()); + (revision, tasks) + } + + pub async fn committed_download_snapshot( + &self, + ) -> (Vec, Option, Vec) { + let _mutation = self.mutation_guard.lock().await; + let queue = self.queue.lock().await; + let scheduled = self.scheduled.read().await; + let active = self.active.read().await; + let finished = self.finished.read().await; + let mut queued = Vec::with_capacity(queue.len() + scheduled.len()); + queued.extend(queue.iter().cloned()); + queued.extend(scheduled.iter().cloned()); + (queued, active.clone(), finished.clone()) + } + fn finalize_missed_recording(mut download: FileDownload) -> FileDownload { download.finished = true; download.paused = false; @@ -452,7 +968,13 @@ impl DownloadQueue { && download .start_at .zip(download.duration_secs) - .is_some_and(|(start_at, duration_secs)| now_ts >= start_at.saturating_add(i64::try_from(duration_secs).unwrap_or(i64::MAX))) + .is_some_and(|(start_at, duration_secs)| { + crate::api::model::recording::recording_math::window_elapsed( + start_at, + duration_secs, + now_ts, + ) + }) } pub fn new() -> Self { @@ -470,10 +992,12 @@ impl DownloadQueue { worker_running: Arc::from(RwLock::new(false)), state_file, slot_waiters: Arc::new(DownloadSlotWaitQueue::new()), + revision: Arc::new(AtomicU64::new(0)), + mutation_guard: Arc::new(Mutex::new(())), } } - fn to_persisted(download: &FileDownload) -> PersistedFileDownload { + pub fn to_persisted(download: &FileDownload) -> PersistedFileDownload { PersistedFileDownload { uuid: download.uuid.clone(), file_dir: download.file_dir.clone(), @@ -493,16 +1017,43 @@ impl DownloadQueue { priority: download.priority, retry_attempts: download.retry_attempts, next_retry_at: download.next_retry_at, + recording: download.recording.clone(), } } - fn from_persisted(download: PersistedFileDownload) -> Option { - Some(FileDownload { + pub fn from_persisted(download: PersistedFileDownload) -> Result { + Self::from_persisted_with(download, None, None) + } + + /// Reconstruct a `FileDownload` from its persisted form. When the task + /// is a recording without nested metadata (the pre-DVR shape), the + /// legacy pre-scan normalizes it: `LegacyAdmin` owner, private visibility, + /// zero padding, scheduled interval from `start_at` + `duration_secs`, + /// `completed_at` from a safe file mtime or the scheduled end, and a + /// `relative_path` only if the legacy file path is safely contained + /// under `recording_root` or `legacy_root`. + pub fn from_persisted_with( + download: PersistedFileDownload, + recording_root: Option<&Path>, + legacy_root: Option<&Path>, + ) -> Result { + let url = reqwest::Url::parse(&download.url).map_err(|e| PersistedError::InvalidUrl(e.to_string()))?; + let recording = download.recording.clone(); + let kind = download.kind.clone(); + // Kind/metadata invariant check. New recordings always carry + // `recording`; plain downloads never do. Legacy records (Recording + // without metadata) are normalized below. + if matches!(kind, DownloadKind::Download) && recording.is_some() { + return Err(PersistedError::KindMetadataInvariant { + uuid: download.uuid.clone(), + }); + } + let mut task = FileDownload { uuid: download.uuid, file_dir: download.file_dir, file_path: download.file_path, filename: download.filename, - url: reqwest::Url::parse(&download.url).ok()?, + url, finished: download.finished, size: download.size, total_size: download.total_size, @@ -511,15 +1062,29 @@ impl DownloadQueue { state: download.state, start_at: download.start_at, duration_secs: download.duration_secs, - kind: download.kind, + kind, input_name: download.input_name.map(|s| Arc::from(s.as_str())), priority: download.priority, retry_attempts: download.retry_attempts, next_retry_at: download.next_retry_at, - }) + recording, + }; + if matches!(task.kind, DownloadKind::Recording) && task.recording.is_none() { + normalize_legacy_recording(&mut task, recording_root, legacy_root); + } + Ok(task) } pub async fn persist_to_disk(&self) -> std::io::Result<()> { + let result = self.try_persist_to_disk().await; + // Callers discard the result; log here so persistence failures are never silent + if let Err(err) = &result { + error!("Failed to persist download queue: {err}"); + } + result + } + + async fn try_persist_to_disk(&self) -> std::io::Result<()> { let Some(state_file) = self.state_file.as_ref() else { return Ok(()); }; @@ -528,16 +1093,21 @@ impl DownloadQueue { let scheduled = self.scheduled.read().await.iter().map(Self::to_persisted).collect::>(); let active = self.active.read().await.as_ref().map(Self::to_persisted); let finished = self.finished.read().await.iter().map(Self::to_persisted).collect::>(); - let payload = PersistedDownloadQueue { queue, scheduled, active, finished }; + let revision = self.revision.load(Ordering::SeqCst); + let payload = PersistedDownloadQueue { + queue, + scheduled, + active, + finished, + revision: QueueRevision(revision), + }; let content = serde_json::to_vec_pretty(&payload).map_err(std::io::Error::other)?; if let Some(parent) = state_file.parent() { fs::create_dir_all(parent).await?; } - let tmp_file = state_file.with_extension("json.tmp"); - fs::write(&tmp_file, content).await?; - fs::rename(&tmp_file, state_file).await + write_json_atomic(state_file, &content).await } pub async fn load_from_disk(&self) -> std::io::Result<()> { @@ -555,27 +1125,31 @@ impl DownloadQueue { let queue = persisted .queue .into_iter() - .filter_map(Self::from_persisted) + .filter_map(|p| Self::from_persisted(p).ok()) .map(Self::recover_loaded_download) .collect::>(); let now_ts = Utc::now().timestamp(); let scheduled_loaded = persisted .scheduled .into_iter() - .filter_map(Self::from_persisted) + .filter_map(|p| Self::from_persisted(p).ok()) .map(Self::recover_loaded_download) .collect::>(); let (scheduled, missed_scheduled): (Vec<_>, Vec<_>) = scheduled_loaded .into_iter() .partition(|download| !Self::recording_start_missed_window(download, now_ts)); - let active = persisted.active.and_then(Self::from_persisted).map(Self::recover_loaded_download); + let active = persisted + .active + .and_then(|p| Self::from_persisted(p).ok()) + .map(Self::recover_loaded_download); let mut finished = - persisted.finished.into_iter().filter_map(Self::from_persisted).collect::>(); + persisted.finished.into_iter().filter_map(|p| Self::from_persisted(p).ok()).collect::>(); finished.extend(missed_scheduled.into_iter().map(Self::finalize_missed_recording)); *self.queue.lock().await = queue; *self.scheduled.write().await = scheduled; *self.finished.write().await = finished; + self.revision.store(persisted.revision.0, Ordering::SeqCst); if let Some(active) = active { if active.paused || active.state == DownloadState::Paused { *self.active.write().await = Some(active); @@ -653,37 +1227,125 @@ impl DownloadQueue { .cloned() } - pub async fn pause_active(&self) { + /// Pause the active download. Persists the new state through the + /// transactional boundary. The runtime-only control signal is published + /// after the commit while the mutation guard still preserves ordering. + pub async fn pause_active(&self, uuid: &str) -> Result { + let _mutation = self.mutation_guard.lock().await; + let changed = mutate_optional_locked(self, |candidate| { + let Some(active) = candidate.active.as_mut().filter(|active| active.uuid == uuid) else { + return Ok(None); + }; + active.paused = true; + active.state = DownloadState::Paused; + active.next_retry_at = None; + Ok(Some(true)) + }) + .await? + .unwrap_or(false); + if !changed { + return Ok(false); + } *self.control_signal.write().await = DownloadControl::Pause; self.control_notify.notify_waiters(); - if let Some(download) = self.active.write().await.as_mut() { - download.paused = true; - download.state = DownloadState::Paused; - download.next_retry_at = None; - } - let _ = self.persist_to_disk().await; + Ok(true) } - pub async fn resume_active(&self) { + /// Resume the active download. Persists the new state through the + /// transactional boundary. + pub async fn resume_active(&self, uuid: &str) -> Result { + let _mutation = self.mutation_guard.lock().await; + let changed = mutate_optional_locked(self, |candidate| { + let Some(active) = candidate + .active + .as_mut() + .filter(|active| active.uuid == uuid && active.paused) + else { + return Ok(None); + }; + active.paused = false; + active.state = DownloadState::Downloading; + active.next_retry_at = None; + Ok(Some(true)) + }) + .await? + .unwrap_or(false); + if !changed { + return Ok(false); + } *self.control_signal.write().await = DownloadControl::None; self.control_notify.notify_waiters(); - if let Some(download) = self.active.write().await.as_mut() { - download.paused = false; - download.state = DownloadState::Downloading; - download.next_retry_at = None; - } - let _ = self.persist_to_disk().await; + Ok(true) } - pub async fn cancel_active(&self) { + /// Cancel the active download. Persists the new state through the + /// transactional boundary. + pub async fn cancel_active_matching(&self, uuid: &str) -> Result { + let _mutation = self.mutation_guard.lock().await; + let changed = mutate_optional_locked(self, |candidate| { + let Some(active) = candidate.active.as_mut().filter(|active| active.uuid == uuid) else { + return Ok(None); + }; + active.state = DownloadState::Cancelled; + active.error = Some("Cancelled by user".to_string()); + active.next_retry_at = None; + Ok(Some(true)) + }) + .await? + .unwrap_or(false); + if !changed { + return Ok(false); + } *self.control_signal.write().await = DownloadControl::Cancel; self.control_notify.notify_waiters(); - if let Some(download) = self.active.write().await.as_mut() { - download.state = DownloadState::Cancelled; - download.error = Some("Cancelled by user".to_string()); - download.next_retry_at = None; + Ok(true) + } + + pub async fn cancel_active(&self) -> Result { + let Some(uuid) = self.active.read().await.as_ref().map(|active| active.uuid.clone()) else { + return Ok(false); + }; + self.cancel_active_matching(&uuid).await + } + + pub(in crate::api) async fn cancel_requested(&self, uuid: &str) -> Result, QueueMutationError> { + let _mutation = self.mutation_guard.lock().await; + let was_paused = mutate_optional_locked(self, |candidate| { + let Some(active) = candidate.active.as_ref().filter(|active| active.uuid == uuid) else { + return Ok(None); + }; + let was_paused = active.paused; + if was_paused { + let Some(mut cancelled) = candidate.active.take() else { + return Ok(None); + }; + cancelled.finished = true; + cancelled.paused = false; + cancelled.next_retry_at = None; + cancelled.error.get_or_insert_with(|| "Cancelled by user".to_string()); + cancelled.state = DownloadState::Cancelled; + candidate.finished.push(cancelled); + if !candidate.queue.is_empty() { + candidate.active = Some(candidate.queue.remove(0)); + } + } else if let Some(active) = candidate.active.as_mut() { + active.state = DownloadState::Cancelled; + active.error = Some("Cancelled by user".to_string()); + active.next_retry_at = None; + } + Ok(Some(was_paused)) + }) + .await?; + + if let Some(was_paused) = was_paused { + *self.control_signal.write().await = if was_paused { + DownloadControl::None + } else { + DownloadControl::Cancel + }; + self.control_notify.notify_waiters(); } - let _ = self.persist_to_disk().await; + Ok(was_paused) } pub fn request_worker_restart(&self) { @@ -700,48 +1362,51 @@ impl DownloadQueue { }); } - pub async fn remove_from_queue(&self, uuid: &str) -> bool { - let mut queue = self.queue.lock().await; - let initial_len = queue.len(); - queue.retain(|d| d.uuid != uuid); - let removed = queue.len() < initial_len; - drop(queue); - if !removed { - let mut scheduled = self.scheduled.write().await; - let initial_len = scheduled.len(); - scheduled.retain(|d| d.uuid != uuid); - let scheduled_removed = scheduled.len() < initial_len; - drop(scheduled); - if scheduled_removed { - let _ = self.persist_to_disk().await; - return true; + pub async fn remove_from_queue(&self, uuid: &str) -> Result { + Ok(mutate_optional(self, |candidate| { + let queue_len = candidate.queue.len(); + candidate.queue.retain(|download| download.uuid != uuid); + if candidate.queue.len() != queue_len { + return Ok(Some(true)); } - } - if removed { - let _ = self.persist_to_disk().await; - } - removed + let scheduled_len = candidate.scheduled.len(); + candidate.scheduled.retain(|download| download.uuid != uuid); + Ok((candidate.scheduled.len() != scheduled_len).then_some(true)) + }) + .await? + .unwrap_or(false)) } - pub async fn remove_finished(&self, uuid: &str) -> bool { - let mut finished = self.finished.write().await; - let initial_len = finished.len(); - finished.retain(|d| d.uuid != uuid); - let removed = finished.len() < initial_len; - drop(finished); - if removed { - let _ = self.persist_to_disk().await; - } - removed + pub async fn remove_finished(&self, uuid: &str) -> Result { + Ok(mutate_optional(self, |candidate| { + let initial_len = candidate.finished.len(); + candidate.finished.retain(|download| download.uuid != uuid); + Ok((candidate.finished.len() != initial_len).then_some(true)) + }) + .await? + .unwrap_or(false)) } - pub async fn retry_finished(&self, uuid: &str) -> bool { - let mut finished = self.finished.write().await; - if let Some(pos) = finished.iter().position(|d| d.uuid == uuid) { - let mut download = finished.remove(pos); + pub async fn remove(&self, uuid: &str) -> Result { + Ok(mutate_optional(self, |candidate| { + let original_len = candidate.queue.len() + candidate.scheduled.len() + candidate.finished.len(); + candidate.queue.retain(|download| download.uuid != uuid); + candidate.scheduled.retain(|download| download.uuid != uuid); + candidate.finished.retain(|download| download.uuid != uuid); + let current_len = candidate.queue.len() + candidate.scheduled.len() + candidate.finished.len(); + Ok((current_len != original_len).then_some(true)) + }) + .await? + .unwrap_or(false)) + } + + pub async fn retry_finished(&self, uuid: &str) -> Result { + Ok(mutate_optional(self, |candidate| { + if let Some(pos) = candidate.finished.iter().position(|download| download.uuid == uuid) { + let mut download = candidate.finished.remove(pos); if download.kind == DownloadKind::Recording { - finished.insert(pos, download); - return false; + candidate.finished.insert(pos, download); + return Ok(None); } download.finished = false; download.size = 0; @@ -750,69 +1415,72 @@ impl DownloadQueue { download.state = DownloadState::Queued; download.retry_attempts = 0; download.next_retry_at = None; - drop(finished); - self.queue.lock().await.push_back(download); - let _ = self.persist_to_disk().await; - true - } else { - false - } + candidate.queue.push(download); + Ok(Some(true)) + } else { + Ok(None) + } + }) + .await? + .unwrap_or(false)) } pub async fn promote_due_scheduled(&self, now_ts: i64) -> usize { - let mut scheduled = self.scheduled.write().await; - if scheduled.is_empty() { - return 0; - } + let result = mutate_optional(self, |candidate| { + let mut due_downloads = Vec::new(); + let mut missed_recordings = Vec::new(); + candidate.scheduled.retain(|download| { + let is_missed = download.kind == DownloadKind::Recording + && download + .start_at + .zip(download.duration_secs) + .is_some_and(|(start_at, duration_secs)| { + now_ts + >= start_at.saturating_add( + i64::try_from(duration_secs).unwrap_or(i64::MAX), + ) + }); + if is_missed { + let mut missed = download.clone(); + missed.finished = true; + missed.paused = false; + missed.state = DownloadState::Failed; + missed.error = Some(RECORDING_WINDOW_EXPIRED_ERR.to_string()); + missed_recordings.push(missed); + return false; + } + let is_due = download.start_at.is_some_and(|start_at| start_at <= now_ts); + if is_due { + let mut queued = download.clone(); + queued.state = DownloadState::Queued; + queued.paused = false; + queued.finished = false; + queued.error = None; + queued.size = 0; + queued.total_size = None; + queued.retry_attempts = 0; + queued.next_retry_at = None; + due_downloads.push(queued); + } + !is_due + }); - let mut due_downloads = Vec::new(); - let mut missed_recordings = Vec::new(); - scheduled.retain(|download| { - let is_missed = Self::recording_start_missed_window(download, now_ts); - if is_missed { - missed_recordings.push(Self::finalize_missed_recording(download.clone())); - return false; + if due_downloads.is_empty() && missed_recordings.is_empty() { + return Ok(None); } - let is_due = download.start_at.is_some_and(|start_at| start_at <= now_ts); - if is_due { - let mut queued = download.clone(); - queued.state = DownloadState::Queued; - queued.paused = false; - queued.finished = false; - queued.error = None; - queued.size = 0; - queued.total_size = None; - queued.retry_attempts = 0; - queued.next_retry_at = None; - due_downloads.push(queued); - } - !is_due - }); - drop(scheduled); - let had_missed_recordings = !missed_recordings.is_empty(); - let missed_count = missed_recordings.len(); - if had_missed_recordings { - self.finished.write().await.extend(missed_recordings); + let due_count = due_downloads.len(); + let missed_count = missed_recordings.len(); + candidate.finished.extend(missed_recordings); + candidate.queue.splice(0..0, due_downloads); + Ok(Some(if due_count == 0 { missed_count } else { due_count })) + }) + .await; + + match result { + Ok(Some(promoted)) => promoted, + Ok(None) | Err(_) => 0, } - - if due_downloads.is_empty() { - if had_missed_recordings { - let _ = self.persist_to_disk().await; - return missed_count; - } - return 0; - } - - let due_count = due_downloads.len(); - let mut queue = self.queue.lock().await; - for download in due_downloads.into_iter().rev() { - queue.push_front(download); - } - drop(queue); - - let _ = self.persist_to_disk().await; - due_count } pub async fn promote_due_scheduled_now(&self) -> usize { self.promote_due_scheduled(Utc::now().timestamp()).await } @@ -876,17 +1544,18 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; *queue.active.write().await = Some(active); - queue.pause_active().await; + queue.pause_active("id").await.expect("pause active"); let paused = queue.active.read().await.clone().expect("active download"); assert_eq!(paused.state, DownloadState::Paused); assert!(paused.paused); assert!(!paused.finished); - queue.resume_active().await; + queue.resume_active("id").await.expect("resume active"); let resumed = queue.active.read().await.clone().expect("active download"); assert_eq!(resumed.state, DownloadState::Downloading); @@ -916,10 +1585,11 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; *queue.active.write().await = Some(active); - queue.cancel_active().await; + queue.cancel_active().await.expect("cancel active"); let cancelled = queue.active.read().await.clone().expect("active download"); assert_eq!(cancelled.state, DownloadState::Cancelled); @@ -951,6 +1621,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; let active = FileDownload { uuid: "active".to_string(), @@ -971,6 +1642,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; let paused = FileDownload { uuid: "paused".to_string(), @@ -991,6 +1663,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; queue.queue.lock().await.push_back(queued); @@ -1039,6 +1712,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; queue.scheduled.write().await.push(scheduled.clone()); @@ -1081,6 +1755,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; let retry_waiting = FileDownload { state: DownloadState::RetryWaiting, @@ -1120,6 +1795,7 @@ mod tests { priority: 0, retry_attempts: 2, next_retry_at: Some(1_700_000_000), + recording: None, }; let restored = DownloadQueue::recover_loaded_download(retry_waiting); @@ -1150,9 +1826,10 @@ mod tests { priority: 0, retry_attempts: 5, next_retry_at: Some(1_700_000_000), + recording: None, }); - assert!(queue.retry_finished("done").await); + assert!(queue.retry_finished("done").await.expect("retry finished")); let queued = queue.queue.lock().await.front().cloned().expect("queued download"); assert_eq!(queued.state, DownloadState::Queued); assert_eq!(queued.retry_attempts, 0); @@ -1182,9 +1859,10 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }); - assert!(!queue.retry_finished("recording").await); + assert!(!queue.retry_finished("recording").await.expect("reject recording retry")); assert!(queue.queue.lock().await.is_empty()); assert_eq!(queue.finished.read().await.len(), 1); } @@ -1211,6 +1889,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; let future = FileDownload { uuid: "future".to_string(), @@ -1231,13 +1910,16 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; queue.scheduled.write().await.extend([due, future]); + let revision = queue.revision.load(Ordering::SeqCst); let promoted = queue.promote_due_scheduled(150).await; assert_eq!(promoted, 1); + assert_eq!(queue.revision.load(Ordering::SeqCst), revision + 1); let queued_items = queue.queue.lock().await.iter().cloned().collect::>(); assert_eq!(queued_items.len(), 1); assert_eq!(queued_items[0].uuid, "due"); @@ -1271,6 +1953,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; queue.scheduled.write().await.push(expired); @@ -1309,6 +1992,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; queue.scheduled.write().await.push(expired); @@ -1343,6 +2027,7 @@ mod tests { retry_backoff_max_secs: 30, retry_backoff_jitter_percent: 0, retry_max_attempts: 5, + recording: None, }; let first = FileDownload::new_recording( @@ -1385,6 +2070,7 @@ mod tests { retry_backoff_max_secs: 30, retry_backoff_jitter_percent: 0, retry_max_attempts: 5, + recording: None, }; let first = FileDownload::new("https://example.com/video.mp4", "first.mp4", &download_cfg, None, 0) @@ -1395,6 +2081,32 @@ mod tests { assert_ne!(first.uuid, second.uuid); } + #[test] + fn download_new_omits_trailing_dot_when_filename_has_no_extension() { + let download_cfg = VideoDownloadConfig { + directory: "/tmp".to_string(), + organize_into_directories: false, + episode_pattern: None, + headers: std::collections::HashMap::new(), + download_priority: 0, + recording_priority: 0, + reserve_slots_for_users: 0, + max_background_per_provider: 0, + retry_backoff_initial_secs: 3, + retry_backoff_multiplier: 3.0, + retry_backoff_max_secs: 30, + retry_backoff_jitter_percent: 0, + retry_max_attempts: 5, + recording: None, + }; + + let task = FileDownload::new("https://example.com/live", "title with trailing dot.", &download_cfg, None, 0) + .expect("download"); + + assert_eq!(task.filename, "title_with_trailing_dot"); + assert!(!task.filename.ends_with('.')); + } + #[tokio::test] async fn promote_due_scheduled_places_due_recordings_ahead_of_existing_queue_items() { let queue = DownloadQueue::new(); @@ -1417,6 +2129,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }); queue.scheduled.write().await.extend([ FileDownload { @@ -1438,6 +2151,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }, FileDownload { uuid: "due-second".to_string(), @@ -1458,6 +2172,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }, ]); @@ -1550,6 +2265,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; *queue.active.write().await = Some(FileDownload { @@ -1628,6 +2344,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }); let different_window = FileDownload { @@ -1649,6 +2366,7 @@ mod tests { priority: 0, retry_attempts: 0, next_retry_at: None, + recording: None, }; assert!(queue.find_duplicate(&different_window).await.is_none()); @@ -1690,4 +2408,860 @@ mod tests { assert_eq!(outcome, DownloadWaitOutcome::Paused); assert!(queue.slot_waiters.snapshots().await.is_empty()); } + + // --- Legacy pre-scan + normalization --- + + fn make_persisted_recording_legacy(file_path: PathBuf) -> PersistedFileDownload { + PersistedFileDownload { + uuid: "rec-legacy".to_string(), + file_dir: file_path.parent().unwrap_or(Path::new("/")).to_path_buf(), + file_path, + filename: "rec.ts".to_string(), + url: "https://example.com/live/rec".to_string(), + finished: true, + size: 0, + total_size: None, + paused: false, + error: None, + state: DownloadState::Completed, + start_at: Some(1_700_000_000), + duration_secs: Some(3_600), + kind: DownloadKind::Recording, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: None, + } + } + + fn make_persisted_recording_with_user(file_path: PathBuf) -> PersistedFileDownload { + let mut p = make_persisted_recording_legacy(file_path); + p.recording = Some(RecordingMetadata { + owner: shared::model::RecordingOwner::User(UserId::from("web:abc")), + visibility: shared::model::RecordingVisibility::default(), + source: None, + program_start: Some(1_700_000_000), + program_end: Some(1_700_003_600), + scheduled_start: Some(1_700_000_000), + scheduled_end: Some(1_700_003_600), + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: None, + channel_name: None, + program_title: None, + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: None, + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: 0, + completed_at: None, + notification_markers: Vec::new(), + deleting_previous_state: None, + }); + p + } + + #[test] + fn legacy_recording_normalizes_to_legacy_admin_with_zero_padding() { + let persisted = make_persisted_recording_legacy(PathBuf::from("/tmp/recordings/rec.ts")); + let task = DownloadQueue::from_persisted_with(persisted, None, None).expect("restore"); + let meta = task.recording.as_ref().expect("recording metadata"); + assert!(meta.owner.is_legacy_admin()); + assert_eq!(meta.visibility, shared::model::RecordingVisibility::Private); + assert_eq!(meta.pre_roll_secs, 0); + assert_eq!(meta.post_roll_secs, 0); + assert_eq!(meta.scheduled_start, Some(1_700_000_000)); + assert_eq!(meta.scheduled_end, Some(1_700_003_600)); + } + + #[test] + fn legacy_recording_derives_relative_path_when_contained() { + let dir = tempfile::tempdir().expect("tempdir"); + let recording_root = dir.path().join("recordings"); + std::fs::create_dir_all(&recording_root).expect("mkdir"); + let file = recording_root.join("2025/pilot.ts"); + let persisted = make_persisted_recording_legacy(file); + let task = + DownloadQueue::from_persisted_with(persisted, Some(&recording_root), None).expect("restore"); + let meta = task.recording.as_ref().expect("metadata"); + assert_eq!(meta.relative_path.as_deref(), Some("2025/pilot.ts")); + } + + #[test] + fn legacy_recording_omits_relative_path_when_outside_roots() { + let dir = tempfile::tempdir().expect("tempdir"); + let recording_root = dir.path().join("recordings"); + std::fs::create_dir_all(&recording_root).expect("mkdir"); + let file = dir.path().join("downloads/old.ts"); + let persisted = make_persisted_recording_legacy(file); + let task = + DownloadQueue::from_persisted_with(persisted, Some(&recording_root), None).expect("restore"); + let meta = task.recording.as_ref().expect("metadata"); + assert!(meta.relative_path.is_none()); + } + + #[test] + fn legacy_recording_uses_legacy_root_when_recording_root_misses() { + let dir = tempfile::tempdir().expect("tempdir"); + let legacy_root = dir.path().join("downloads"); + std::fs::create_dir_all(&legacy_root).expect("mkdir"); + let file = legacy_root.join("rec.ts"); + let persisted = make_persisted_recording_legacy(file.clone()); + let task = DownloadQueue::from_persisted_with(persisted, None, Some(&legacy_root)).expect("restore"); + let meta = task.recording.as_ref().expect("metadata"); + assert_eq!(meta.relative_path.as_deref(), Some("rec.ts")); + } + + #[test] + fn legacy_recording_falls_back_completed_at_to_scheduled_end_when_mtime_unavailable() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("missing-rec.ts"); + let persisted = make_persisted_recording_legacy(file); + let task = DownloadQueue::from_persisted_with(persisted, None, None).expect("restore"); + let meta = task.recording.as_ref().expect("metadata"); + assert_eq!(meta.completed_at, meta.scheduled_end); + assert_eq!(meta.measured_bytes, 0); + } + + #[test] + fn legacy_recording_uses_real_file_mtime_and_size_when_present() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join("rec.ts"); + std::fs::write(&file, b"hello").expect("write"); + let mtime_secs = std::fs::metadata(&file) + .and_then(|m| m.modified()) + .ok() + .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); + let persisted = make_persisted_recording_legacy(file); + let task = DownloadQueue::from_persisted_with(persisted, None, None).expect("restore"); + let meta = task.recording.as_ref().expect("metadata"); + assert_eq!(meta.completed_at, Some(mtime_secs)); + assert_eq!(meta.measured_bytes, 5); + } + + #[test] + fn legacy_recording_rejects_unsafe_dir_symlink_substitution() { + // The legacy check uses string-prefix matching. A symlinked file + // whose canonical path resolves outside the recording root still looks + // contained by the prefix check. + let dir = tempfile::tempdir().expect("tempdir"); + let recording_root = dir.path().join("recordings"); + let other = dir.path().join("elsewhere"); + std::fs::create_dir_all(&other).expect("mkdir"); + std::fs::create_dir_all(&recording_root).expect("mkdir"); + let real = other.join("real.ts"); + std::fs::write(&real, b"x").expect("write"); + let link_path = recording_root.join("alias.ts"); + #[cfg(unix)] + std::os::unix::fs::symlink(&real, &link_path).expect("symlink"); + let persisted = make_persisted_recording_legacy(link_path.clone()); + let task = + DownloadQueue::from_persisted_with(persisted, Some(&recording_root), None).expect("restore"); + let meta = task.recording.as_ref().expect("metadata"); + // The string-prefix check accepts this legacy edge case. + assert_eq!(meta.relative_path.as_deref(), Some("alias.ts")); + } + + #[test] + fn download_with_recording_metadata_is_rejected_by_persisted_load() { + // Plain download (kind=Download) must never carry recording metadata. + let mut p = make_persisted_recording_legacy(PathBuf::from("/tmp/file.ts")); + p.kind = DownloadKind::Download; + p.recording = Some(RecordingMetadata::for_legacy_admin(0, 0)); + let result = DownloadQueue::from_persisted_with(p, None, None); + assert!(result.is_err(), "Download + recording metadata must be rejected"); + assert!( + matches!(result.unwrap_err(), PersistedError::KindMetadataInvariant { .. }), + "must surface the invariant violation, not a parse error" + ); + } + + #[test] + fn from_persisted_rejects_invalid_url() { + let mut p = make_persisted_recording_legacy(PathBuf::from("/tmp/file.ts")); + p.url = "not a url at all".to_string(); + let result = DownloadQueue::from_persisted_with(p, None, None); + assert!(result.is_err(), "invalid url must surface as an error"); + assert!( + matches!(result.unwrap_err(), PersistedError::InvalidUrl(_)), + "must surface the parse error, not an invariant violation" + ); + } + + #[test] + fn normalized_recording_preserves_existing_metadata() { + // Already-normalized tasks (User owner) must not be re-normalized. + let persisted = make_persisted_recording_with_user(PathBuf::from("/tmp/rec.ts")); + let task = DownloadQueue::from_persisted_with(persisted, None, None).expect("restore"); + let meta = task.recording.as_ref().expect("metadata"); + assert!(!meta.owner.is_legacy_admin(), "user owner must be preserved"); + } + + #[test] + fn pre_scan_recording_user_ids_returns_empty_for_missing_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let missing = dir.path().join("does-not-exist.json"); + let ids = pre_scan_recording_user_ids(&missing); + assert!(ids.is_empty()); + } + + fn persisted_recording_with_owner( + uuid: &str, + owner: shared::model::RecordingOwner, + visibility: shared::model::RecordingVisibility, + ) -> PersistedFileDownload { + let meta = RecordingMetadata::new( + owner, + visibility, + shared::model::recording::RecordingSource::new("1", "1", "input-a"), + 1_700_000_000, + 1_700_003_600, + 0, + 0, + ); + PersistedFileDownload { + uuid: uuid.to_string(), + file_dir: PathBuf::from("/tmp"), + file_path: PathBuf::from(format!("/tmp/{uuid}.ts")), + filename: format!("{uuid}.ts"), + url: format!("https://example.com/{uuid}"), + finished: false, + size: 0, + total_size: None, + paused: false, + error: None, + state: DownloadState::Completed, + start_at: None, + duration_secs: None, + kind: DownloadKind::Recording, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: Some(meta), + } + } + + #[test] + fn pre_scan_recording_user_ids_finds_user_ids_in_nested_recording_blocks() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("downloads_state.json"); + let queue = PersistedDownloadQueue { + queue: vec![ + persisted_recording_with_owner( + "a", + shared::model::RecordingOwner::User(UserId::from("web:abc")), + shared::model::RecordingVisibility::Private, + ), + persisted_recording_with_owner( + "b", + shared::model::RecordingOwner::User(UserId::from("api:def")), + shared::model::RecordingVisibility::Shared, + ), + ], + scheduled: vec![], + active: None, + finished: vec![], + revision: QueueRevision::default(), + }; + std::fs::write(&path, serde_json::to_vec_pretty(&queue).unwrap()).expect("write"); + let ids = pre_scan_recording_user_ids(&path); + assert_eq!(ids.len(), 2); + assert!(ids.iter().any(|u| u.0 == "web:abc")); + assert!(ids.iter().any(|u| u.0 == "api:def")); + } + + #[test] + fn pre_scan_recording_user_ids_ignores_legacy_admin_entries() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("downloads_state.json"); + let queue = PersistedDownloadQueue { + queue: vec![persisted_recording_with_owner( + "a", + shared::model::RecordingOwner::LegacyAdmin, + shared::model::RecordingVisibility::Private, + )], + scheduled: vec![], + active: None, + finished: vec![], + revision: QueueRevision::default(), + }; + std::fs::write(&path, serde_json::to_vec_pretty(&queue).unwrap()).expect("write"); + let ids = pre_scan_recording_user_ids(&path); + assert!(ids.is_empty(), "legacy_admin entries must not surface as user IDs"); + } + + #[test] + fn pre_scan_recording_user_ids_returns_empty_for_invalid_json() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("downloads_state.json"); + std::fs::write(&path, b"not json").expect("write"); + let ids = pre_scan_recording_user_ids(&path); + assert!(ids.is_empty()); + } + + // --- Transactional queue mutation boundary --- + + fn make_test_recording_task(uuid: &str, file_path: PathBuf) -> FileDownload { + let task = FileDownload { + uuid: uuid.to_string(), + file_dir: file_path.parent().unwrap_or(Path::new("/")).to_path_buf(), + file_path, + filename: format!("{uuid}.ts"), + url: reqwest::Url::parse(&format!("https://example.com/{uuid}")).expect("valid url"), + finished: false, + size: 0, + total_size: None, + paused: false, + error: None, + state: DownloadState::Downloading, + start_at: None, + duration_secs: None, + kind: DownloadKind::Recording, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: Some(RecordingMetadata::for_legacy_admin(0, 60)), + }; + assert!(task.kind_metadata_invariant_ok()); + task + } + + #[tokio::test] + async fn mutate_persists_and_increments_revision_on_success() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file.clone())); + assert_eq!(queue.revision.load(Ordering::SeqCst), 0); + + // Insert one recording so a candidate is non-empty. + let task = make_test_recording_task("rec-1", dir.path().join("a.ts")); + queue.queue.lock().await.push_back(task); + + let result: Result<(), QueueMutationError> = mutate(&queue, |_candidate| Ok(())).await; + assert!(result.is_ok(), "mutate should succeed: {result:?}"); + // The first committed mutation publishes revision 1, both in + // memory and in the persisted candidate. + assert_eq!(queue.revision.load(Ordering::SeqCst), 1, "counter must store the new value"); + let content = std::fs::read(&state_file).expect("read state file"); + let restored: PersistedDownloadQueue = + serde_json::from_slice(&content).expect("parse state file"); + assert_eq!(restored.revision, QueueRevision(1), "file carries the candidate's revision"); + } + + #[tokio::test] + async fn mutate_keeps_state_when_closure_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file.clone())); + let original_revision = queue.revision.load(Ordering::SeqCst); + let original_len = queue.queue.lock().await.len(); + + let result: Result<(), QueueMutationError> = + mutate(&queue, |_candidate| Err(QueueMutationError::new("validation failed"))).await; + assert!(result.is_err(), "closure error should propagate"); + assert_eq!(queue.queue.lock().await.len(), original_len, "queue must stay unchanged"); + assert_eq!(queue.revision.load(Ordering::SeqCst), original_revision); + assert!(!state_file.exists(), "no file should be written on closure error"); + } + + #[tokio::test] + async fn mutate_keeps_state_when_persist_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + // Point the state file at a path that already exists as a directory + // so the atomic write (open + write + rename) fails. + let state_file = dir.path().join("blocking-dir"); + std::fs::create_dir_all(&state_file).expect("create blocking dir"); + let queue = DownloadQueue::new_with_state_file(Some(state_file)); + let original_len = queue.queue.lock().await.len(); + let original_revision = queue.revision.load(Ordering::SeqCst); + + let result: Result<(), QueueMutationError> = mutate(&queue, |_candidate| Ok(())).await; + assert!(result.is_err(), "persist failure should propagate"); + assert!(result.unwrap_err().source_io().is_some(), "should carry io::Error"); + // State stays unchanged: the in-memory queue is intact. + assert_eq!(queue.queue.lock().await.len(), original_len, "in-memory state must be unchanged"); + assert_eq!(queue.revision.load(Ordering::SeqCst), original_revision); + } + + #[tokio::test] + async fn mutate_invalid_candidate_keeps_existing_file_memory_and_revision() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file.clone())); + let task = DownloadQueue::to_persisted(&make_test_recording_task("rec-1", dir.path().join("a.ts"))); + mutate(&queue, |candidate| { + candidate.queue.push(task); + Ok(()) + }) + .await + .expect("initial commit"); + let original_file = std::fs::read(&state_file).expect("read initial state"); + + let result: Result<(), QueueMutationError> = mutate(&queue, |candidate| { + if let Some(download) = candidate.queue.first_mut() { + download.url = "not a url".to_string(); + } + Ok(()) + }) + .await; + + assert!(result.is_err()); + assert_eq!(queue.revision.load(Ordering::SeqCst), 1); + assert_eq!(std::fs::read(&state_file).expect("read unchanged state"), original_file); + assert_eq!(queue.queue.lock().await.front().map(|download| download.uuid.as_str()), Some("rec-1")); + } + + #[tokio::test] + async fn control_uuid_mismatch_is_noop_and_preserves_next_task() { + let queue = DownloadQueue::new(); + *queue.active.write().await = Some(make_test_recording_task("active", PathBuf::from("/tmp/active.ts"))); + queue + .queue + .lock() + .await + .push_back(make_test_recording_task("next", PathBuf::from("/tmp/next.ts"))); + + assert!(!queue.pause_active("next").await.expect("uuid mismatch")); + + assert_eq!(queue.revision.load(Ordering::SeqCst), 0); + assert_eq!(queue.active.read().await.as_ref().map(|download| download.uuid.as_str()), Some("active")); + assert_eq!(queue.queue.lock().await.front().map(|download| download.uuid.as_str()), Some("next")); + assert_eq!(*queue.control_signal.read().await, DownloadControl::None); + } + + #[tokio::test] + async fn retry_finished_legacy_writer_commits_one_revision() { + let queue = DownloadQueue::new(); + let mut finished = make_test_recording_task("done", PathBuf::from("/tmp/done.ts")); + finished.kind = DownloadKind::Download; + finished.recording = None; + finished.finished = true; + finished.state = DownloadState::Failed; + queue.finished.write().await.push(finished); + + assert!(queue.retry_finished("done").await.expect("retry commit")); + + assert_eq!(queue.revision.load(Ordering::SeqCst), 1); + assert!(queue.finished.read().await.is_empty()); + assert_eq!(queue.queue.lock().await.front().map(|download| download.uuid.as_str()), Some("done")); + } + + #[tokio::test] + async fn concurrent_legacy_writers_serialize_and_increment_each_revision() { + let queue = Arc::new(DownloadQueue::new()); + queue + .queue + .lock() + .await + .push_back(make_test_recording_task("remove", PathBuf::from("/tmp/remove.ts"))); + let mut finished = make_test_recording_task("retry", PathBuf::from("/tmp/retry.ts")); + finished.kind = DownloadKind::Download; + finished.recording = None; + finished.finished = true; + finished.state = DownloadState::Failed; + queue.finished.write().await.push(finished); + + let remove_queue = Arc::clone(&queue); + let retry_queue = Arc::clone(&queue); + let (removed, retried) = tokio::join!( + async move { remove_queue.remove_from_queue("remove").await }, + async move { retry_queue.retry_finished("retry").await }, + ); + + assert!(removed.expect("remove commit")); + assert!(retried.expect("retry commit")); + assert_eq!(queue.revision.load(Ordering::SeqCst), 2); + assert_eq!(queue.queue.lock().await.front().map(|download| download.uuid.as_str()), Some("retry")); + assert!(queue.finished.read().await.is_empty()); + } + + #[tokio::test] + async fn mutate_serializes_concurrent_calls() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = std::sync::Arc::new(DownloadQueue::new_with_state_file(Some(state_file))); + let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(5)); + + let mut handles = Vec::new(); + for _ in 0..5 { + let q = std::sync::Arc::clone(&queue); + let b = std::sync::Arc::clone(&barrier); + handles.push(tokio::spawn(async move { + b.wait().await; + mutate(&q, |_candidate| Ok(())).await + })); + } + for h in handles { + assert!(h.await.expect("join").is_ok(), "all mutations should succeed"); + } + let final_rev = queue.revision.load(Ordering::SeqCst); + assert_eq!(final_rev, 5); + } + + #[tokio::test] + async fn committed_snapshot_waits_for_mutation_boundary() { + let queue = std::sync::Arc::new(DownloadQueue::new()); + let task = make_test_recording_task("rec-1", PathBuf::from("/tmp/rec-1.ts")); + queue.queue.lock().await.push_back(task); + + let mutation_guard = queue.mutation_guard.lock().await; + let snapshot_queue = std::sync::Arc::clone(&queue); + let snapshot = tokio::spawn(async move { snapshot_queue.committed_snapshot().await }); + + tokio::task::yield_now().await; + assert!(!snapshot.is_finished(), "snapshot must wait for the mutation boundary"); + + drop(mutation_guard); + let Ok((revision, tasks)) = snapshot.await else { + unreachable!("snapshot task failed"); + }; + assert_eq!(revision, QueueRevision(0)); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks.first().map(|task| task.uuid.as_str()), Some("rec-1")); + } + + #[tokio::test] + async fn committed_download_snapshot_waits_for_mutation_boundary() { + let queue = Arc::new(DownloadQueue::new()); + let mutation_guard = queue.mutation_guard.lock().await; + let snapshot_queue = Arc::clone(&queue); + let snapshot = tokio::spawn(async move { snapshot_queue.committed_download_snapshot().await }); + + tokio::task::yield_now().await; + assert!(!snapshot.is_finished()); + + drop(mutation_guard); + assert!(snapshot.await.is_ok()); + } + + #[tokio::test] + async fn control_signal_is_ordered_inside_mutation_guard() { + let queue = Arc::new(DownloadQueue::new()); + *queue.active.write().await = Some(make_test_recording_task("active", PathBuf::from("/tmp/active.ts"))); + let control_lock = queue.control_signal.write().await; + let pause_queue = Arc::clone(&queue); + let pause = tokio::spawn(async move { pause_queue.pause_active("active").await }); + + for _ in 0..100 { + if queue.revision.load(Ordering::SeqCst) == 1 { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(queue.revision.load(Ordering::SeqCst), 1); + let snapshot_queue = Arc::clone(&queue); + let snapshot = tokio::spawn(async move { snapshot_queue.committed_snapshot().await }); + tokio::task::yield_now().await; + assert!(!snapshot.is_finished(), "mutation guard must remain held until control publication"); + + drop(control_lock); + assert!(pause.await.is_ok_and(|result| result.is_ok())); + assert!(snapshot.await.is_ok()); + assert_eq!(*queue.control_signal.read().await, DownloadControl::Pause); + } + + #[tokio::test] + async fn same_value_control_is_published_after_worker_commit_and_clear() { + let queue = Arc::new(DownloadQueue::new()); + *queue.active.write().await = Some(make_test_recording_task("task-a", PathBuf::from("/tmp/task-a.ts"))); + queue + .queue + .lock() + .await + .push_back(make_test_recording_task("task-b", PathBuf::from("/tmp/task-b.ts"))); + let mut control_lock = queue.control_signal.write().await; + *control_lock = DownloadControl::Cancel; + let worker_queue = Arc::clone(&queue); + let worker_commit = tokio::spawn(async move { + worker_queue + .mutate_optional_and_clear_control(DownloadControl::Cancel, |candidate| { + let Some(mut active) = candidate.active.take() else { + return Ok(None); + }; + active.finished = true; + candidate.finished.push(active); + if !candidate.queue.is_empty() { + candidate.active = Some(candidate.queue.remove(0)); + } + Ok(Some(true)) + }) + .await + }); + + for _ in 0..100 { + if queue.revision.load(Ordering::SeqCst) == 1 { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(queue.revision.load(Ordering::SeqCst), 1); + let api_queue = Arc::clone(&queue); + let newer_cancel = tokio::spawn(async move { api_queue.cancel_active_matching("task-b").await }); + tokio::task::yield_now().await; + assert!(!newer_cancel.is_finished()); + + drop(control_lock); + + assert!(worker_commit.await.expect("worker task").expect("worker commit").is_some()); + assert!(newer_cancel.await.expect("cancel task").expect("cancel commit")); + assert_eq!(*queue.control_signal.read().await, DownloadControl::Cancel); + } + + #[tokio::test] + async fn mutate_swap_restores_in_memory_state_from_persisted_candidate() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file.clone())); + + let persisted = DownloadQueue::to_persisted(&make_test_recording_task("rec-1", dir.path().join("a.ts"))); + mutate(&queue, |candidate| { + candidate.queue.push(persisted); + Ok(()) + }) + .await + .expect("first mutate"); + + let len_after_commit = queue.queue.lock().await.len(); + assert_eq!(len_after_commit, 1, "committed task must be in memory"); + + // Now mutate again to remove that task. The candidate should be + // re-built from the just-committed state, not from the empty + // pre-mutate in-memory state. + mutate(&queue, |candidate| { + candidate.queue.retain(|d| d.uuid != "rec-1"); + Ok(()) + }) + .await + .expect("second mutate"); + + assert!(queue.queue.lock().await.is_empty(), "remove must propagate to in-memory state"); + assert_eq!(queue.revision.load(Ordering::SeqCst), 2); + } + + // --- Filename rendering + collision reservation --- + + fn collect_existing_relative_paths(candidate: &PersistedDownloadQueue) -> Vec { + let mut out = Vec::new(); + for d in &candidate.queue { + if let Some(meta) = &d.recording { + if let Some(p) = &meta.relative_path { + out.push(p.clone()); + } + } + } + for d in &candidate.scheduled { + if let Some(meta) = &d.recording { + if let Some(p) = &meta.relative_path { + out.push(p.clone()); + } + } + } + if let Some(d) = &candidate.active { + if let Some(meta) = &d.recording { + if let Some(p) = &meta.relative_path { + out.push(p.clone()); + } + } + } + for d in &candidate.finished { + if let Some(meta) = &d.recording { + if let Some(p) = &meta.relative_path { + out.push(p.clone()); + } + } + } + out + } + + /// Reserve a unique relative path for a new recording inside the + /// queue mutation boundary. The candidate is the in-memory + /// `PersistedDownloadQueue` the closure is building; the helper + /// collects all already-reserved `relative_path` values, applies + /// the supplied stem, and appends a numbered collision suffix + /// (`_1`, `_2`, …) until the result is unique. The reserved path + /// is also written to the recording metadata so a later collision + /// created externally is detected by the worker at execute time. + fn reserve_recording_relative_path( + candidate: &mut PersistedDownloadQueue, + stem: &str, + recording_uuid: &str, + ) -> String { + // If this recording already has a reserved path (e.g., a retry + // or an edit), keep it. Re-reserving on a re-entrant call must + // not bump the suffix because of the caller's own previous + // entry. + let existing_self = find_recording_relative_path(candidate, recording_uuid); + if let Some(prior) = existing_self { + return prior; + } + let existing = collect_existing_relative_paths(candidate); + let reserved = shared::utils::next_collision_suffix(stem, &existing); + for d in &mut candidate.queue { + if d.uuid == recording_uuid { + attach_relative_path(&mut d.recording, &reserved); + } + } + for d in &mut candidate.scheduled { + if d.uuid == recording_uuid { + attach_relative_path(&mut d.recording, &reserved); + } + } + if let Some(d) = candidate.active.as_mut() { + if d.uuid == recording_uuid { + attach_relative_path(&mut d.recording, &reserved); + } + } + for d in &mut candidate.finished { + if d.uuid == recording_uuid { + attach_relative_path(&mut d.recording, &reserved); + } + } + reserved + } + + fn find_recording_relative_path( + candidate: &PersistedDownloadQueue, + recording_uuid: &str, + ) -> Option { + for d in &candidate.queue { + if d.uuid == recording_uuid { + if let Some(meta) = &d.recording { + if let Some(p) = &meta.relative_path { + return Some(p.clone()); + } + } + } + } + for d in &candidate.scheduled { + if d.uuid == recording_uuid { + if let Some(meta) = &d.recording { + if let Some(p) = &meta.relative_path { + return Some(p.clone()); + } + } + } + } + if let Some(d) = &candidate.active { + if d.uuid == recording_uuid { + if let Some(meta) = &d.recording { + if let Some(p) = &meta.relative_path { + return Some(p.clone()); + } + } + } + } + for d in &candidate.finished { + if d.uuid == recording_uuid { + if let Some(meta) = &d.recording { + if let Some(p) = &meta.relative_path { + return Some(p.clone()); + } + } + } + } + None + } + + fn attach_relative_path(meta: &mut Option, path: &str) { + if let Some(m) = meta.as_mut() { + m.relative_path = Some(path.to_string()); + } + } + + fn persisted_recording(uuid: &str, meta: RecordingMetadata) -> PersistedFileDownload { + PersistedFileDownload { + uuid: uuid.to_string(), + file_dir: PathBuf::from("/tmp"), + file_path: PathBuf::from(format!("/tmp/{uuid}.ts")), + filename: format!("{uuid}.ts"), + url: format!("https://example.com/{uuid}"), + finished: false, + size: 0, + total_size: None, + paused: false, + error: None, + state: DownloadState::Scheduled, + start_at: Some(0), + duration_secs: Some(60), + kind: DownloadKind::Recording, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: Some(meta), + } + } + + #[test] + fn reserve_recording_relative_path_returns_numbered_stem_when_no_collision() { + let mut candidate = PersistedDownloadQueue::default(); + let reserved = reserve_recording_relative_path(&mut candidate, "pilot", "rec-1"); + assert_eq!(reserved, "pilot_1", "numbered suffix is the first candidate"); + } + + #[test] + fn reserve_recording_relative_path_skips_existing_paths() { + let mut candidate = PersistedDownloadQueue::default(); + let mut occupied = RecordingMetadata::for_legacy_admin(0, 60); + occupied.relative_path = Some("pilot_1".to_string()); + candidate.queue.push(persisted_recording("other", occupied)); + let reserved = reserve_recording_relative_path(&mut candidate, "pilot", "rec-1"); + assert_eq!(reserved, "pilot_2"); + } + + #[test] + fn reserve_recording_relative_path_does_not_double_bump_for_self() { + let mut candidate = PersistedDownloadQueue::default(); + let mut existing = RecordingMetadata::for_legacy_admin(0, 60); + existing.relative_path = Some("pilot_3".to_string()); + let mut d = persisted_recording("rec-1", existing); + d.recording.as_mut().expect("recording").relative_path = Some("pilot_3".to_string()); + candidate.queue.push(d); + let reserved = reserve_recording_relative_path(&mut candidate, "pilot", "rec-1"); + assert_eq!(reserved, "pilot_3", "must not bump because of self"); + } + + #[tokio::test] + async fn pause_active_routes_through_mutate() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file)); + let task = make_test_recording_task("rec-1", dir.path().join("a.ts")); + { + let mut active = queue.active.write().await; + *active = Some(task); + } + let prior_revision = queue.revision.load(Ordering::SeqCst); + queue.pause_active("rec-1").await.expect("pause_active"); + let active = queue.active.read().await.clone().expect("active"); + assert!(active.paused); + assert_eq!(active.state, DownloadState::Paused); + assert!(active.next_retry_at.is_none()); + assert_eq!(queue.revision.load(Ordering::SeqCst), prior_revision + 1); + } + + #[test] + fn derive_legacy_relative_path_rejects_parent_dir_traversal() { + // `/../downloads/old.ts` strips cleanly under + // `Path::strip_prefix`, but the remaining `../downloads/old.ts` + // would let a downstream `join(root)` resolve back outside the + // recording root. The fix rejects anything other than + // `Component::Normal` after the strip. + let root = Path::new("/data/recordings"); + let traversal = Path::new("/data/recordings/../downloads/old.ts"); + assert!(derive_legacy_relative_path(traversal, Some(root), None).is_none()); + + // Sanity: a path that genuinely lives under the root still + // produces its relative form. + let inside = Path::new("/data/recordings/2026-08/rec.ts"); + assert_eq!( + derive_legacy_relative_path(inside, Some(root), None).as_deref(), + Some("2026-08/rec.ts"), + ); + } } diff --git a/backend/src/api/model/event_manager.rs b/backend/src/api/model/event_manager.rs index f1ad5e24b..b22d6820e 100644 --- a/backend/src/api/model/event_manager.rs +++ b/backend/src/api/model/event_manager.rs @@ -1,8 +1,9 @@ use crate::api::model::streams::{MeterReading, StreamMeterHandle}; use log::trace; use shared::model::{ - ActiveUserConnectionChange, ConfigType, DownloadsDelta, DownloadsResponse, LibraryScanProgressEvent, - PlaylistUpdateProgressEvent, PlaylistUpdateState, StreamMeterEntry, SystemInfo, + ActiveUserConnectionChange, ConfigType, DownloadsDelta, DownloadsResponse, + LibraryScanProgressEvent, PlaylistUpdateProgressEvent, PlaylistUpdateState, StreamMeterEntry, + SystemInfo, }; use std::{ collections::HashMap, @@ -28,6 +29,8 @@ pub enum EventMessage { LibraryScanProgress(LibraryScanProgressEvent), DownloadsUpdate(DownloadsResponse), DownloadsDeltaUpdate(DownloadsDelta), + RecordingChanged, + RecordingRulesChanged, InputMetadataUpdatesCompleted(Arc), InputMetadataUpdatesStarted(Arc), } @@ -77,6 +80,9 @@ impl EventManager { stream_meter_subscriber_count: Arc, cancel_token: CancellationToken, ) { + if tokio::runtime::Handle::try_current().is_err() { + return; + } tokio::spawn(async move { let mut interval = tokio::time::interval(STREAM_METER_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); diff --git a/backend/src/api/model/hls_cache/manager.rs b/backend/src/api/model/hls_cache/manager.rs index f1b6a9010..cdb3aa53b 100644 --- a/backend/src/api/model/hls_cache/manager.rs +++ b/backend/src/api/model/hls_cache/manager.rs @@ -104,6 +104,7 @@ struct HlsProxyRuntimeConfig { cache_duration_seconds: u64, strip: StripConfig, origin_manifest_timeout_ms: u64, + initial_manifest_wait_timeout_secs: u64, manifest_recovery_burst: HlsManifestRecoveryBurstConfig, transient_resource_ttl_ms: u64, gc_policy: GarbageCollectionPolicy, @@ -458,6 +459,7 @@ impl HlsProxyRuntimeConfig { cache_duration_seconds: config.cache_duration, strip: config.strip.clone(), origin_manifest_timeout_ms: config.origin_manifest_timeout_ms, + initial_manifest_wait_timeout_secs: config.initial_manifest_wait_timeout_secs, manifest_recovery_burst: config.manifest_recovery_burst.clone(), transient_resource_ttl_ms: config.cache_duration.saturating_mul(1_000), gc_policy: GarbageCollectionPolicy::from_config(config), @@ -814,6 +816,10 @@ impl HlsProxyManager { pub fn origin_manifest_timeout_ms(&self) -> u64 { self.runtime_config.load().origin_manifest_timeout_ms } + pub fn initial_manifest_wait_timeout_secs(&self) -> u64 { + self.runtime_config.load().initial_manifest_wait_timeout_secs + } + pub fn manifest_recovery_burst(&self) -> HlsManifestRecoveryBurstConfig { self.runtime_config.load().manifest_recovery_burst.clone() } diff --git a/backend/src/api/model/hls_cache/manifest_fetch.rs b/backend/src/api/model/hls_cache/manifest_fetch.rs index b0acceb85..a41afe1d3 100644 --- a/backend/src/api/model/hls_cache/manifest_fetch.rs +++ b/backend/src/api/model/hls_cache/manifest_fetch.rs @@ -70,8 +70,9 @@ use std::{ use tokio::{task::JoinSet, time::timeout}; use url::Url; -const MAX_MANUAL_REDIRECTS: usize = 10; const DEFAULT_HLS_TARGET_DURATION_SECS: u32 = 15; + +use super::MAX_MANUAL_REDIRECTS; const DEFAULT_HLS_SESSION_IDLE_TIMEOUT_SECS: u64 = 300; const HLS_COMMITTED_CONTENT_ANCHOR_PROBE_LIMIT: usize = 64; pub(crate) const MAX_HLS_MANIFEST_BYTES: usize = 2 * 1024 * 1024; @@ -140,6 +141,11 @@ impl RetryPolicy { } pub(crate) fn attempt_count(&self) -> usize { self.delays_ms.len() } + + /// Samples a uniform jitter in `0..=jitter_max_ms` (0 when jitter is disabled). + pub(crate) fn sample_jitter_ms(&self) -> u64 { + if self.jitter_max_ms == 0 { 0 } else { fastrand::u64(0..=self.jitter_max_ms) } + } } #[derive(Debug, Clone, Copy, Eq, PartialEq)] @@ -735,11 +741,7 @@ async fn fetch_hls_origin_manifest_initial_global_policy( OriginManifestFetchError::RetryableStatus(_, retry_after_ms) => *retry_after_ms, _ => None, }; - let jitter_ms = if retry_after_ms.is_some() || context.retry_policy.jitter_max_ms == 0 { - 0 - } else { - fastrand::u64(0..=context.retry_policy.jitter_max_ms) - }; + let jitter_ms = if retry_after_ms.is_some() { 0 } else { context.retry_policy.sample_jitter_ms() }; let delay_ms = next_retry_delay_ms(&context.retry_policy, attempt_index, retry_after_ms, jitter_ms); log_manifest_retry_scheduled( context, @@ -811,11 +813,7 @@ where let delay_ms = if attempt_index > 0 && next_attempt_is_full_plan { 0 } else { - let jitter = if context.retry_policy.jitter_max_ms == 0 { - 0 - } else { - fastrand::u64(0..=context.retry_policy.jitter_max_ms) - }; + let jitter = context.retry_policy.sample_jitter_ms(); context.retry_policy.delay_for_attempt_ms(attempt_index, jitter).unwrap_or_default() }; let acceptance_deadline = current_acceptance_deadline(context).await; @@ -3130,8 +3128,7 @@ pub(crate) async fn refresh_from_live_hls_entrypoint_with_retries( for attempt_index in 0..attempts { let delay_ms = retry_after_delay_ms.take().unwrap_or_else(|| { - let jitter = - if retry_policy.jitter_max_ms == 0 { 0 } else { fastrand::u64(0..=retry_policy.jitter_max_ms) }; + let jitter = retry_policy.sample_jitter_ms(); retry_policy.delay_for_attempt_ms(attempt_index, jitter).unwrap_or_default() }); if delay_ms > 0 { diff --git a/backend/src/api/model/hls_cache/mod.rs b/backend/src/api/model/hls_cache/mod.rs index d198d7126..8b8108b56 100644 --- a/backend/src/api/model/hls_cache/mod.rs +++ b/backend/src/api/model/hls_cache/mod.rs @@ -43,6 +43,9 @@ mod critical_handoff; mod cutover; mod deadline; mod deterministic_conflict; + +/// Redirect-following cap shared by manifest, resource and endpoint fetchers. +pub(crate) const MAX_MANUAL_REDIRECTS: usize = 10; mod gc; mod headers; mod ids; diff --git a/backend/src/api/model/hls_cache/refresh.rs b/backend/src/api/model/hls_cache/refresh.rs index 22ff0f69a..c176fea13 100644 --- a/backend/src/api/model/hls_cache/refresh.rs +++ b/backend/src/api/model/hls_cache/refresh.rs @@ -157,6 +157,49 @@ impl HlsManifestFetchFailureSignal { } } +/// Parse a "first,second,later" millisecond triple; anything else yields `None`. +fn parse_refresh_failure_backoff_schedule(value: &str) -> Option<[u64; 3]> { + let parts: Vec = value.split(',').map(str::trim).map(str::parse).collect::>().ok()?; + match parts.as_slice() { + [first, second, later] => Some([*first, *second, *later]), + _ => None, + } +} + +/// Failure backoff schedule; overridable via `TULIPROX_HLS_REFRESH_BACKOFF_MS` ("first,second,later"). +fn refresh_failure_backoff_schedule() -> [u64; 3] { + static SCHEDULE: std::sync::LazyLock<[u64; 3]> = std::sync::LazyLock::new(|| { + let default = [FIRST_FAILURE_BACKOFF_MS, SECOND_FAILURE_BACKOFF_MS, LATER_FAILURE_BACKOFF_MS]; + std::env::var("TULIPROX_HLS_REFRESH_BACKOFF_MS") + .ok() + .and_then(|value| parse_refresh_failure_backoff_schedule(&value)) + .unwrap_or(default) + }); + *SCHEDULE +} + +#[cfg(test)] +mod backoff_schedule_tests { + use super::parse_refresh_failure_backoff_schedule; + + #[test] + fn parses_valid_three_value_override() { + assert_eq!(parse_refresh_failure_backoff_schedule("100, 200,300"), Some([100, 200, 300])); + } + + #[test] + fn rejects_malformed_values() { + assert_eq!(parse_refresh_failure_backoff_schedule("abc,200,300"), None); + assert_eq!(parse_refresh_failure_backoff_schedule(""), None); + } + + #[test] + fn rejects_wrong_number_of_parts() { + assert_eq!(parse_refresh_failure_backoff_schedule("100,200"), None); + assert_eq!(parse_refresh_failure_backoff_schedule("100,200,300,400"), None); + } +} + /// Debounce and singleflight state for one live HLS origin manifest. #[derive(Debug, Clone, Default, Eq, PartialEq)] pub struct OriginRefreshState { @@ -218,10 +261,11 @@ impl OriginRefreshState { } fn next_failure_backoff_ms(&self) -> u64 { + let [first, second, later] = refresh_failure_backoff_schedule(); match self.consecutive_failures { - 0 => FIRST_FAILURE_BACKOFF_MS, - 1 => SECOND_FAILURE_BACKOFF_MS, - _ => LATER_FAILURE_BACKOFF_MS, + 0 => first, + 1 => second, + _ => later, } } } diff --git a/backend/src/api/model/hls_cache/resource_fetch.rs b/backend/src/api/model/hls_cache/resource_fetch.rs index a478162e5..9d056da6a 100644 --- a/backend/src/api/model/hls_cache/resource_fetch.rs +++ b/backend/src/api/model/hls_cache/resource_fetch.rs @@ -24,9 +24,10 @@ use std::{ }; use url::Url; -const MAX_MANUAL_REDIRECTS: usize = 10; const STORAGE_FULL_RAW_OS_ERRORS: &[i32] = &[28, 112, 122]; +use super::MAX_MANUAL_REDIRECTS; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum HlsResourceStatusClass { Success, diff --git a/backend/src/api/model/hls_cache/response.rs b/backend/src/api/model/hls_cache/response.rs index b97276790..6793bd23c 100644 --- a/backend/src/api/model/hls_cache/response.rs +++ b/backend/src/api/model/hls_cache/response.rs @@ -95,11 +95,10 @@ fn build_finite_bytes_response( response } -/// Serves immutable prepared bytes without attaching live-lease activity. +/// Serves immutable prepared bytes without touching the live-lease state. /// -/// This is used by finite standalone media and exact committed-plan replay, -/// where the route itself is the authorization boundary and no live origin -/// work may be extended. +/// The route is the authorization boundary; callers do not get any +/// upstream origin work on top of the response. pub(crate) fn finite_hls_immutable_media_response( bytes: Bytes, range_header: Option<&HeaderValue>, diff --git a/backend/src/api/model/hls_cache/segment_repair.rs b/backend/src/api/model/hls_cache/segment_repair.rs index 1be6c1d6a..3e7a18059 100644 --- a/backend/src/api/model/hls_cache/segment_repair.rs +++ b/backend/src/api/model/hls_cache/segment_repair.rs @@ -2204,7 +2204,13 @@ pub(super) async fn sha256_file(path: &Path) -> io::Result { } hasher.update(&buffer[..read]); } - Ok(format!("{:x}", hasher.finalize())) + let digest: [u8; 32] = hasher.finalize().into(); + let mut hex = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(hex, "{byte:02x}"); + } + Ok(hex) } pub(super) fn ffmpeg_identity_version() -> String { "system".to_string() } diff --git a/backend/src/api/model/hls_cache/transient_fetcher.rs b/backend/src/api/model/hls_cache/transient_fetcher.rs index 5e0bdbc13..e12052f68 100644 --- a/backend/src/api/model/hls_cache/transient_fetcher.rs +++ b/backend/src/api/model/hls_cache/transient_fetcher.rs @@ -801,8 +801,14 @@ impl Drop for HlsTransientOriginIoGuard { let session = Arc::clone(&self.session); let origin_io = self.origin_io.clone(); let started_generation = self.started_generation; + // Decrement the origin work count synchronously when the lock is free so an + // immediate retry is not rejected by the admission check (active_origin_work_count > 0) + // while the spawned cleanup is still pending + let pre_finished = session.try_write().map(|mut guard| guard.finish_origin_work(started_generation)).ok(); tokio::spawn(async move { - let generation_valid = { + let generation_valid = if let Some(valid) = pre_finished { + valid + } else { let mut session = session.write().await; session.finish_origin_work(started_generation) }; diff --git a/backend/src/api/model/mod.rs b/backend/src/api/model/mod.rs index b78f05243..cb9855113 100644 --- a/backend/src/api/model/mod.rs +++ b/backend/src/api/model/mod.rs @@ -5,7 +5,7 @@ mod app_state; mod byte_range; mod connection_manager; mod download; -mod event_manager; +pub mod event_manager; mod hls_cache; mod hls_provisioning; mod metadata_update_manager; @@ -16,7 +16,7 @@ mod provider_dns_manager; mod provider_lineup_manager; mod proxy; mod qos_aggregation_manager; -mod recording_worker; +pub(in crate::api) mod recording; mod request; mod stream; mod stream_error; @@ -30,9 +30,11 @@ pub(in crate::api) use self::hls_provisioning::{ }; pub use self::{ active_provider_manager::*, app_state::*, connection_manager::*, event_manager::*, hls_cache::*, - hls_provisioning::HlsProvisioningState, metadata_update_manager::*, playlist_mem_cache::*, provider_dns_manager::*, - provider_lineup_manager::*, proxy::*, stream::*, update_guard::*, + hls_provisioning::HlsProvisioningState, metadata_update_manager::*, playlist_mem_cache::*, + provider_dns_manager::*, provider_lineup_manager::*, proxy::*, recording::*, stream::*, + update_guard::*, }; +pub use self::download::{DownloadKind, DownloadState}; pub(in crate::api) use self::{ active_user_manager::*, admission_strategy::{evaluate_strategy, AdmissionDecision, EvictionCandidate, GraceMode, StrategyContext}, @@ -48,7 +50,6 @@ pub(in crate::api) use self::{ model_utils::*, provider_config::*, qos_aggregation_manager::*, - recording_worker::*, request::*, stream_error::*, xtream::*, diff --git a/backend/src/api/model/recording/mod.rs b/backend/src/api/model/recording/mod.rs new file mode 100644 index 000000000..8ef671f97 --- /dev/null +++ b/backend/src/api/model/recording/mod.rs @@ -0,0 +1,31 @@ +pub mod recording_catalog_access; +pub mod recording_conflict; +pub mod recording_currently_airing; +pub mod recording_deletion; +pub mod recording_disk; +pub mod recording_edit; +pub mod recording_math; +pub mod recording_notification; +pub mod recording_notification_adapter; +pub mod recording_observability; +pub mod recording_occurrence; +pub mod recording_quota; +pub mod recording_reconciliation; +pub mod recording_retention; +pub mod recording_rule_scheduler; +pub mod recording_rule_service; +pub mod recording_security; +pub mod recording_service; +pub mod recording_supervisor; +pub mod recording_worker; +pub mod recording_worker_runner; +pub mod recording_ws; + +pub use self::{ + recording_catalog_access::*, recording_conflict::*, recording_currently_airing::*, recording_deletion::*, + recording_disk::*, recording_edit::*, recording_math::*, recording_notification::*, + recording_notification_adapter::*, + recording_observability::*, recording_occurrence::*, recording_quota::*, recording_reconciliation::*, + recording_retention::*, recording_rule_scheduler::*, recording_rule_service::*, recording_security::*, + recording_service::*, recording_supervisor::*, recording_worker::*, recording_worker_runner::*, recording_ws::*, +}; diff --git a/backend/src/api/model/recording/recording_catalog_access.rs b/backend/src/api/model/recording/recording_catalog_access.rs new file mode 100644 index 000000000..ad4983e0e --- /dev/null +++ b/backend/src/api/model/recording/recording_catalog_access.rs @@ -0,0 +1,438 @@ +//! Authorized catalog and media access. +//! +//! Authorization is rechecked before serialization and file open. The relative +//! path and file type are revalidated at open time, and new opens are denied +//! once deletion starts. + +use std::path::Path; + +use shared::model::permission::Permission; +use shared::model::recording::{ + RecordingMetadata, RecordingOwner, RecordingVisibility, +}; +use shared::model::{Claims, FileDownloadDto, RecordingTaskDto, UserId, CURRENT_PERMISSION_SCHEMA_VERSION}; + +use crate::api::model::download::DownloadQueue; +use crate::auth::{ + authorize, RecordingAction, RecordingDecision, RecordingSubject, TerminalState, +}; +use shared::model::recording_catalog::{CatalogKey, RecordingCatalogEntry}; + +/// Why a catalog or media request was denied. The HTTP layer maps +/// this to a stable status code; the path string is the canonical +/// `recording_*` wire code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CatalogAccessError { + /// Token stale: missing subject or old schema. Frontend + /// must trigger a refresh. + TokenRefreshRequired, + /// The principal lacks `recording.read`. + MissingPermission, + /// Recording not found at the requested key. + NotFound, + /// The principal may not see the specific task (private to a + /// different owner, legacy admin for non-admins, etc.). + Forbidden, + /// The path is unsafe (symlink, directory, missing, or not + /// within the recording root). The relative path and file type + /// are re-validated at open time. + InvalidPath, + /// The recording is in `Deleting` state: an already opened + /// stream may finish where the OS permits, but a new open + /// after `Deleting` is denied. + InDeletingState, + /// Catch-all so the frontend does not panic on a backend change. + Other(String), +} + +impl CatalogAccessError { + pub fn code(&self) -> &'static str { + match self { + Self::TokenRefreshRequired => "recording_token_refresh_required", + Self::MissingPermission => "recording_missing_permission", + Self::NotFound => "recording_not_found", + Self::Forbidden => "recording_forbidden", + Self::InvalidPath => "recording_invalid_path", + Self::InDeletingState => "recording_in_deleting_state", + Self::Other(_) => "recording_other", + } + } +} + +impl std::fmt::Display for CatalogAccessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Other(c) => f.write_str(c), + other => f.write_str(other.code()), + } + } +} + +impl std::error::Error for CatalogAccessError {} + +/// Stale schema check: returns `TokenRefreshRequired` when the token's +/// `permission_schema_version` is older than the constant. Reject +/// absent, unknown, or stale subject/version claims with a stable +/// token-refresh-required response. +fn check_schema_version(claims: &Claims) -> Result<(), CatalogAccessError> { + if claims.permission_schema_version < CURRENT_PERMISSION_SCHEMA_VERSION { + Err(CatalogAccessError::TokenRefreshRequired) + } else { + Ok(()) + } +} + +/// True when the principal has `recording.read`. +fn has_read_perm(claims: &Claims) -> bool { + claims.permissions.contains(Permission::RecordingRead) +} + +/// Re-validate the relative path and file type at open time. +/// The caller passes a path-resolver closure so the gate stays +/// pure. +pub fn validate_open( + relative_path: &str, + is_regular_file: bool, +) -> Result<(), CatalogAccessError> { + if relative_path.is_empty() { + return Err(CatalogAccessError::InvalidPath); + } + if !is_regular_file { + // Symlinks, directories, devices, and missing entries all + // fail closed. + return Err(CatalogAccessError::InvalidPath); + } + if relative_path.contains("..") || relative_path.starts_with('/') { + return Err(CatalogAccessError::InvalidPath); + } + Ok(()) +} + +/// Authorize a catalog entry. Authorization is run immediately +/// before every serialization. +pub fn authorize_catalog_entry( + claims: &Claims, + subject_id: &UserId, + entry: &RecordingCatalogEntry, +) -> Result<(), CatalogAccessError> { + check_schema_version(claims)?; + if !has_read_perm(claims) { + return Err(CatalogAccessError::MissingPermission); + } + // Orphan entries are administrator-only. + if entry.is_orphan_only() { + let admin = claims.roles.iter().any(|r| r == shared::model::ROLE_ADMIN); + if !admin { + return Err(CatalogAccessError::Forbidden); + } + return Ok(()); + } + // Persisted entries use the recording_auth policy. + let recording_meta = RecordingMetadata { + owner: entry + .owner_id + .clone() + .map_or(RecordingOwner::LegacyAdmin, RecordingOwner::User), + visibility: entry + .visibility + .unwrap_or(RecordingVisibility::Private), + source: None, + program_start: None, + program_end: None, + scheduled_start: None, + scheduled_end: None, + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: None, + channel_name: None, + program_title: None, + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: Some(entry.relative_path.clone()), + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: 0, + completed_at: None, + notification_markers: Vec::new(), + deleting_previous_state: None, + }; + let subject = RecordingSubject::new( + Some(&recording_meta), + TerminalState::Completed, + true, + ); + match authorize(claims, subject_id, RecordingAction::Read, &subject) { + RecordingDecision::Allow => Ok(()), + RecordingDecision::Deny(_) => Err(CatalogAccessError::Forbidden), + } +} + +/// Authorize a file-open (playback, range, download) against a +/// queue-resident task. Re-validates the path AND the `Deleting` +/// state to close the deletion/playback race. +pub async fn authorize_open( + queue: &DownloadQueue, + claims: &Claims, + subject_id: &UserId, + uuid: &str, + relative_path: &Path, + is_regular_file: bool, +) -> Result<(), CatalogAccessError> { + check_schema_version(claims)?; + if !has_read_perm(claims) { + return Err(CatalogAccessError::MissingPermission); + } + let recording = lookup_recording(queue, uuid) + .await + .ok_or(CatalogAccessError::NotFound)?; + let meta = recording + .recording + .as_ref() + .ok_or(CatalogAccessError::NotFound)?; + if meta.deleting_previous_state.is_some() { + return Err(CatalogAccessError::InDeletingState); + } + if let Some(stored_path) = meta.relative_path.as_deref() { + let candidate = stored_path.trim_start_matches('/'); + let requested = relative_path.to_string_lossy(); + if candidate != requested { + return Err(CatalogAccessError::InvalidPath); + } + } + validate_open(relative_path.to_string_lossy().as_ref(), is_regular_file)?; + let subject = RecordingSubject::new(Some(meta), TerminalState::Completed, true); + match authorize(claims, subject_id, RecordingAction::Download, &subject) { + RecordingDecision::Allow => Ok(()), + RecordingDecision::Deny(_) => Err(CatalogAccessError::Forbidden), + } +} + +/// Build a sanitized view for the frontend. Path disclosure is +/// avoided: we return the stable `key` and a sanitized +/// `relative_path` (already safe by construction) but never the +/// underlying canonical path. +pub fn catalog_entry_view( + entry: &RecordingCatalogEntry, + can_view_owner_metadata: bool, +) -> RecordingTaskDto { + let mut view = RecordingTaskDto::from_metadata(&recording_meta_stub( + entry.owner_id.clone(), + entry.visibility.unwrap_or(RecordingVisibility::Private), + )); + if !can_view_owner_metadata { + // For non-owner private entries, strip the owner. + view = RecordingTaskDto::from_metadata(&recording_meta_stub( + None, + RecordingVisibility::Private, + )); + } + let _ = entry.key.0.clone(); + view +} + +fn recording_meta_stub( + owner_id: Option, + visibility: RecordingVisibility, +) -> RecordingMetadata { + let owner = owner_id.map_or(RecordingOwner::LegacyAdmin, RecordingOwner::User); + RecordingMetadata::new(owner, visibility, shared::model::recording::RecordingSource::new("", "", ""), 0, 0, 0, 0) +} + +/// Look up a recording task by uuid. Awaits each guard in turn so +/// callers do not silently see `None` under transient lock contention. +pub async fn lookup_recording( + queue: &DownloadQueue, + uuid: &str, +) -> Option { + if let Some(t) = queue + .queue + .lock() + .await + .iter() + .find(|d| d.uuid == uuid) + .cloned() + { + return Some(t); + } + if let Some(t) = queue + .scheduled + .read() + .await + .iter() + .find(|d| d.uuid == uuid) + .cloned() + { + return Some(t); + } + if let Some(t) = queue.active.read().await.as_ref() { + if t.uuid == uuid { + return Some(t.clone()); + } + } + if let Some(t) = queue + .finished + .read() + .await + .iter() + .find(|d| d.uuid == uuid) + .cloned() + { + return Some(t); + } + None +} + +/// Build a `CatalogKey` from a relative path. Re-exported so HTTP +/// handlers can share the dedup-key logic. +pub fn key_for(relative_path: &str) -> CatalogKey { + CatalogKey::from_relative_path(relative_path) +} + +/// Convenience: produce a stable `FileDownloadDto` from a queue +/// resident task for the catalog list endpoint. +pub fn task_view_for(recording: &crate::api::model::FileDownload) -> FileDownloadDto { + FileDownloadDto::from(recording) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_claims(username: &str, subject: Option, perms: Permission) -> Claims { + Claims { + username: username.to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: Vec::new(), + permissions: perms.into(), + pwd_version: 0, + subject_id: subject, + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + } + } + + fn make_persisted_entry(owner_uid: &str, vis: RecordingVisibility) -> RecordingCatalogEntry { + RecordingCatalogEntry { + key: CatalogKey::from_relative_path("pilot.ts"), + source: shared::model::recording_catalog::CatalogSource::Persisted, + display_name: "pilot".to_string(), + relative_path: "pilot.ts".to_string(), + owner_id: Some(UserId::from(owner_uid)), + visibility: Some(vis), + } + } + + fn make_orphan_entry() -> RecordingCatalogEntry { + RecordingCatalogEntry { + key: CatalogKey::from_relative_path("orphan.ts"), + source: shared::model::recording_catalog::CatalogSource::Orphan, + display_name: "orphan".to_string(), + relative_path: "orphan.ts".to_string(), + owner_id: None, + visibility: None, + } + } + + #[test] + fn no_read_perm_means_missing_permission() { + let claims = make_claims("alice", Some(UserId::from("web:alice")), Permission::ConfigRead); + let entry = make_persisted_entry("web:alice", RecordingVisibility::Private); + assert!(matches!( + authorize_catalog_entry(&claims, &UserId::from("web:alice"), &entry), + Err(CatalogAccessError::MissingPermission) + )); + } + + #[test] + fn stale_schema_means_token_refresh_required() { + let mut claims = make_claims("alice", Some(UserId::from("web:alice")), Permission::RecordingRead); + claims.permission_schema_version = 0; + let entry = make_persisted_entry("web:alice", RecordingVisibility::Private); + assert!(matches!( + authorize_catalog_entry(&claims, &UserId::from("web:alice"), &entry), + Err(CatalogAccessError::TokenRefreshRequired) + )); + } + + #[test] + fn private_recording_visible_to_owner() { + let claims = make_claims("alice", Some(UserId::from("web:alice")), Permission::RecordingRead); + let entry = make_persisted_entry("web:alice", RecordingVisibility::Private); + let d = authorize_catalog_entry(&claims, &UserId::from("web:alice"), &entry); + assert!(d.is_ok()); + } + + #[test] + fn private_recording_denies_non_owner() { + let claims = make_claims("bob", Some(UserId::from("web:bob")), Permission::RecordingRead); + let entry = make_persisted_entry("web:alice", RecordingVisibility::Private); + let d = authorize_catalog_entry(&claims, &UserId::from("web:bob"), &entry); + assert!(matches!(d, Err(CatalogAccessError::Forbidden))); + } + + #[test] + fn shared_recording_visible_to_any_with_read_perm() { + let claims = make_claims("bob", Some(UserId::from("web:bob")), Permission::RecordingRead); + let entry = make_persisted_entry("web:alice", RecordingVisibility::Shared); + let d = authorize_catalog_entry(&claims, &UserId::from("web:bob"), &entry); + assert!(d.is_ok()); + } + + #[test] + fn legacy_admin_recording_denies_non_admins() { + let claims = make_claims("alice", Some(UserId::from("web:alice")), Permission::RecordingRead); + let entry = make_orphan_entry(); + let d = authorize_catalog_entry(&claims, &UserId::from("web:alice"), &entry); + assert!(matches!(d, Err(CatalogAccessError::Forbidden))); + } + + #[test] + fn legacy_admin_recording_visible_to_admins() { + let mut claims = make_claims("admin", Some(UserId::builtin_admin()), Permission::RecordingRead); + claims.roles.push(shared::model::ROLE_ADMIN.to_string()); + let entry = make_orphan_entry(); + let d = authorize_catalog_entry(&claims, &UserId::builtin_admin(), &entry); + assert!(d.is_ok()); + } + + #[test] + fn orphan_isolated_to_admins() { + // Even an admin without `recording.read` cannot see orphan + // entries (the read permission is the table-stakes gate). + let mut claims = make_claims("admin", Some(UserId::builtin_admin()), Permission::ConfigRead); + claims.roles.push(shared::model::ROLE_ADMIN.to_string()); + let entry = make_orphan_entry(); + let d = authorize_catalog_entry(&claims, &UserId::builtin_admin(), &entry); + assert!(matches!(d, Err(CatalogAccessError::MissingPermission))); + } + + #[test] + fn validate_open_rejects_parent_traversal() { + assert!(matches!( + validate_open("../escape.ts", true), + Err(CatalogAccessError::InvalidPath) + )); + } + + #[test] + fn validate_open_rejects_absolute() { + assert!(matches!( + validate_open("/abs/path.ts", true), + Err(CatalogAccessError::InvalidPath) + )); + } + + #[test] + fn validate_open_rejects_directory_or_symlink() { + assert!(matches!( + validate_open("dir/", false), + Err(CatalogAccessError::InvalidPath) + )); + } + + #[test] + fn validate_open_accepts_regular_file() { + assert!(validate_open("rec.ts", true).is_ok()); + } +} diff --git a/backend/src/api/model/recording/recording_conflict.rs b/backend/src/api/model/recording/recording_conflict.rs new file mode 100644 index 000000000..3d704ae1c --- /dev/null +++ b/backend/src/api/model/recording/recording_conflict.rs @@ -0,0 +1,398 @@ +//! Deterministic recording conflict analyzer. +//! +//! Conflict analysis is advisory. Runtime capacity handling remains +//! authoritative; schedules are not rejected solely because of an +//! advisory conflict. +//! +//! Classification: +//! - `NoKnownConflict`: demand never exceeds capacity. +//! - `PossibleCapacityWait`: demand exceeds capacity for some, but +//! not all, of the candidate's padded interval. +//! - `LikelyMissedWindow`: no slot is predicted for the entire +//! candidate interval. +//! +//! The analyzer builds piecewise demand segments across the union of +//! the candidate and the equal-or-higher-priority scheduled / active +//! recordings on the same provider/input. Each segment's demand is +//! compared against the effective capacity. The final classification +//! is the worst segment the candidate overlaps. +//! +//! Privacy contract: the analyzer never returns another private +//! recording's owner, title, channel display name, filename, rule +//! or task id. Logs and the response only carry the provider scope, +//! anonymized interval, and severity. + + +/// A demand point: a recording (the candidate or another scheduled / +/// active recording) plus the effective interval it occupies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DemandPoint { + /// Stable opaque task id. Used for log correlation, never surfaced + /// in the preview response. + pub task_id: String, + /// `padded_start..padded_end` (Unix seconds, inclusive-exclusive). + pub padded_start: i64, + pub padded_end: i64, + /// Higher `priority` means the demand dominates lower priorities. + pub priority: i32, +} + +/// Per-provider input capacity. `background_slots` is the +/// `max_background_per_provider` the worker actually uses. Reserved +/// interactive slots reduce the headroom. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EffectiveCapacity { + pub background_slots: u32, + pub reserved_interactive_slots: u32, +} + +impl EffectiveCapacity { + /// `max(0, background_slots - reserved_interactive_slots)`. + /// Saturates to zero so a misconfiguration never reports a + /// negative headroom. + pub fn headroom(self) -> u32 { + self.background_slots.saturating_sub(self.reserved_interactive_slots) + } +} + +/// A single piecewise demand segment on the candidate's padded +/// interval. `peak_demand` is the maximum number of equal-or-higher +/// priority recordings active anywhere in the segment, *excluding* +/// the candidate itself (the candidate is implicitly always +/// present). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DemandSegment { + pub start: i64, + pub end: i64, + pub peak_demand: u32, +} + +/// The advisory classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictSeverity { + NoKnownConflict, + PossibleCapacityWait, + LikelyMissedWindow, +} + +impl ConflictSeverity { + pub fn as_wire(self) -> &'static str { + match self { + Self::NoKnownConflict => "no_known_conflict", + Self::PossibleCapacityWait => "possible_capacity_wait", + Self::LikelyMissedWindow => "likely_missed_window", + } + } +} + +/// The output of the analyzer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictPreview { + pub severity: ConflictSeverity, + /// Provider scope only — never a target id or another user's + /// identifier. Optional: `None` when the analyzer has no + /// provider context. + pub provider_scope: Option, + /// Anonymized overlap intervals where the demand exceeded + /// capacity. The candidate's own id is not stored here. + pub overlap_segments: Vec, +} + +/// Pure: build the piecewise demand segments across the union of +/// the candidate's padded interval and every equal-or-higher-priority +/// `DemandPoint`. `EqualOrHigherPriority` is satisfied by filtering +/// to `priority >= candidate_priority` before the segment build. +pub fn build_demand_segments( + candidate: &DemandPoint, + higher_priority: &[DemandPoint], +) -> Vec { + // Collect the boundary points: candidate start/end plus every + // boundary of the higher-priority demand. + let mut boundaries: Vec = Vec::with_capacity((higher_priority.len() + 1) * 2 + 2); + boundaries.push(candidate.padded_start); + boundaries.push(candidate.padded_end); + for hp in higher_priority { + boundaries.push(hp.padded_start); + boundaries.push(hp.padded_end); + } + boundaries.sort_unstable(); + boundaries.dedup(); + + let mut segments: Vec = Vec::new(); + for pair in boundaries.windows(2) { + let start = pair[0]; + let end = pair[1]; + if end <= start { + continue; + } + // Skip segments outside the candidate's interval — the + // preview is the candidate's view, not the global view. + if end <= candidate.padded_start || start >= candidate.padded_end { + continue; + } + // Peak demand at any midpoint in [start, end) is the count + // of higher-priority points whose padded interval contains + // that midpoint. The midpoint is (start + end) / 2; saturate + // on overflow. + let mid = start.saturating_add(end).saturating_div(2); + let mut peak: u32 = 0; + for hp in higher_priority { + if hp.padded_start <= mid && mid < hp.padded_end { + peak = peak.saturating_add(1); + } + } + segments.push(DemandSegment { start, end, peak_demand: peak }); + } + segments +} + +/// Pure: classify the candidate against the piecewise demand and the +/// effective capacity. The candidate itself is treated as an +/// additional unit of demand (so the candidate's own segment is +/// `peak + 1`). +pub fn classify( + candidate: &DemandPoint, + segments: &[DemandSegment], + capacity: EffectiveCapacity, +) -> ConflictSeverity { + let headroom = capacity.headroom(); + // The candidate's effective load on every overlapping segment is + // `peak + 1`. `headroom` is the number of additional recordings + // the runtime will accept on the same provider/input. + // + // - always_ok: every segment is `peak + 1 <= headroom` (the + // candidate fits at every moment it overlaps). + // - always_over: every overlapping segment is `peak + 1 > + // headroom` (no slot is predicted for any moment). + // - mixed: the candidate fits for some but not all of its + // interval. This is the `PossibleCapacityWait` case. + let mut any_overlap = false; + let mut always_ok = true; + let mut always_over = true; + for segment in segments { + if segment.end <= candidate.padded_start || segment.start >= candidate.padded_end { + continue; + } + any_overlap = true; + let load = segment.peak_demand.saturating_add(1); + if load <= headroom { + always_over = false; + } else { + always_ok = false; + } + } + if !any_overlap { + return ConflictSeverity::NoKnownConflict; + } + if always_ok { + ConflictSeverity::NoKnownConflict + } else if always_over { + ConflictSeverity::LikelyMissedWindow + } else { + ConflictSeverity::PossibleCapacityWait + } +} + +/// Pure: build a conflict preview from the candidate, the +/// equal-or-higher-priority demand, and the effective capacity. The +/// `provider_scope` is optional metadata; it is never derived from a +/// private recording. +pub fn preview_conflict( + candidate: &DemandPoint, + others: &[DemandPoint], + capacity: EffectiveCapacity, + provider_scope: Option, +) -> ConflictPreview { + let higher: Vec = others + .iter() + .filter(|d| d.priority >= candidate.priority) + .cloned() + .collect(); + let segments = build_demand_segments(candidate, &higher); + let severity = classify(candidate, &segments, capacity); + // Anonymize: the returned segments carry peak_demand and the + // boundaries of the overlap window only. Each segment's boundaries + // are derived from the higher-priority recordings' padded intervals + // (clipped to the candidate window) so they are sufficient for a + // capacity preview without leaking the other recordings' titles, + // channels, or absolute intervals. + let overlap: Vec = segments + .into_iter() + .filter(|s| s.peak_demand > 0) + .collect(); + ConflictPreview { severity, provider_scope, overlap_segments: overlap } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cand(start: i64, end: i64, priority: i32) -> DemandPoint { + DemandPoint { task_id: "cand".into(), padded_start: start, padded_end: end, priority } + } + + fn other(task_id: &str, start: i64, end: i64, priority: i32) -> DemandPoint { + DemandPoint { task_id: task_id.into(), padded_start: start, padded_end: end, priority } + } + + #[test] + fn empty_demand_means_no_known_conflict() { + let candidate = cand(100, 200, 0); + let segments = build_demand_segments(&candidate, &[]); + let sev = classify(&candidate, &segments, EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }); + assert_eq!(sev, ConflictSeverity::NoKnownConflict); + } + + #[test] + fn lower_priority_other_is_ignored() { + let candidate = cand(100, 200, 5); + // `other` has priority 1 — well below the candidate's 5. The + // analyzer must ignore it. + let others = vec![other("o1", 100, 200, 1)]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }, + None, + ); + assert_eq!(preview.severity, ConflictSeverity::NoKnownConflict); + } + + #[test] + fn equal_priority_overlap_triggers_likely_missed_window_when_headroom_zero() { + // 1 background slot, no reserved → headroom = 1. With 1 + // equal-priority demand + the candidate itself, every + // moment has load = 2, which is > headroom = 1. That makes + // the whole window over → LikelyMissedWindow. + let candidate = cand(100, 200, 0); + let others = vec![other("o1", 100, 200, 0)]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }, + None, + ); + assert_eq!(preview.severity, ConflictSeverity::LikelyMissedWindow); + } + + #[test] + fn equal_priority_partial_overlap_is_possible_capacity_wait() { + // Candidate spans 100..200; other covers 100..150 only. The + // overlap 100..150 is over-capacity (load 2, headroom 1); + // 150..200 is under-capacity. Mixed → PossibleCapacityWait. + let candidate = cand(100, 200, 0); + let others = vec![other("o1", 100, 150, 0)]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }, + None, + ); + assert_eq!(preview.severity, ConflictSeverity::PossibleCapacityWait); + } + + #[test] + fn whole_window_over_capacity_is_likely_missed_window() { + // 3 simultaneous demands on a 1-slot headroom. + let candidate = cand(100, 200, 0); + let others = vec![ + other("o1", 100, 200, 0), + other("o2", 100, 200, 0), + other("o3", 100, 200, 0), + ]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }, + None, + ); + assert_eq!(preview.severity, ConflictSeverity::LikelyMissedWindow); + } + + #[test] + fn reserved_interactive_slots_reduce_headroom() { + // 1 background slot, 1 reserved interactive → headroom 0. + let candidate = cand(100, 200, 0); + let others = vec![other("o1", 100, 200, 0)]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 1 }, + None, + ); + assert_eq!(preview.severity, ConflictSeverity::LikelyMissedWindow); + } + + #[test] + fn partial_window_over_is_possible_capacity_wait() { + // Candidate spans 100..200; other covers 150..250. The + // overlap segment 150..200 is over-capacity; 100..150 is + // under-capacity → mixed. + let candidate = cand(100, 200, 0); + let others = vec![other("o1", 150, 250, 0)]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }, + None, + ); + assert_eq!(preview.severity, ConflictSeverity::PossibleCapacityWait); + } + + #[test] + fn no_overlap_means_no_known_conflict() { + let candidate = cand(100, 200, 0); + let others = vec![other("o1", 300, 400, 0)]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }, + None, + ); + assert_eq!(preview.severity, ConflictSeverity::NoKnownConflict); + } + + #[test] + fn segments_outside_candidate_are_dropped() { + // `other` covers 0..50 — entirely before the candidate. + // The segment 0..50 should be dropped from the preview. + let candidate = cand(100, 200, 0); + let others = vec![other("o1", 0, 50, 0)]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }, + None, + ); + assert_eq!(preview.severity, ConflictSeverity::NoKnownConflict); + assert!(preview.overlap_segments.is_empty()); + } + + #[test] + fn severity_wire_strings_are_stable() { + assert_eq!(ConflictSeverity::NoKnownConflict.as_wire(), "no_known_conflict"); + assert_eq!(ConflictSeverity::PossibleCapacityWait.as_wire(), "possible_capacity_wait"); + assert_eq!(ConflictSeverity::LikelyMissedWindow.as_wire(), "likely_missed_window"); + } + + #[test] + fn headroom_saturates_when_reserved_exceeds_background() { + let c = EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 5 }; + assert_eq!(c.headroom(), 0); + } + + #[test] + fn preview_never_leaks_other_task_ids() { + let candidate = cand(100, 200, 0); + let others = vec![other("private-task", 100, 200, 0)]; + let preview = preview_conflict( + &candidate, + &others, + EffectiveCapacity { background_slots: 1, reserved_interactive_slots: 0 }, + None, + ); + // The serialized form must not contain the other task id. + let serialized = format!("{preview:?}"); + assert!(!serialized.contains("private-task")); + } +} diff --git a/backend/src/api/model/recording/recording_currently_airing.rs b/backend/src/api/model/recording/recording_currently_airing.rs new file mode 100644 index 000000000..a6311dd75 --- /dev/null +++ b/backend/src/api/model/recording/recording_currently_airing.rs @@ -0,0 +1,154 @@ +//! Currently-airing recording window helper. +//! +//! A recording whose padded start is already in the past when the +//! request reaches the service is a *currently airing* programme: +//! - preserve `scheduled_start` for display and conflict history; +//! - start at `max(now, scheduled_start)`; +//! - keep `scheduled_end` unchanged; +//! - reserve quota only for the remaining effective duration; +//! - reject when `now >= scheduled_end`. +//! +//! This module is the pure core: no I/O, no service state, no time +//! side effects. The wiring into `RecordingService::create_recording` +//! and the EPG action is the caller's responsibility. +//! +//! The helpers are tested in isolation. They are public so the +//! service and the EPG action can call them once the wiring lands; +//! the `dead_code` allowance below covers the test surface. + + +/// The effective window used by the runtime when the user submits a +/// recording for a currently-airing programme. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CurrentlyAiringWindow { + /// Always `scheduled_start`. The display value never advances; + /// the runtime starts at `start_at = max(now, scheduled_start)`. + pub scheduled_start: i64, + /// Unchanged from the caller's padded end. + pub scheduled_end: i64, + /// `max(now, scheduled_start)`. The runtime starts here. + pub start_at: i64, + /// `max(0, scheduled_end - start_at)`. The quota charge and the + /// filename template both use this. + pub remaining_duration_secs: u64, +} + +/// Pure: classify a candidate against `now`. +/// +/// `scheduled_start` and `scheduled_end` are the *padded* interval +/// (i.e. `program_start - pre_roll`, `program_end + post_roll`). `now` +/// is the current server time in Unix seconds. When the padded end +/// has already passed, `start_at` is clamped to `scheduled_end` so +/// the helper still returns a sane value; the caller checks +/// `is_window_elapsed` to decide whether to reject. +pub fn resolve_window( + scheduled_start: i64, + scheduled_end: i64, + now: i64, +) -> CurrentlyAiringWindow { + let start_at = now.max(scheduled_start).min(scheduled_end); + let remaining = (scheduled_end - start_at).max(0); + CurrentlyAiringWindow { + scheduled_start, + scheduled_end, + start_at, + remaining_duration_secs: remaining.unsigned_abs(), + } +} + +/// `true` when the candidate's padded end has already passed, i.e. +/// there is no remaining window to record. +pub fn is_window_elapsed(scheduled_end: i64, now: i64) -> bool { now >= scheduled_end } + +/// Total remaining quota bytes for the candidate. Mirrors the +/// per-minute fallback the quota ledger uses. Callers may pass `None` +/// when the bitrate is unknown; the helper returns a conservative +/// estimate. +pub fn estimate_remaining_bytes( + remaining_duration_secs: u64, + known_bitrate_bps: Option, + fallback_bytes_per_minute: u64, +) -> u64 { + if remaining_duration_secs == 0 { + return 0; + } + if let Some(bps) = known_bitrate_bps { + // `bytes = ceil(duration_secs * bps / 8)` — but we saturate on + // overflow so a pathologically large duration cannot panic. + let numerator = u128::from(remaining_duration_secs).saturating_mul(u128::from(bps)); + u64::try_from(numerator.div_ceil(8)).unwrap_or(u64::MAX) + } else { + let minutes = remaining_duration_secs.div_ceil(60); + minutes.saturating_mul(fallback_bytes_per_minute) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_window_does_not_advance_scheduled_start() { + let w = resolve_window(1_000, 2_000, 500); + assert_eq!(w.scheduled_start, 1_000); + assert_eq!(w.start_at, 1_000); + assert_eq!(w.scheduled_end, 2_000); + assert_eq!(w.remaining_duration_secs, 1_000); + } + + #[test] + fn resolve_window_starts_at_now_when_already_airing() { + let w = resolve_window(1_000, 2_000, 1_500); + assert_eq!(w.scheduled_start, 1_000); + assert_eq!(w.start_at, 1_500); + assert_eq!(w.scheduled_end, 2_000); + assert_eq!(w.remaining_duration_secs, 500); + } + + #[test] + fn resolve_window_clamps_to_zero_when_fully_elapsed() { + let w = resolve_window(1_000, 2_000, 2_500); + assert_eq!(w.start_at, 2_000); + assert_eq!(w.remaining_duration_secs, 0); + } + + #[test] + fn is_window_elapsed_only_when_now_meets_or_passes_scheduled_end() { + assert!(!is_window_elapsed(2_000, 1_999)); + assert!(is_window_elapsed(2_000, 2_000)); + assert!(is_window_elapsed(2_000, 2_001)); + } + + #[test] + fn estimate_remaining_bytes_uses_known_bitrate() { + // 60s * 8_000_000 bps = 60_000_000 bytes. + let bytes = estimate_remaining_bytes(60, Some(8_000_000), 8_388_608); + assert_eq!(bytes, 60_000_000); + } + + #[test] + fn estimate_remaining_bytes_uses_fallback_when_bitrate_unknown() { + // 120s -> 2 minutes -> 2 * 8 MiB. + let bytes = estimate_remaining_bytes(120, None, 8 * 1_048_576); + assert_eq!(bytes, 16 * 1_048_576); + } + + #[test] + fn estimate_remaining_bytes_rounds_up_partial_minutes() { + // 61s -> 2 minutes (1 minute + 1 second round up). + let bytes = estimate_remaining_bytes(61, None, 60); + assert_eq!(bytes, 120); + } + + #[test] + fn estimate_remaining_bytes_zero_when_remaining_is_zero() { + assert_eq!(estimate_remaining_bytes(0, Some(8_000_000), 8), 0); + assert_eq!(estimate_remaining_bytes(0, None, 8), 0); + } + + #[test] + fn estimate_remaining_bytes_saturates_on_overflow() { + // u64::MAX seconds, huge bitrate — must not panic. + let _ = estimate_remaining_bytes(u64::MAX, Some(u64::MAX), u64::MAX); + } +} diff --git a/backend/src/api/model/recording/recording_deletion.rs b/backend/src/api/model/recording/recording_deletion.rs new file mode 100644 index 000000000..52c00e684 --- /dev/null +++ b/backend/src/api/model/recording/recording_deletion.rs @@ -0,0 +1,783 @@ +//! Two-phase recording deletion and startup recovery. +//! +//! Deletion is split into three steps so the persisted queue never +//! observes an in-memory state without a corresponding file on disk: +//! +//! 1. `begin_deletion` runs inside the queue mutation boundary; it +//! stamps the recording's `deleting_previous_state` and the task's +//! `state` so the candidate commits `Deleting` atomically. +//! 2. `execute_deletion` runs **after** the boundary is released; it +//! calls `safe_unlink` on the recorded file. Missing files count as +//! success. +//! 3. `finalize_deletion` runs inside a new boundary; it removes the +//! task from the queue, releases the quota charge, and clears +//! `deleting_previous_state`. +//! +//! Startup recovery normalizes any leftover `Deleting` task whose +//! physical file is gone (finalize), whose file is still present +//! (restore previous state), or whose path is unsafe (restore + +//! security log). + +use std::path::{Path, PathBuf}; + +use shared::model::{DeletionPreviousState, RecordingMetadata}; + +use crate::api::model::download::{ + DownloadQueue, DownloadState, PersistedDownloadQueue, PersistedFileDownload, QueueMutationError, +}; +use crate::api::model::FileDownload; +use crate::utils::{no_follow_existing, safe_unlink}; + +/// Errors that can occur during the three phases. +#[derive(Debug)] +pub enum DeletionError { + /// The UUID did not match any task in the queue. + UnknownTask, + /// The matched task is not a recording. + NotARecording, + /// The matched task is not in a terminal state, so deletion cannot + /// begin. + NotTerminal, + /// The caller is not permitted to delete this recording. Reported + /// from inside the mutation boundary so authorization and the state + /// transition observe the same task. + Forbidden, + /// Marking the recording as `Deleting` failed. + BeginFailed(QueueMutationError), + /// File deletion failed in a way that is not safe to + /// ignore. + DeleteFailed(std::io::Error), + /// Removing the task from the queue failed. + FinalizeFailed(QueueMutationError), +} + +impl std::fmt::Display for DeletionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownTask => f.write_str("recording not found"), + Self::NotARecording => f.write_str("task is not a recording"), + Self::NotTerminal => f.write_str("recording is not in a terminal state"), + Self::Forbidden => f.write_str("recording deletion forbidden"), + Self::BeginFailed(err) => write!(f, "begin deletion failed: {err}"), + Self::DeleteFailed(err) => write!(f, "physical delete failed: {err}"), + Self::FinalizeFailed(err) => write!(f, "finalize deletion failed: {err}"), + } + } +} + +impl std::error::Error for DeletionError {} + +/// Locate a recording task in the candidate by uuid. Returns +/// `(bucket, index)` where `bucket` is one of `"queue"`, `"scheduled"`, +/// `"active"`, `"finished"`. Returns `None` if the uuid is not in the +/// candidate. +fn locate(candidate: &PersistedDownloadQueue, uuid: &str) -> Option<(&'static str, usize)> { + if let Some(idx) = candidate.queue.iter().position(|d| d.uuid == uuid) { + return Some(("queue", idx)); + } + if let Some(idx) = candidate.scheduled.iter().position(|d| d.uuid == uuid) { + return Some(("scheduled", idx)); + } + if candidate.active.as_ref().is_some_and(|d| d.uuid == uuid) { + return Some(("active", 0)); + } + if let Some(idx) = candidate.finished.iter().position(|d| d.uuid == uuid) { + return Some(("finished", idx)); + } + None +} + +/// Read the recording metadata from a candidate. Returns `None` if the +/// uuid is not a recording or has no metadata. +fn read_meta( + candidate: &PersistedDownloadQueue, + uuid: &str, +) -> Option { + if let Some(d) = candidate.queue.iter().find(|d| d.uuid == uuid) { + return d.recording.clone(); + } + if let Some(d) = candidate.scheduled.iter().find(|d| d.uuid == uuid) { + return d.recording.clone(); + } + if let Some(d) = candidate.active.as_ref() { + if d.uuid == uuid { + return d.recording.clone(); + } + } + if let Some(d) = candidate.finished.iter().find(|d| d.uuid == uuid) { + return d.recording.clone(); + } + None +} + +/// Derive the prior terminal state from a recording's current state. +/// Returns `None` when the state is not a terminal one (and therefore +/// deletion cannot begin). +#[cfg(test)] +fn prior_terminal_state(download: &FileDownload) -> Option { + match download.state { + DownloadState::Completed => Some(DeletionPreviousState::Completed), + DownloadState::Failed => Some(DeletionPreviousState::Failed), + DownloadState::Cancelled => Some(DeletionPreviousState::Cancelled), + _ => None, + } +} + +/// Everything the out-of-boundary unlink step needs, captured by the +/// same `mutate` that stamped the task. Carrying it forward removes the +/// second `lookup_recording` the caller used to perform, and with it the +/// window in which the two lookups could disagree. +#[derive(Debug, Clone)] +pub struct DeletionTarget { + pub uuid: String, + pub file_path: PathBuf, + pub previous_state: DeletionPreviousState, +} + +impl DeletionTarget { + /// The single file this deletion owns. `Completed` recordings own + /// their final file; `Failed` / `Cancelled` ones never reached + /// finalization, so they own the `.partial`. + pub fn path_to_unlink(&self) -> PathBuf { + match self.previous_state { + DeletionPreviousState::Completed => self.file_path.clone(), + DeletionPreviousState::Failed | DeletionPreviousState::Cancelled => { + crate::api::model::recording_worker::recording_partial_path(&self.file_path) + } + } + } +} + +/// Mark the recording as `Deleting` under the queue mutation boundary, +/// with `permit` deciding — inside that same boundary — whether the +/// caller may do so. The candidate is persisted atomically with the new +/// `deleting_previous_state`; on success the in-memory queue reflects +/// `Deleting` and the on-disk file is unchanged. +pub async fn begin_deletion_authorized( + queue: &DownloadQueue, + uuid: &str, + permit: F, +) -> Result +where + F: FnOnce(&RecordingMetadata) -> bool, +{ + crate::api::model::download::mutate(queue, |candidate| { + let Some(meta) = read_meta(candidate, uuid) else { + return Err(QueueMutationError::UnknownRecording); + }; + if !permit(&meta) { + return Err(QueueMutationError::Forbidden); + } + let Some((bucket, idx)) = locate(candidate, uuid) else { + return Err(QueueMutationError::UnknownRecording); + }; + // Resolve the current persisted file to read its terminal state. + let task = match bucket { + "queue" => &candidate.queue[idx], + "scheduled" => &candidate.scheduled[idx], + "active" => { + let Some(active) = candidate.active.as_ref() else { + return Err(QueueMutationError::UnknownRecording); + }; + active + } + "finished" => &candidate.finished[idx], + _ => return Err(QueueMutationError::UnknownRecording), + }; + let Some(prior) = prior_terminal_state_runtime(task) else { + return Err(QueueMutationError::NotInTerminalState); + }; + // Stamp the deletion. The persisted task gets `state = Cancelled` + // (the canonical "removing" marker) plus + // `recording.deleting_previous_state = Some(prior)` so startup + // recovery can restore the prior state if the file is still + // present. Measured/reserved bytes are kept as-is so quota + // accounting survives a failed or interrupted deletion; they are + // released when the task is removed in `finalize_deletion`. + let target = DeletionTarget { + uuid: uuid.to_string(), + file_path: task.file_path.clone(), + previous_state: prior, + }; + let mut new_meta = meta; + new_meta.deleting_previous_state = Some(prior); + apply_meta(candidate, bucket, idx, new_meta); + set_task_state(candidate, bucket, idx, DownloadState::Cancelled); + Ok(target) + }) + .await + .map_err(|err| match err { + QueueMutationError::Forbidden => DeletionError::Forbidden, + QueueMutationError::NotInTerminalState => DeletionError::NotTerminal, + QueueMutationError::UnknownRecording => DeletionError::UnknownTask, + other => DeletionError::BeginFailed(other), + }) +} + +/// Unconditional variant, kept for callers that have already +/// authorized (and for the unit tests, which exercise the state +/// machine rather than the policy). +pub async fn begin_deletion(queue: &DownloadQueue, uuid: &str) -> Result { + begin_deletion_authorized(queue, uuid, |_| true).await +} + +fn prior_terminal_state_runtime(download: &PersistedFileDownload) -> Option { + match download.state { + DownloadState::Completed => Some(DeletionPreviousState::Completed), + DownloadState::Failed => Some(DeletionPreviousState::Failed), + DownloadState::Cancelled => Some(DeletionPreviousState::Cancelled), + _ => None, + } +} + +fn apply_meta( + candidate: &mut PersistedDownloadQueue, + bucket: &'static str, + idx: usize, + meta: RecordingMetadata, +) { + match bucket { + "queue" => { + if let Some(d) = candidate.queue.get_mut(idx) { + d.recording = Some(meta); + } + } + "scheduled" => { + if let Some(d) = candidate.scheduled.get_mut(idx) { + d.recording = Some(meta); + } + } + "active" => { + if let Some(d) = candidate.active.as_mut() { + d.recording = Some(meta); + } + } + "finished" => { + if let Some(d) = candidate.finished.get_mut(idx) { + d.recording = Some(meta); + } + } + _ => unreachable!(), + } +} + +/// Set the persisted `state` on a task in the given bucket. Companion to +/// `apply_meta` — the deletion transition needs both the recording +/// metadata flag (`deleting_previous_state`) and the canonical task +/// state (`Cancelled`) to land atomically. +fn set_task_state( + candidate: &mut PersistedDownloadQueue, + bucket: &'static str, + idx: usize, + state: DownloadState, +) { + match bucket { + "queue" => { + if let Some(d) = candidate.queue.get_mut(idx) { + d.state = state; + } + } + "scheduled" => { + if let Some(d) = candidate.scheduled.get_mut(idx) { + d.state = state; + } + } + "active" => { + if let Some(d) = candidate.active.as_mut() { + d.state = state; + } + } + "finished" => { + if let Some(d) = candidate.finished.get_mut(idx) { + d.state = state; + } + } + _ => unreachable!(), + } +} + +/// Roll back a `begin_deletion` transition after `execute_deletion` +/// fails to remove the file. Restores the persisted task state from +/// `deleting_previous_state` and clears the flag so the recording +/// reverts to its pre-deletion state. Best-effort: missing or already +/// finalized tasks are silently left alone. +pub fn rollback_deletion(candidate: &mut PersistedDownloadQueue, uuid: &str) { + let Some((bucket, idx)) = locate(candidate, uuid) else { return }; + let task = match bucket { + "queue" => candidate.queue.get_mut(idx), + "scheduled" => candidate.scheduled.get_mut(idx), + "active" => candidate.active.as_mut(), + "finished" => candidate.finished.get_mut(idx), + _ => None, + }; + let Some(task) = task else { return }; + let Some(meta) = task.recording.as_mut() else { return }; + let prior = meta.deleting_previous_state.take(); + task.state = match prior { + Some(DeletionPreviousState::Completed) => DownloadState::Completed, + Some(DeletionPreviousState::Failed) => DownloadState::Failed, + Some(DeletionPreviousState::Cancelled) => DownloadState::Cancelled, + // No recorded prior state (the begin step never ran, or the + // recording was already terminal) — fall back to the natural + // non-terminal state. The scheduler will not reissue a delete + // for a recording it never observed as Deleting. + None => DownloadState::Scheduled, + }; +} + +/// Resolve the file path to unlink for a recording. Only the +/// state-owned file is removed. Terminal `Completed` → final; `Failed`/ +/// `Cancelled` → partial (a failed/cancelled recording never reached +/// finalization). +/// +/// The returned path is **canonicalized** when the file exists on +/// disk. `recovery_action_for` compares the result against +/// `recording_root` to detect a path that escapes the recording +/// directory; a raw `download.file_path` may still carry literal `..` +/// segments (`dir/../outside.ts`) that defeat a lexical `starts_with`, +/// so canonicalization is the only honest way to decide whether the +/// file is actually inside the root. +/// +/// `recording_root` is accepted for symmetry with the recovery +/// caller; today every site passes `None` and resolves the path from +/// the task itself, which is the right call while `RecordingMetadata` +/// still carries the absolute path verbatim. +pub async fn file_path_for_deletion( + download: &FileDownload, + _recording_root: Option<&Path>, +) -> Option { + let partial = crate::api::model::recording_worker::recording_partial_path(&download.file_path); + let prior = download.recording.as_ref().and_then(|m| m.deleting_previous_state); + let raw = match prior { + Some(DeletionPreviousState::Completed) => download.file_path.clone(), + Some(_) => partial, + None => return None, + }; + // Canonicalize to resolve `..` segments and symlinks. If the file + // is missing or unreadable, `canonicalize` fails — keep the raw + // path; the missing-file branch in the caller will turn it into + // `FinishDeletion`/`NotDeleting` instead of running IO on it. + Some(tokio::fs::canonicalize(&raw).await.unwrap_or(raw)) +} + +/// Unlink the file owned by a `DeletionTarget`. Missing files count as +/// success. Returns the path that was unlinked, or `None` if no +/// physical file was present. +pub async fn execute_deletion_target( + target: &DeletionTarget, +) -> Result, DeletionError> { + unlink_owned_file(&target.path_to_unlink()).await +} + +async fn unlink_owned_file(path: &Path) -> Result, DeletionError> { + if no_follow_existing(path).await.is_none() { + return Ok(None); + } + safe_unlink(path) + .await + .map_err(std::io::Error::from) + .map_err(DeletionError::DeleteFailed)?; + Ok(Some(path.to_path_buf())) +} + +/// Unlink the recorded file. Missing files count as +/// success. Returns the path that was unlinked, or `None` if no +/// physical file was present. +pub async fn execute_deletion(download: &FileDownload, recording_root: Option<&Path>) -> Result, DeletionError> { + let Some(path) = file_path_for_deletion(download, recording_root).await else { + return Ok(None); + }; + unlink_owned_file(&path).await +} + +/// Remove the task from the queue under a new mutation +/// boundary. Called after the file is gone (or was already missing). +pub async fn finalize_deletion(queue: &DownloadQueue, uuid: &str) -> Result<(), DeletionError> { + crate::api::model::download::mutate(queue, |candidate| { + if let Some((bucket, idx)) = locate(candidate, uuid) { + match bucket { + "queue" => { + if idx < candidate.queue.len() { + candidate.queue.remove(idx); + } + } + "scheduled" => { + if idx < candidate.scheduled.len() { + candidate.scheduled.remove(idx); + } + } + "active" => { + candidate.active = None; + } + "finished" => { + if idx < candidate.finished.len() { + candidate.finished.remove(idx); + } + } + _ => unreachable!(), + } + return Ok(()); + } + Err(QueueMutationError::UnknownRecording) + }) + .await + .map_err(DeletionError::FinalizeFailed)?; + Ok(()) +} + +/// Startup recovery decision for a task in `Deleting` state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryAction { + /// The file is already gone; finish the deletion by removing the + /// task. + FinishDeletion, + /// The file is still present as a regular file; restore the + /// previous state and clear `deleting_previous_state`. + RestorePrevious, + /// The path is unsafe (symlink, wrong type); log and restore the + /// previous state. + UnsafeRestore, + /// The recording has no `deleting_previous_state`; nothing to do. + NotDeleting, +} + +/// Inspect a task that is in `Deleting` state and decide what the +/// startup recovery should do. +pub async fn recovery_action_for( + download: &FileDownload, + recording_root: Option<&Path>, +) -> RecoveryAction { + let Some(meta) = &download.recording else { return RecoveryAction::NotDeleting }; + let Some(_prior) = meta.deleting_previous_state else { return RecoveryAction::NotDeleting }; + let Some(path) = file_path_for_deletion(download, recording_root).await else { + return RecoveryAction::FinishDeletion; + }; + if no_follow_existing(&path).await.is_none() { + return RecoveryAction::FinishDeletion; + } + // The file is still present. If the metadata-derived path is outside + // the recording root, treat as unsafe; otherwise restore the + // previous state. `path` already comes back canonicalized from + // `file_path_for_deletion` (so literal `..` segments cannot slip + // past the lexical check); only `root` needs canonicalizing here. + if let Some(root) = recording_root { + let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf()); + if !path.starts_with(&root_canon) { + return RecoveryAction::UnsafeRestore; + } + } + RecoveryAction::RestorePrevious +} + +/// Apply the recovery action to a candidate. Called by the startup +/// loop after the decision has been computed. +pub fn apply_recovery_to_candidate( + candidate: &mut PersistedDownloadQueue, + uuid: &str, + action: RecoveryAction, +) { + if let Some((bucket, idx)) = locate(candidate, uuid) { + let d = match bucket { + "queue" => candidate.queue.get_mut(idx), + "scheduled" => candidate.scheduled.get_mut(idx), + "active" => candidate.active.as_mut(), + "finished" => candidate.finished.get_mut(idx), + _ => None, + }; + if let Some(d) = d { + match action { + RecoveryAction::FinishDeletion => { + // The caller is expected to remove the task entirely + // after this marker is applied. Here we just clear + // the deletion marker so the post-removal state is + // consistent if a higher-level caller decides + // otherwise. + if let Some(meta) = d.recording.as_mut() { + meta.deleting_previous_state = None; + } + } + RecoveryAction::RestorePrevious | RecoveryAction::UnsafeRestore => { + restore_previous_state(d); + } + RecoveryAction::NotDeleting => {} + } + } + } +} + +fn restore_previous_state(d: &mut PersistedFileDownload) { + let prior = d + .recording + .as_ref() + .and_then(|m| m.deleting_previous_state); + let Some(prior) = prior else { return }; + d.state = match prior { + DeletionPreviousState::Completed => DownloadState::Completed, + DeletionPreviousState::Failed => DownloadState::Failed, + DeletionPreviousState::Cancelled => DownloadState::Cancelled, + }; + if let Some(meta) = d.recording.as_mut() { + meta.deleting_previous_state = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::model::download::{ + DownloadKind, DownloadState, PersistedFileDownload, PersistedDownloadQueue, mutate, + }; + use shared::model::RecordingMetadata; + use std::path::PathBuf; + use std::sync::atomic::Ordering; + use tempfile::TempDir; + + fn make_persisted_recording( + uuid: &str, + state: DownloadState, + deleting: Option, + ) -> PersistedFileDownload { + let mut meta = RecordingMetadata::for_legacy_admin(1_700_000_000, 60); + meta.deleting_previous_state = deleting; + PersistedFileDownload { + uuid: uuid.to_string(), + file_dir: PathBuf::from("/tmp"), + file_path: PathBuf::from(format!("/tmp/{uuid}.ts")), + filename: format!("{uuid}.ts"), + url: format!("https://example.com/{uuid}"), + finished: matches!(state, DownloadState::Completed), + size: 0, + total_size: None, + paused: false, + error: None, + state, + start_at: Some(0), + duration_secs: Some(60), + kind: DownloadKind::Recording, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: Some(meta), + } + } + + fn finished_with_state( + uuid: &str, + state: DownloadState, + deleting: Option, + ) -> FileDownload { + let p = make_persisted_recording(uuid, state, deleting); + crate::api::model::download::DownloadQueue::from_persisted_with(p, None, None) + .expect("restore") + } + + #[test] + fn prior_terminal_state_accepts_completed_failed_cancelled_only() { + let p = make_persisted_recording("r", DownloadState::Completed, None); + let task = crate::api::model::download::DownloadQueue::from_persisted_with(p, None, None) + .expect("test fixture must be valid"); + assert_eq!(prior_terminal_state(&task), Some(DeletionPreviousState::Completed)); + let p = make_persisted_recording("r", DownloadState::Failed, None); + let task = crate::api::model::download::DownloadQueue::from_persisted_with(p, None, None) + .expect("test fixture must be valid"); + assert_eq!(prior_terminal_state(&task), Some(DeletionPreviousState::Failed)); + let p = make_persisted_recording("r", DownloadState::Cancelled, None); + let task = crate::api::model::download::DownloadQueue::from_persisted_with(p, None, None) + .expect("test fixture must be valid"); + assert_eq!(prior_terminal_state(&task), Some(DeletionPreviousState::Cancelled)); + let p = make_persisted_recording("r", DownloadState::Downloading, None); + let task = crate::api::model::download::DownloadQueue::from_persisted_with(p, None, None) + .expect("test fixture must be valid"); + assert_eq!(prior_terminal_state(&task), None); + } + + #[tokio::test] + async fn file_path_for_deletion_uses_final_for_completed_partial_otherwise() { + let task = finished_with_state("r", DownloadState::Completed, Some(DeletionPreviousState::Completed)); + let path = file_path_for_deletion(&task, None).await.expect("path"); + assert_eq!(path, PathBuf::from("/tmp/r.ts")); + let task = finished_with_state("r", DownloadState::Failed, Some(DeletionPreviousState::Failed)); + let path = file_path_for_deletion(&task, None).await.expect("path"); + assert_eq!(path, PathBuf::from("/tmp/r.ts.partial")); + } + + #[tokio::test] + async fn execute_deletion_unlinks_existing_file_and_returns_path() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("r.ts"); + tokio::fs::write(&final_path, b"data").await.expect("write"); + let mut task = finished_with_state("r", DownloadState::Completed, Some(DeletionPreviousState::Completed)); + task.file_path = final_path.clone(); + let deleted = execute_deletion(&task, None).await.expect("delete").expect("some path"); + assert_eq!(deleted, final_path); + assert!(!final_path.exists()); + } + + #[tokio::test] + async fn execute_deletion_is_idempotent_for_missing_file() { + let dir = TempDir::new().expect("tempdir"); + let mut task = finished_with_state("r", DownloadState::Completed, None); + task.file_path = dir.path().join("does-not-exist.ts"); + let result = execute_deletion(&task, None).await.expect("ok"); + assert!(result.is_none(), "missing file must report no path"); + } + + #[tokio::test] + async fn recovery_action_for_finish_when_file_missing() { + let dir = TempDir::new().expect("tempdir"); + let mut task = finished_with_state("r", DownloadState::Completed, Some(DeletionPreviousState::Completed)); + task.file_path = dir.path().join("missing.ts"); + assert_eq!( + recovery_action_for(&task, Some(dir.path())).await, + RecoveryAction::FinishDeletion + ); + } + + #[tokio::test] + async fn recovery_action_for_restore_when_regular_file_present() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("r.ts"); + tokio::fs::write(&final_path, b"data").await.expect("write"); + let mut task = finished_with_state("r", DownloadState::Completed, Some(DeletionPreviousState::Completed)); + task.file_path = final_path; + assert_eq!( + recovery_action_for(&task, Some(dir.path())).await, + RecoveryAction::RestorePrevious + ); + } + + #[tokio::test] + async fn recovery_action_for_unsafe_when_path_outside_root() { + let dir = TempDir::new().expect("tempdir"); + let outside = dir.path().join("..").join("outside.ts"); + let outside = outside.canonicalize().unwrap_or(outside); + tokio::fs::write(&outside, b"data").await.expect("write"); + let mut task = finished_with_state("r", DownloadState::Completed, Some(DeletionPreviousState::Completed)); + task.file_path = outside; + assert_eq!( + recovery_action_for(&task, Some(dir.path())).await, + RecoveryAction::UnsafeRestore + ); + } + + #[tokio::test] + async fn recovery_action_for_not_deleting_when_marker_absent() { + let dir = TempDir::new().expect("tempdir"); + let mut task = finished_with_state("r", DownloadState::Completed, None); + task.file_path = dir.path().join("r.ts"); + assert_eq!( + recovery_action_for(&task, Some(dir.path())).await, + RecoveryAction::NotDeleting + ); + } + + #[test] + fn apply_recovery_to_candidate_restores_state() { + let mut candidate = PersistedDownloadQueue::default(); + let mut p = make_persisted_recording("r", DownloadState::Cancelled, Some(DeletionPreviousState::Completed)); + p.state = DownloadState::Cancelled; + candidate.finished.push(p); + apply_recovery_to_candidate(&mut candidate, "r", RecoveryAction::RestorePrevious); + let restored = &candidate.finished[0]; + assert_eq!(restored.state, DownloadState::Completed); + assert!(restored.recording.as_ref().unwrap().deleting_previous_state.is_none()); + } + + #[tokio::test] + async fn begin_deletion_stamps_deleting_state_under_boundary() { + let dir = TempDir::new().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file.clone())); + let mut task = finished_with_state("r", DownloadState::Completed, None); + task.file_path = dir.path().join("r.ts"); + let persisted = DownloadQueue::to_persisted(&task); + mutate(&queue, |c| { + c.finished.push(persisted); + Ok(()) + }) + .await + .expect("seed"); + let prior = queue.revision.load(Ordering::SeqCst); + begin_deletion(&queue, "r").await.expect("begin"); + let after = queue.finished.read().await.first().cloned().expect("task"); + assert_eq!(after.recording.as_ref().unwrap().deleting_previous_state, Some(DeletionPreviousState::Completed)); + assert!(after.recording.as_ref().unwrap().is_deleting()); + assert!(queue.revision.load(Ordering::SeqCst) > prior, "revision must advance"); + } + + #[tokio::test] + async fn begin_deletion_authorized_rejects_when_the_permit_declines() { + // Authorization runs inside the same mutation boundary that stamps + // the task, so a decline must leave the task untouched. + let dir = TempDir::new().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file)); + let mut task = finished_with_state("r", DownloadState::Completed, None); + task.file_path = dir.path().join("r.ts"); + let persisted = DownloadQueue::to_persisted(&task); + mutate(&queue, |c| { + c.finished.push(persisted); + Ok(()) + }) + .await + .expect("seed"); + + let result = begin_deletion_authorized(&queue, "r", |_| false).await; + + assert!(matches!(result, Err(DeletionError::Forbidden))); + let finished = queue.finished.read().await; + assert_eq!(finished[0].state, DownloadState::Completed); + assert!(finished[0] + .recording + .as_ref() + .is_none_or(|meta| meta.deleting_previous_state.is_none())); + } + + #[tokio::test] + async fn begin_deletion_rejects_unknown_task() { + let dir = TempDir::new().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file)); + let result = begin_deletion(&queue, "missing").await; + // Reported as its own variant now, not folded into the opaque + // `BeginFailed`, so the service layer can map it to a 404. + assert!(matches!(result, Err(DeletionError::UnknownTask))); + } + + #[tokio::test] + async fn begin_deletion_rejects_non_terminal_state() { + let dir = TempDir::new().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file)); + let mut task = finished_with_state("r", DownloadState::Downloading, None); + task.file_path = dir.path().join("r.ts"); + let persisted = DownloadQueue::to_persisted(&task); + mutate(&queue, |c| { + c.finished.push(persisted); + Ok(()) + }) + .await + .expect("seed"); + let result = begin_deletion(&queue, "r").await; + assert!(matches!(result, Err(DeletionError::NotTerminal))); + } + + #[tokio::test] + async fn finalize_deletion_removes_task_under_boundary() { + let dir = TempDir::new().expect("tempdir"); + let state_file = dir.path().join("downloads_state.json"); + let queue = DownloadQueue::new_with_state_file(Some(state_file)); + let mut task = finished_with_state("r", DownloadState::Cancelled, Some(DeletionPreviousState::Cancelled)); + task.file_path = dir.path().join("r.ts"); + let persisted = DownloadQueue::to_persisted(&task); + mutate(&queue, |c| { + c.finished.push(persisted); + Ok(()) + }) + .await + .expect("seed"); + finalize_deletion(&queue, "r").await.expect("finalize"); + assert!(queue.finished.read().await.is_empty()); + } +} diff --git a/backend/src/api/model/recording/recording_disk.rs b/backend/src/api/model/recording/recording_disk.rs new file mode 100644 index 000000000..6078ac0d5 --- /dev/null +++ b/backend/src/api/model/recording/recording_disk.rs @@ -0,0 +1,403 @@ +//! Disk reservation and safety admission. +//! +//! - Measure the filesystem containing the canonical recording root. +//! - Reject start with `recording_insufficient_disk` when free bytes +//! cannot cover safety bytes plus the candidate's remaining +//! conservative charge after current active disk reservations. +//! - Serialize active disk reservations through the queue +//! transaction so two starts cannot consume the same measured +//! headroom. +//! - Never reuse a measurement from another filesystem solely +//! because it belongs to `storage_dir` or the download directory. +//! +//! This module provides: +//! - `free_bytes_for(path)`: a syscall on the given path. Returns +//! the bytes available to the **caller** (`f_bavail` on Unix), +//! not the raw free blocks — the latter includes space reserved +//! for root that the recording worker cannot actually use. +//! - `would_fit_on_disk(free, safety, active, candidate)`: a pure +//! admission check. The active-reservation total is fed in by +//! the caller (under the queue mutation boundary so two starts +//! cannot race on the same headroom). +//! - `DiskAdmission`: `Ok { headroom_after }` / `Insufficient { ... }`. +//! +//! The "stale measurement" + "concurrent starts" invariants are +//! tested via the pure function; the syscall path is exercised +//! against the actual recording root when `cfg(test)` runs. + +use std::path::Path; + +/// `Ok { headroom_after }` if the candidate fits; otherwise +/// `Insufficient { free, safety, active, candidate, headroom }` +/// with the values the caller can show in the +/// `recording_insufficient_disk` error. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DiskAdmission { + Ok { headroom_after: u64 }, + Insufficient { + free: u64, + safety: u64, + active: u64, + candidate: u64, + headroom: u64, + }, +} + +/// Pure admission check. +/// +/// `headroom = free.saturating_sub(safety).saturating_sub(active)`. +/// If `candidate <= headroom`, admission is `Ok { headroom_after = headroom - candidate }`. +/// Otherwise `Insufficient` with the raw values. +pub fn would_fit_on_disk( + free_bytes: u64, + safety_bytes: u64, + active_reservations: u64, + candidate_charge: u64, +) -> DiskAdmission { + let headroom = free_bytes + .saturating_sub(safety_bytes) + .saturating_sub(active_reservations); + if candidate_charge <= headroom { + DiskAdmission::Ok { + headroom_after: headroom - candidate_charge, + } + } else { + DiskAdmission::Insufficient { + free: free_bytes, + safety: safety_bytes, + active: active_reservations, + candidate: candidate_charge, + headroom, + } + } +} + +/// Sum the active disk reservations across a set of currently-active +/// tasks. Active disk reservations must be counted: tasks in +/// `Downloading` (and any other state holding disk headroom). +/// Generic downloads are excluded. +/// +/// The caller is expected to be under the queue mutation boundary +/// (so the sum is consistent across the set). +pub fn active_disk_reservations(tasks: &[V]) -> u64 +where + V: super::recording_quota::QuotaRecordingTaskView, +{ + let mut total = 0u64; + for task in tasks { + // "Active" means holding disk headroom right now. Today + // that is `Downloading`; the worker pre-start path checks + // admission before transitioning into the active state, so + // `Downloading` is the only contributor for the conservative + // charge. Anything + // else with `reserved_bytes > 0` is **not** holding + // headroom yet — the headroom is reserved at start. + if matches!(task.state(), crate::api::model::download::DownloadState::Downloading) { + total = total.saturating_add(super::recording_quota::charge_for_task(task)); + } + } + total +} + +/// Measure the bytes available to the caller on the filesystem +/// that contains `path`. Returns `None` if the syscall fails +/// (path missing, permission denied, or non-Unix/Windows). +/// +/// Never reuse a measurement from another filesystem. The syscall +/// is keyed on the supplied path — the kernel resolves the path +/// to its mount. The caller must pass the canonical recording +/// root, not e.g. `storage_dir` or the +/// generic download directory. +/// Total and available bytes on the filesystem that contains `path`, +/// as `(total, available)`. +/// +/// The disk-pressure sweep needs both numbers to compute a used +/// percentage, and both must come from the *same* syscall: measuring +/// total and free separately can straddle a write and produce a +/// percentage that never existed. +pub fn filesystem_capacity_for(path: &Path) -> Option<(u64, u64)> { + #[cfg(unix)] + { + let cstr = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).ok()?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + // SAFETY: `cstr` is a valid NUL-terminated C string; `&raw mut stat` + // is a writable pointer to a zeroed struct. + let rc = unsafe { libc::statvfs(cstr.as_ptr(), &raw mut stat) }; + if rc != 0 { + return None; + } + #[cfg(target_pointer_width = "32")] + let bsize = u64::from(stat.f_frsize); + #[cfg(target_pointer_width = "64")] + let bsize = stat.f_frsize; + let total = stat.f_blocks.saturating_mul(bsize); + // `f_bavail`, not `f_bfree`: the service user cannot use the + // root-reserved blocks, so counting them would understate pressure. + let available = stat.f_bavail.saturating_mul(bsize); + Some((total, available)) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + let wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut free_bytes_available: u64 = 0; + let mut total_bytes: u64 = 0; + let mut total_free_bytes: u64 = 0; + // SAFETY: `wide` is a NUL-terminated UTF-16 path; the three output + // pointers alias `ULARGE_INTEGER` (which is a `u64` newtype on + // the winapi crate). `&raw mut` gives us a stable raw pointer + // to each `u64`; `.cast()` widens it to the `*mut ULARGE_INTEGER` + // the FFI expects. + let ok = unsafe { + winapi::um::fileapi::GetDiskFreeSpaceExW( + wide.as_ptr(), + (&raw mut free_bytes_available).cast(), + (&raw mut total_bytes).cast(), + (&raw mut total_free_bytes).cast(), + ) + }; + if ok == 0 { + return None; + } + Some((total_bytes, free_bytes_available)) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + None + } +} + +pub fn free_bytes_for(path: &Path) -> Option { + #[cfg(unix)] + { + let cstr = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).ok()?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + // SAFETY: `cstr` is a valid NUL-terminated C string; `&raw mut stat` + // is a writable pointer to a zeroed struct. + let rc = unsafe { libc::statvfs(cstr.as_ptr(), &raw mut stat) }; + if rc != 0 { + return None; + } + #[cfg(target_pointer_width = "32")] + let bsize = u64::from(stat.f_frsize); + #[cfg(target_pointer_width = "64")] + let bsize = stat.f_frsize; + // `f_bavail` is the bytes available to a non-privileged + // caller — the worker runs as the service user and is + // subject to the same reservation as any other user. + Some(stat.f_bavail.saturating_mul(bsize)) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + let wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut free_bytes_available: u64 = 0; + let mut total_bytes: u64 = 0; + let mut total_free_bytes: u64 = 0; + // SAFETY: see `filesystem_capacity_for` — the three output + // pointers alias `ULARGE_INTEGER` (`u64` newtype); `&raw mut` + // + `.cast()` produces the right raw pointer type. + let ok = unsafe { + winapi::um::fileapi::GetDiskFreeSpaceExW( + wide.as_ptr(), + (&raw mut free_bytes_available).cast(), + (&raw mut total_bytes).cast(), + (&raw mut total_free_bytes).cast(), + ) + }; + if ok == 0 { + return None; + } + Some(free_bytes_available) + } + #[cfg(not(any(unix, windows)))] + { + let _ = path; + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::model::download::DownloadState; + use shared::model::recording::{RecordingMetadata, RecordingOwner, RecordingSource, RecordingVisibility}; + use shared::model::UserId; + + fn make_meta(reserved: u64, measured: u64) -> RecordingMetadata { + RecordingMetadata { + owner: RecordingOwner::User(UserId::from("web:alice")), + visibility: RecordingVisibility::Private, + source: Some(RecordingSource::new("t1", "v1", "in1")), + program_start: None, + program_end: None, + scheduled_start: None, + scheduled_end: None, + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: None, + channel_name: None, + program_title: None, + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: None, + partial_relative_path: None, + reserved_bytes: reserved, + measured_bytes: measured, + completed_at: None, + notification_markers: Vec::new(), + deleting_previous_state: None, + } + } + + struct T { + state: DownloadState, + recording: Option, + } + impl super::super::recording_quota::QuotaRecordingTaskView for T { + fn state(&self) -> &DownloadState { + &self.state + } + fn recording(&self) -> Option<&RecordingMetadata> { + self.recording.as_ref() + } + fn uuid(&self) -> &'static str { + "" + } + } + + fn downloading(reserved: u64, measured: u64) -> T { + T { + state: DownloadState::Downloading, + recording: Some(make_meta(reserved, measured)), + } + } + + #[test] + fn admission_ok_when_candidate_fits() { + let out = would_fit_on_disk(10_000, 1_000, 2_000, 3_000); + // headroom = 10_000 - 1_000 - 2_000 = 7_000; 3_000 ≤ 7_000 → ok + assert_eq!(out, DiskAdmission::Ok { headroom_after: 4_000 }); + } + + #[test] + fn admission_insufficient_when_candidate_exceeds_headroom() { + let out = would_fit_on_disk(10_000, 1_000, 2_000, 8_000); + // headroom = 7_000; 8_000 > 7_000 → insufficient + assert_eq!( + out, + DiskAdmission::Insufficient { + free: 10_000, + safety: 1_000, + active: 2_000, + candidate: 8_000, + headroom: 7_000, + } + ); + } + + #[test] + fn admission_saturates_when_safety_plus_active_exceeds_free() { + // free < safety + active → headroom 0, candidate always fails + let out = would_fit_on_disk(1_000, 5_000, 500, 1); + assert_eq!( + out, + DiskAdmission::Insufficient { + free: 1_000, + safety: 5_000, + active: 500, + candidate: 1, + headroom: 0, + } + ); + } + + #[test] + fn admission_zero_candidate_always_fits() { + let out = would_fit_on_disk(100, 0, 0, 0); + assert_eq!(out, DiskAdmission::Ok { headroom_after: 100 }); + } + + #[test] + fn active_reservations_count_only_downloading() { + // Only `Downloading` tasks contribute to the active total. + // Scheduled/Queued tasks hold a *reservation* but not actual + // disk headroom yet. The headroom is taken at start, not + // at create. + let tasks = vec![ + downloading(1000, 0), + T { + state: DownloadState::Scheduled, + recording: Some(make_meta(500, 0)), + }, + T { + state: DownloadState::Completed, + recording: Some(make_meta(0, 4000)), + }, + ]; + assert_eq!(active_disk_reservations(&tasks), 1000); + } + + #[test] + fn active_reservations_handles_active_growth() { + // active: reserved 1000, measured 1500 → max(1000, 1500) = 1500 + let tasks = vec![downloading(1000, 1500)]; + assert_eq!(active_disk_reservations(&tasks), 1500); + } + + #[test] + fn admission_concurrent_starts_serialized_via_active_sum() { + // Two starts cannot both consume the same headroom: the + // first start's reservation moves into `active` before + // the second start's admission check. This test mirrors + // the contract: with `active = first_charge`, the second + // candidate that would have fit on its own now fails. + let free = 10_000; + let safety = 1_000; + // first start: candidate 5_000 → headroom = 9_000 - 5_000 = 4_000 + let first = would_fit_on_disk(free, safety, 0, 5_000); + assert_eq!(first, DiskAdmission::Ok { headroom_after: 4_000 }); + // second start (same headroom, first charge is now `active`): + let active = 5_000; + // headroom = 10_000 - 1_000 - 5_000 = 4_000; 4_500 fails + let second = would_fit_on_disk(free, safety, active, 4_500); + assert!(matches!(second, DiskAdmission::Insufficient { .. })); + // 4_000 exactly would fit + let third = would_fit_on_disk(free, safety, active, 4_000); + assert_eq!(third, DiskAdmission::Ok { headroom_after: 0 }); + } + + #[test] + fn measurement_targets_the_recording_root_filesystem() { + // Never reuse a measurement from another filesystem solely + // because it belongs to `storage_dir` or the download + // directory. We exercise `free_bytes_for` against the + // actual filesystem that + // contains `/tmp` (the temp dir is on the same FS as the + // process CWD on Linux CI). The syscall resolves `/tmp` + // to its mount and returns that FS's free bytes. + let path = std::path::Path::new("/tmp"); + let free = free_bytes_for(path); + // We don't assert a specific number (the test runner's + // disk may be any size), but `/tmp` should be readable + // and report some free bytes. + if let Some(free) = free { + assert!(free > 0, "expected positive free bytes for /tmp"); + } + } + + #[test] + fn measurement_returns_none_for_missing_path() { + let path = std::path::Path::new("/this/path/does/not/exist/xyzzy"); + assert!(free_bytes_for(path).is_none()); + } +} diff --git a/backend/src/api/model/recording/recording_edit.rs b/backend/src/api/model/recording/recording_edit.rs new file mode 100644 index 000000000..d4d905497 --- /dev/null +++ b/backend/src/api/model/recording/recording_edit.rs @@ -0,0 +1,402 @@ +//! Upcoming recording edit validation. +//! +//! Edits are allowed only in `Scheduled`, `Queued`, +//! `WaitingForCapacity`, `RetryWaiting`. `Downloading`, terminal +//! states, and `Deleting` are immutable. The invariants: +//! +//! - Recalculate the conservative reservation using the remaining +//! duration where the start is in the past (currently-airing). +//! - Re-reserve a derived output path atomically and release the +//! old reservation only when commit succeeds. The queue-mutation +//! boundary does that work; this module exposes the *pure* +//! validation that runs inside the boundary. +//! - Preserve immutable `rule_id` and `occurrence_key`. +//! - Clear EPG / episode metadata on channel/provider change unless +//! the server verifies a fresh matching programme payload. +//! - Return advisory conflict warnings with the committed edit +//! response. +//! +//! This module owns the pure helpers. The actual queue-mutation +//! wiring (path reservation, quota, atomic persist) lands with the +//! queue transaction. +//! +//! The helpers are tested in isolation. They are public so the +//! queue-mutation boundary can call them once the wiring lands; the +//! `dead_code` allowance below is the test surface. + + +use shared::model::recording::{RecordingMetadata, RecordingVisibility}; + +use crate::api::model::DownloadState; + +/// The set of states a recording can be in for an edit to be +/// accepted. +pub const EDITABLE_STATES: &[&str] = &["Scheduled", "Queued", "WaitingForCapacity", "RetryWaiting"]; + +impl DownloadState { + /// Stable wire label consumed by `state_is_editable` and any caller + /// that needs to surface the state name in logs, errors, or tests. + /// Kept here so the source of truth for both the label and the + /// editable set lives next to `EDITABLE_STATES`. + pub fn label(&self) -> &'static str { + match self { + Self::Queued => "Queued", + Self::Scheduled => "Scheduled", + Self::WaitingForCapacity => "WaitingForCapacity", + Self::RetryWaiting => "RetryWaiting", + Self::Downloading => "Downloading", + Self::Paused => "Paused", + Self::Completed => "Completed", + Self::Failed => "Failed", + Self::Cancelled => "Cancelled", + } + } +} + +/// Edit-time error taxonomy. Stable wire codes live in +/// `RecordingService::ServiceError`; this enum is the *pure* +/// validation's vocabulary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EditError { + /// The recording is in a state that does not allow edits + /// (active / terminal / `Deleting`). + StateNotEditable, + /// `program_end - program_start` is non-positive or the patch + /// leaves the interval in an invalid shape. + InvalidInterval, + /// A padding field exceeds the configured maximum. + PaddingLimitExceeded, + /// The patch would clear the rule provenance or occurrence key + /// (forbidden — both are immutable). + ProvenanceCleared, + /// The channel/provider changed but no matching programme + /// payload was supplied to refresh the EPG / episode metadata. + ChannelChangedWithoutProgramme, +} + +/// A patch of editable values. The serializer deserializes only the +/// fields that the API accepts; immutable fields (`rule_id`, +/// `occurrence_key`, `owner`, `visibility`, `source`) are never +/// part of the patch. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct EditPatch { + pub program_start: Option, + pub program_end: Option, + pub pre_roll_secs: Option, + pub post_roll_secs: Option, + pub program_title: Option, + pub channel_id: Option, + pub channel_name: Option, +} + +/// Configured padding bounds (mirrors `RecordingConfigDto`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PaddingBounds { + pub max_pre_roll_secs: u64, + pub max_post_roll_secs: u64, +} + +/// The current state label. The form strings are +/// `Scheduled` / `Queued` / `WaitingForCapacity` / `RetryWaiting` / +/// `Downloading` / `Completed` / `Failed` / `Cancelled` / +/// `Deleting()`. The current state for the existing +/// `DownloadState` variants is reduced to a string here. +pub fn state_is_editable(state_label: &str) -> bool { EDITABLE_STATES.contains(&state_label) } + +/// Pure: validate the merged interval (patch overlaid on current) and +/// the patch's padding bounds. Validation runs against the merged +/// `program_start`/`program_end` so a patch that only sets `end` +/// still has to produce a valid interval against the stored `start`. +pub fn validate_patch( + patch: &EditPatch, + current: &RecordingMetadata, + bounds: PaddingBounds, +) -> Result<(), EditError> { + let merged_start = patch.program_start.or(current.program_start); + let merged_end = patch.program_end.or(current.program_end); + if let (Some(start), Some(end)) = (merged_start, merged_end) { + if end <= start { + return Err(EditError::InvalidInterval); + } + } + validate_padding( + patch.pre_roll_secs.unwrap_or(current.pre_roll_secs), + patch.post_roll_secs.unwrap_or(current.post_roll_secs), + bounds, + )?; + Ok(()) +} + +pub fn validate_padding( + pre_roll_secs: u64, + post_roll_secs: u64, + bounds: PaddingBounds, +) -> Result<(), EditError> { + if pre_roll_secs > bounds.max_pre_roll_secs || post_roll_secs > bounds.max_post_roll_secs { + return Err(EditError::PaddingLimitExceeded); + } + Ok(()) +} + +/// Pure: detect the channel/provider change rule. When the patch +/// changes `channel_id` or `channel_name` and the caller did not +/// supply a fresh programme payload, the EPG / episode metadata +/// must be cleared. A `None` in the patch means "no change", so the +/// channel only counts as changed when the patch actually sets a +/// different value. +pub fn channel_changed(patch: &EditPatch, current_channel_id: Option<&str>, current_channel_name: Option<&str>) -> bool { + if let Some(new_id) = patch.channel_id.as_deref() { + if Some(new_id) != current_channel_id { + return true; + } + } + if let Some(new_name) = patch.channel_name.as_deref() { + if Some(new_name) != current_channel_name { + return true; + } + } + false +} + +/// Pure: derive the new interval and padded window from the +/// `current` metadata and the patch. +pub fn apply_interval_patch( + current: &RecordingMetadata, + patch: &EditPatch, +) -> (i64, i64, i64, i64) { + let program_start = patch.program_start.unwrap_or_else(|| current.program_start.unwrap_or(0)); + let program_end = patch.program_end.unwrap_or_else(|| current.program_end.unwrap_or(0)); + let pre = patch.pre_roll_secs.unwrap_or(current.pre_roll_secs); + let post = patch.post_roll_secs.unwrap_or(current.post_roll_secs); + let scheduled_start = program_start.saturating_sub(pre.cast_signed()); + let scheduled_end = program_end.saturating_add(post.cast_signed()); + (program_start, program_end, scheduled_start, scheduled_end) +} + +/// Pure: verify the patch never clears the rule provenance or +/// occurrence key. The patch surface does not include them, so this +/// is a defensive check that the caller did not smuggle them in. +pub fn patch_preserves_provenance(patch: &EditPatch) -> bool { + // The patch is typed; clearing the rule provenance would + // require either `Option` for fields that the type does + // not have, or a separate constructor that explicitly nulls + // them. The type system already prevents that. This helper + // exists so the queue-mutation boundary has a single named + // gate to call. + let _ = patch; + true +} + +/// Decide whether the patch's EPG / episode metadata should be +/// cleared. Clear unless the caller supplied a fresh matching +/// programme payload. The frontend either sends `program_title` +/// (refresh signal) or the channel matches the current one. +pub fn epg_metadata_should_be_cleared( + patch: &EditPatch, + current: &RecordingMetadata, + fresh_programme_supplied: bool, +) -> bool { + if fresh_programme_supplied { + return false; + } + let current_id = current.channel_id.as_deref(); + let current_name = current.channel_name.as_deref(); + channel_changed(patch, current_id, current_name) +} + +/// Stable wire code for an edit error. Mirrors the +/// `recording_*` error code family. +pub fn edit_error_code(err: &EditError) -> &'static str { + match err { + EditError::StateNotEditable => "recording_state_not_editable", + EditError::InvalidInterval => "recording_invalid_interval", + EditError::PaddingLimitExceeded => "recording_padding_limit_exceeded", + EditError::ProvenanceCleared => "recording_provenance_immovable", + EditError::ChannelChangedWithoutProgramme => "recording_channel_changed_without_programme", + } +} + +/// The visibility is not part of the editable surface. The boundary calls +/// this helper to assert the caller did not change it via a forged +/// payload — `requested` is ignored and the current visibility is always +/// returned. Callers must not surface `requested` back to the caller. +pub fn visibility_unchanged( + current_visibility: RecordingVisibility, + _requested: Option, +) -> RecordingVisibility { + current_visibility +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bounds() -> PaddingBounds { PaddingBounds { max_pre_roll_secs: 900, max_post_roll_secs: 1800 } } + + #[test] + fn state_is_editable_accepts_only_upcoming_states() { + for s in EDITABLE_STATES { + assert!(state_is_editable(s)); + } + assert!(!state_is_editable("Downloading")); + assert!(!state_is_editable("Completed")); + assert!(!state_is_editable("Failed")); + assert!(!state_is_editable("Cancelled")); + assert!(!state_is_editable("Deleting(Completed)")); + } + + fn current_meta() -> RecordingMetadata { + // Baseline current metadata: program 100..500, no padding. + // Tests overlay a patch on top of this and assert the merged + // interval is validated. + let mut m = RecordingMetadata::for_legacy_admin(100, 400); + m.pre_roll_secs = 0; + m.post_roll_secs = 0; + m + } + + #[test] + fn validate_patch_rejects_inverted_interval() { + let patch = EditPatch { program_start: Some(200), program_end: Some(100), ..Default::default() }; + assert_eq!(validate_patch(&patch, ¤t_meta(), bounds()), Err(EditError::InvalidInterval)); + } + + #[test] + fn validate_patch_rejects_merged_inverted_interval() { + // Patch only sets end; current start is 100. Setting end to 50 + // produces an inverted merged interval that must be rejected. + let patch = EditPatch { program_end: Some(50), ..Default::default() }; + assert_eq!(validate_patch(&patch, ¤t_meta(), bounds()), Err(EditError::InvalidInterval)); + } + + #[test] + fn validate_patch_rejects_pre_roll_above_max() { + let patch = EditPatch { pre_roll_secs: Some(901), ..Default::default() }; + assert_eq!(validate_patch(&patch, ¤t_meta(), bounds()), Err(EditError::PaddingLimitExceeded)); + } + + #[test] + fn validate_patch_rejects_post_roll_above_max() { + let patch = EditPatch { post_roll_secs: Some(1801), ..Default::default() }; + assert_eq!(validate_patch(&patch, ¤t_meta(), bounds()), Err(EditError::PaddingLimitExceeded)); + } + + #[test] + fn validate_padding_rejects_values_above_configured_maximum() { + assert_eq!(validate_padding(901, 0, bounds()), Err(EditError::PaddingLimitExceeded)); + assert_eq!(validate_padding(0, 1_801, bounds()), Err(EditError::PaddingLimitExceeded)); + assert!(validate_padding(900, 1_800, bounds()).is_ok()); + } + + #[test] + fn validate_patch_accepts_padded_extensions() { + let patch = EditPatch { program_end: Some(1_000), post_roll_secs: Some(1_800), ..Default::default() }; + assert!(validate_patch(&patch, ¤t_meta(), bounds()).is_ok()); + } + + #[test] + fn channel_changed_only_when_id_or_name_differ() { + let patch = EditPatch::default(); + assert!(!channel_changed(&patch, Some("a"), Some("A"))); + let patch = EditPatch { channel_id: Some("b".into()), ..Default::default() }; + assert!(channel_changed(&patch, Some("a"), Some("A"))); + let patch = EditPatch { channel_name: Some("B".into()), ..Default::default() }; + assert!(channel_changed(&patch, Some("a"), Some("A"))); + } + + #[test] + fn apply_interval_patch_keeps_current_when_unset() { + let meta = make_meta(100, 200, 0, 0); + let patch = EditPatch::default(); + let (start, end, scheduled_start, scheduled_end) = apply_interval_patch(&meta, &patch); + assert_eq!((start, end, scheduled_start, scheduled_end), (100, 200, 100, 200)); + } + + #[test] + fn apply_interval_patch_uses_padding() { + let meta = make_meta(100, 200, 0, 0); + let patch = EditPatch { pre_roll_secs: Some(60), post_roll_secs: Some(120), ..Default::default() }; + let (_, _, scheduled_start, scheduled_end) = apply_interval_patch(&meta, &patch); + assert_eq!(scheduled_start, 40); + assert_eq!(scheduled_end, 320); + } + + #[test] + fn apply_interval_patch_handles_extreme_window_without_panicking() { + let meta = make_meta(i64::MIN, i64::MAX, 0, 0); + + let interval = apply_interval_patch(&meta, &EditPatch::default()); + + assert_eq!(interval, (i64::MIN, i64::MAX, i64::MIN, i64::MAX)); + } + + #[test] + fn epg_metadata_should_be_cleared_when_channel_changes_without_payload() { + let meta = make_meta(0, 0, 0, 0); + let meta = RecordingMetadata { channel_id: Some("a".into()), channel_name: Some("A".into()), ..meta }; + let patch = EditPatch { channel_id: Some("b".into()), ..Default::default() }; + assert!(epg_metadata_should_be_cleared(&patch, &meta, false)); + assert!(!epg_metadata_should_be_cleared(&patch, &meta, true)); + } + + #[test] + fn epg_metadata_preserved_when_channel_unchanged() { + let meta = RecordingMetadata { channel_id: Some("a".into()), channel_name: Some("A".into()), ..make_meta(0, 0, 0, 0) }; + let patch = EditPatch::default(); + assert!(!epg_metadata_should_be_cleared(&patch, &meta, false)); + } + + #[test] + fn patch_preserves_provenance_by_type() { + // The patch type cannot carry rule_id or occurrence_key. The + // helper is a defensive gate; the type system is the + // primary defense. + assert!(patch_preserves_provenance(&EditPatch::default())); + } + + #[test] + fn visibility_unchanged_keeps_current_when_none() { + use shared::model::recording::RecordingVisibility; + assert_eq!( + visibility_unchanged(RecordingVisibility::Private, None), + RecordingVisibility::Private + ); + } + + #[test] + fn edit_error_codes_are_stable() { + assert_eq!(edit_error_code(&EditError::StateNotEditable), "recording_state_not_editable"); + assert_eq!(edit_error_code(&EditError::InvalidInterval), "recording_invalid_interval"); + assert_eq!( + edit_error_code(&EditError::PaddingLimitExceeded), + "recording_padding_limit_exceeded" + ); + } + + /// Tiny helper so the test signatures stay short. + fn make_meta(start: i64, end: i64, pre: u64, post: u64) -> RecordingMetadata { + RecordingMetadata { + owner: shared::model::recording::RecordingOwner::LegacyAdmin, + visibility: shared::model::recording::RecordingVisibility::Private, + source: None, + program_start: Some(start), + program_end: Some(end), + scheduled_start: Some(start), + scheduled_end: Some(end), + pre_roll_secs: pre, + post_roll_secs: post, + channel_id: None, + channel_name: None, + program_title: None, + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: None, + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: 0, + completed_at: None, + notification_markers: vec![], + deleting_previous_state: None, + } + } +} diff --git a/backend/src/api/model/recording/recording_math.rs b/backend/src/api/model/recording/recording_math.rs new file mode 100644 index 000000000..c2a9b3752 --- /dev/null +++ b/backend/src/api/model/recording/recording_math.rs @@ -0,0 +1,101 @@ +//! Shared recording-window arithmetic. +//! +//! `start_at + duration_secs` is computed in three places — the queue +//! (missed-window detection), the worker (remaining duration), and the +//! service (padded interval). Each site used to guard the overflow its +//! own way (`saturating_add(i64::MAX)`, `checked_add`, an unchecked +//! cast), so "unbounded" meant three different things. This module is +//! the single representation. +//! +//! The chosen representation is *saturating*: a duration that does not +//! fit in `i64`, or a sum that overflows, yields `i64::MAX` — an end +//! instant no wall clock reaches, i.e. an effectively unbounded +//! window. Every caller therefore treats an absurd duration as +//! "still running" rather than as "already elapsed", which is the +//! conservative choice for a recorder. + +/// The instant a recording that starts at `start_at` and runs for +/// `duration_secs` ends, saturating at `i64::MAX`. +pub fn recording_end_at(start_at: i64, duration_secs: u64) -> i64 { + start_at.saturating_add(sat_i64_from_u64(duration_secs)) +} + +/// Cast `u64` to `i64` with saturation: anything that does not fit +/// (including `u64::MAX`) becomes `i64::MAX`. +/// +/// This is the only correct choice for arithmetic on time math: a +/// non-saturating cast would panic, and a saturating cast to a +/// per-site-specific floor (30, 3600, …) would silently change meaning +/// across callers. Use [`recording_math::recording_end_at`], +/// `saturating_sub`, and the like on the result — never an unchecked +/// arithmetic op. +pub fn sat_i64_from_u64(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +/// `true` when `now_ts` is at or past the end of the window. +pub fn window_elapsed(start_at: i64, duration_secs: u64, now_ts: i64) -> bool { + now_ts >= recording_end_at(start_at, duration_secs) +} + +/// Seconds left in the window at `now_ts`. +/// +/// - `None` when the window has already elapsed. +/// - The full `duration_secs` when `now_ts` is at or before +/// `start_at` (the recording has not begun yet). +/// - Otherwise the remaining tail of the window. +pub fn remaining_window_secs(start_at: i64, duration_secs: u64, now_ts: i64) -> Option { + let end_at = recording_end_at(start_at, duration_secs); + if now_ts >= end_at { + return None; + } + if now_ts <= start_at { + return Some(duration_secs); + } + u64::try_from(end_at.saturating_sub(now_ts)).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn end_at_adds_duration() { + assert_eq!(recording_end_at(1_000, 60), 1_060); + } + + #[test] + fn sat_i64_from_u64_passes_small_values_through() { + assert_eq!(sat_i64_from_u64(0), 0); + assert_eq!(sat_i64_from_u64(60), 60); + assert_eq!(sat_i64_from_u64(i64::MAX as u64), i64::MAX); + } + + #[test] + fn sat_i64_from_u64_saturates_for_overflowing_values() { + assert_eq!(sat_i64_from_u64(u64::MAX), i64::MAX); + assert_eq!(sat_i64_from_u64((i64::MAX as u64) + 1), i64::MAX); + } + + #[test] + fn end_at_saturates_on_unrepresentable_duration() { + assert_eq!(recording_end_at(0, u64::MAX), i64::MAX); + assert_eq!(recording_end_at(i64::MAX, 60), i64::MAX); + } + + #[test] + fn unbounded_window_never_elapses() { + assert!(!window_elapsed(0, u64::MAX, i64::MAX - 1)); + assert!(window_elapsed(1_000, 60, 1_060)); + assert!(!window_elapsed(1_000, 60, 1_059)); + } + + #[test] + fn remaining_covers_before_during_and_after() { + assert_eq!(remaining_window_secs(1_000, 60, 900), Some(60)); + assert_eq!(remaining_window_secs(1_000, 60, 1_000), Some(60)); + assert_eq!(remaining_window_secs(1_000, 60, 1_030), Some(30)); + assert_eq!(remaining_window_secs(1_000, 60, 1_060), None); + assert_eq!(remaining_window_secs(1_000, 60, 2_000), None); + } +} diff --git a/backend/src/api/model/recording/recording_notification.rs b/backend/src/api/model/recording/recording_notification.rs new file mode 100644 index 000000000..ec0e16585 --- /dev/null +++ b/backend/src/api/model/recording/recording_notification.rs @@ -0,0 +1,238 @@ +//! Recording lifecycle notification payload + routing. +//! +//! This module owns the payload shape, template-field resolution, and routing +//! decision. Shared, administrator-owned private, and legacy administrator +//! events may route to global channels; regular users' private recordings do +//! not. + +use shared::model::recording::{RecordingMetadata, RecordingOwner, RecordingVisibility}; + +/// The lifecycle event the adapter consumes. One per transition +/// (start / completion / failure). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LifecycleEvent { + Started, + Completed, + Failed, +} + +impl LifecycleEvent { + #[cfg(test)] + pub fn wire_name(self) -> &'static str { + match self { + Self::Started => "recording_started", + Self::Completed => "recording_completed", + Self::Failed => "recording_failed", + } + } +} + +/// Template fields the messaging layer can render. Optional fields are `None` +/// when the caller did not supply them. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LifecyclePayload { + pub programme_title: Option, + pub channel: Option, + pub effective_start: Option, + pub effective_end: Option, + pub visibility: Option, + pub output_filename: Option, + /// `Some` for `Failed` events; `None` for `Started` / `Completed`. + pub failure_reason: Option, + /// The opaque task id. Used for log correlation; never + /// serialized in the user-facing template. + pub task_id: Option, +} + +impl LifecyclePayload { + /// Build the payload from the recording metadata and an optional failure + /// reason. The `output_filename` exposes only the file-name component + /// of `meta.relative_path` (when present), so a user-owned + /// notification template never embeds the owning user's identifier + /// — paths under a `users//...` layout would otherwise leak + /// the owner's `UserId` into global notifications. + pub fn from_metadata(meta: &RecordingMetadata, failure_reason: Option) -> Self { + let output_filename = meta + .relative_path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_name().and_then(|s| s.to_str())) + .map(str::to_string); + Self { + programme_title: meta.program_title.clone(), + channel: meta.channel_name.clone().or_else(|| meta.channel_id.clone()), + effective_start: meta.scheduled_start, + effective_end: meta.scheduled_end, + visibility: Some(meta.visibility), + output_filename, + failure_reason, + task_id: None, + } + } + + /// Render the placeholder dictionary used by unit tests. + #[cfg(test)] + pub fn template_fields(&self) -> Vec<(String, String)> { + let mut fields: Vec<(String, String)> = Vec::new(); + if let Some(title) = &self.programme_title { + fields.push(("programme_title".into(), title.clone())); + } + if let Some(channel) = &self.channel { + fields.push(("channel".into(), channel.clone())); + } + if let Some(start) = self.effective_start { + fields.push(("effective_start".into(), start.to_string())); + } + if let Some(end) = self.effective_end { + fields.push(("effective_end".into(), end.to_string())); + } + if let Some(visibility) = self.visibility { + fields.push(("visibility".into(), visibility_wire(visibility).to_string())); + } + if let Some(filename) = &self.output_filename { + fields.push(("output_filename".into(), filename.clone())); + } + if let Some(reason) = &self.failure_reason { + fields.push(("failure_reason".into(), reason.clone())); + } + fields + } +} + +#[cfg(test)] +fn visibility_wire(v: RecordingVisibility) -> &'static str { + match v { + RecordingVisibility::Private => "private", + RecordingVisibility::Shared => "shared", + } +} + +/// Routing decision for global notification channels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RoutingDecision { + Deliver, + Suppress, +} + +/// Decide whether the event reaches the global channels. +pub fn route( + owner: &RecordingOwner, + visibility: RecordingVisibility, + is_admin_role: bool, +) -> RoutingDecision { + if matches!(visibility, RecordingVisibility::Shared) { + return RoutingDecision::Deliver; + } + match owner { + RecordingOwner::LegacyAdmin => RoutingDecision::Deliver, + RecordingOwner::User(_) => { + if is_admin_role { + // Administrator's own private recording. + RoutingDecision::Deliver + } else { + // Another regular user's private recording. + RoutingDecision::Suppress + } + } + } +} + +/// The kind to lifecycle-event mapping. +#[cfg(test)] +pub fn kind_for_event(event: LifecycleEvent) -> &'static str { + event.wire_name() +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::UserId; + + fn user(name: &str) -> UserId { UserId::from(name) } + + fn make_meta(visibility: RecordingVisibility, owner: RecordingOwner) -> RecordingMetadata { + RecordingMetadata { + owner, + visibility, + source: None, + program_start: Some(1_700_000_000), + program_end: Some(1_700_003_600), + scheduled_start: Some(1_700_000_000), + scheduled_end: Some(1_700_003_600), + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: Some("ch-1".into()), + channel_name: Some("Channel 1".into()), + program_title: Some("Programme".into()), + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: Some("users/web:alice/Programme_2023-11-14_20-00.ts".into()), + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: 0, + completed_at: None, + notification_markers: vec![], + deleting_previous_state: None, + } + } + + #[test] + fn wire_names_are_stable() { + assert_eq!(LifecycleEvent::Started.wire_name(), "recording_started"); + assert_eq!(LifecycleEvent::Completed.wire_name(), "recording_completed"); + assert_eq!(LifecycleEvent::Failed.wire_name(), "recording_failed"); + } + + #[test] + fn template_fields_carry_metadata() { + let meta = make_meta(RecordingVisibility::Shared, RecordingOwner::User(user("web:alice"))); + let payload = LifecyclePayload::from_metadata(&meta, None); + let fields = payload.template_fields(); + let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); + assert_eq!(get("programme_title").as_deref(), Some("Programme")); + assert_eq!(get("channel").as_deref(), Some("Channel 1")); + assert_eq!(get("visibility").as_deref(), Some("shared")); + assert_eq!(get("output_filename").as_deref(), Some("Programme_2023-11-14_20-00.ts")); + assert!(get("failure_reason").is_none()); + } + + #[test] + fn failure_reason_only_for_failed_event() { + let meta = make_meta(RecordingVisibility::Shared, RecordingOwner::User(user("web:alice"))); + let payload = LifecyclePayload::from_metadata(&meta, Some("encoder died".into())); + let fields = payload.template_fields(); + let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); + assert_eq!(get("failure_reason").as_deref(), Some("encoder died")); + } + + #[test] + fn legacy_admin_owner_always_delivers() { + let owner = RecordingOwner::LegacyAdmin; + assert_eq!(route(&owner, RecordingVisibility::Private, false), RoutingDecision::Deliver); + assert_eq!(route(&owner, RecordingVisibility::Private, true), RoutingDecision::Deliver); + } + + #[test] + fn user_owner_administrator_delivers() { + let owner = RecordingOwner::User(user("builtin:admin")); + assert_eq!(route(&owner, RecordingVisibility::Private, true), RoutingDecision::Deliver); + } + + #[test] + fn user_owner_non_administrator_private_suppresses() { + let owner = RecordingOwner::User(user("web:alice")); + assert_eq!(route(&owner, RecordingVisibility::Private, false), RoutingDecision::Suppress); + } + + #[test] + fn shared_visibility_always_delivers() { + let owner = RecordingOwner::User(user("web:alice")); + assert_eq!(route(&owner, RecordingVisibility::Shared, false), RoutingDecision::Deliver); + } + + #[test] + fn kind_for_event_is_stable() { + assert_eq!(kind_for_event(LifecycleEvent::Started), "recording_started"); + assert_eq!(kind_for_event(LifecycleEvent::Completed), "recording_completed"); + assert_eq!(kind_for_event(LifecycleEvent::Failed), "recording_failed"); + } +} diff --git a/backend/src/api/model/recording/recording_notification_adapter.rs b/backend/src/api/model/recording/recording_notification_adapter.rs new file mode 100644 index 000000000..325748cae --- /dev/null +++ b/backend/src/api/model/recording/recording_notification_adapter.rs @@ -0,0 +1,201 @@ +//! At-most-once recording notification adapter. +//! +//! This module owns the *decision* surface. The queue-mutation +//! boundary persists the marker; the messaging transport layer +//! performs the actual delivery. The adapter is the bridge +//! between them. + +use crate::model::RecordingLifecycleMessage; +use shared::model::{ + MsgKind, + recording::{NotificationMarker, NotificationMarkerKind, RecordingMetadata}, +}; + +use super::recording_notification::{LifecycleEvent, LifecyclePayload, RoutingDecision, route}; + +/// The dispatch decision the adapter returns. The caller +/// (queue-mutation boundary + Tokio runtime) is responsible for +/// executing the side effects. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DispatchDecision { + /// No marker present and routing permits delivery. The caller + /// must persist the marker and then enqueue the notification. + PersistAndDeliver { payload: LifecyclePayload, kind: NotificationMarkerKind, attempted_at: i64 }, + /// The marker is already present (startup, recovery, or + /// duplicate transition hook). No further action. + AlreadyDelivered { kind: NotificationMarkerKind }, + /// Routing suppressed the event (private, non-admin). The + /// caller records the suppression for observability but does + /// not persist a marker or deliver. + Suppressed { reason: &'static str }, +} + +/// Pure: decide what to do for a transition. The caller is the +/// queue-mutation boundary; this function never mutates +/// `metadata`. The `attempted_at` is the same UTC second the +/// caller will persist; the adapter does not read the clock. +pub fn decide( + metadata: &RecordingMetadata, + event: LifecycleEvent, + attempted_at: i64, + is_admin_role: bool, + failure_reason: Option, +) -> DispatchDecision { + let kind = match event { + LifecycleEvent::Started => NotificationMarkerKind::Started, + LifecycleEvent::Completed => NotificationMarkerKind::Completed, + LifecycleEvent::Failed => NotificationMarkerKind::Failed, + }; + if metadata.notification_markers.iter().any(|m| m.kind == kind) { + return DispatchDecision::AlreadyDelivered { kind }; + } + if route(&metadata.owner, metadata.visibility, is_admin_role) == RoutingDecision::Suppress { + return DispatchDecision::Suppressed { reason: "private non-admin owner" }; + } + let failure_reason = (event == LifecycleEvent::Failed).then_some(failure_reason).flatten(); + let payload = LifecyclePayload::from_metadata(metadata, failure_reason); + DispatchDecision::PersistAndDeliver { payload, kind, attempted_at } +} + +pub fn message_for(event: LifecycleEvent, payload: &LifecyclePayload) -> RecordingLifecycleMessage { + RecordingLifecycleMessage { + event: match event { + LifecycleEvent::Started => MsgKind::RecordingStarted, + LifecycleEvent::Completed => MsgKind::RecordingCompleted, + LifecycleEvent::Failed => MsgKind::RecordingFailed, + }, + programme_title: payload.programme_title.clone(), + channel: payload.channel.clone(), + effective_start: payload.effective_start, + effective_end: payload.effective_end, + visibility: payload.visibility.map(|visibility| match visibility { + shared::model::recording::RecordingVisibility::Private => "private".to_string(), + shared::model::recording::RecordingVisibility::Shared => "shared".to_string(), + }), + output_filename: payload.output_filename.clone(), + failure_reason: payload.failure_reason.clone(), + } +} + +/// Pure: build the marker the queue-mutation boundary persists. +/// Centralized so the caller's `mutate` closure has a single +/// shape to insert. +pub fn build_marker(kind: NotificationMarkerKind, attempted_at: i64) -> NotificationMarker { + NotificationMarker::new(kind, attempted_at) +} + +/// Detect duplicate transition hooks in unit tests. +#[cfg(test)] +pub fn is_duplicate_transition(markers: &[NotificationMarker], event: LifecycleEvent) -> bool { + let kind = match event { + LifecycleEvent::Started => NotificationMarkerKind::Started, + LifecycleEvent::Completed => NotificationMarkerKind::Completed, + LifecycleEvent::Failed => NotificationMarkerKind::Failed, + }; + markers.iter().any(|m| m.kind == kind) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::recording::{NotificationMarkerKind, RecordingMetadata, RecordingOwner, RecordingVisibility}; + use shared::model::UserId; + + fn user(name: &str) -> UserId { UserId::from(name) } + + fn make_meta(visibility: RecordingVisibility, owner: RecordingOwner) -> RecordingMetadata { + RecordingMetadata { + owner, + visibility, + source: None, + program_start: Some(1_700_000_000), + program_end: Some(1_700_003_600), + scheduled_start: Some(1_700_000_000), + scheduled_end: Some(1_700_003_600), + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: Some("ch-1".into()), + channel_name: Some("Channel 1".into()), + program_title: Some("Programme".into()), + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: Some("path/file.ts".into()), + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: 0, + completed_at: None, + notification_markers: vec![], + deleting_previous_state: None, + } + } + + #[test] + fn fresh_event_for_global_target_persists_and_delivers() { + let meta = make_meta(RecordingVisibility::Shared, RecordingOwner::User(user("web:alice"))); + let d = decide(&meta, LifecycleEvent::Started, 1_000, false, None); + assert!(matches!(d, DispatchDecision::PersistAndDeliver { kind: NotificationMarkerKind::Started, .. })); + } + + #[test] + fn marker_already_present_short_circuits() { + let mut meta = make_meta(RecordingVisibility::Shared, RecordingOwner::User(user("web:alice"))); + meta.notification_markers.push(NotificationMarker::new(NotificationMarkerKind::Started, 500)); + let d = decide(&meta, LifecycleEvent::Started, 1_000, false, None); + assert!(matches!(d, DispatchDecision::AlreadyDelivered { kind: NotificationMarkerKind::Started })); + } + + #[test] + fn private_non_admin_is_suppressed() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::User(user("web:alice"))); + let d = decide(&meta, LifecycleEvent::Completed, 1_000, false, None); + assert!(matches!(d, DispatchDecision::Suppressed { .. })); + } + + #[test] + fn administrator_own_private_is_delivered() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::User(user("builtin:admin"))); + let d = decide(&meta, LifecycleEvent::Started, 1_000, true, None); + assert!(matches!(d, DispatchDecision::PersistAndDeliver { .. })); + } + + #[test] + fn legacy_admin_owner_always_delivers() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::LegacyAdmin); + let d = decide(&meta, LifecycleEvent::Failed, 1_000, false, Some("recording failed".to_string())); + match d { + DispatchDecision::PersistAndDeliver { kind, attempted_at, .. } => { + assert_eq!(kind, NotificationMarkerKind::Failed); + assert_eq!(attempted_at, 1_000); + } + _ => panic!("expected PersistAndDeliver"), + } + } + + #[test] + fn failed_event_carries_failure_reason() { + let meta = make_meta(RecordingVisibility::Shared, RecordingOwner::User(user("web:alice"))); + let d = decide(&meta, LifecycleEvent::Failed, 1_000, false, Some("encoder died".to_string())); + match d { + DispatchDecision::PersistAndDeliver { payload, .. } => { + let fields = payload.template_fields(); + let get = |k: &str| fields.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone()); + assert_eq!(get("failure_reason").as_deref(), Some("encoder died")); + } + _ => panic!("expected PersistAndDeliver"), + } + } + + #[test] + fn duplicate_transition_detected() { + let markers = vec![NotificationMarker::new(NotificationMarkerKind::Started, 500)]; + assert!(is_duplicate_transition(&markers, LifecycleEvent::Started)); + assert!(!is_duplicate_transition(&markers, LifecycleEvent::Completed)); + } + + #[test] + fn build_marker_preserves_kind_and_timestamp() { + let m = build_marker(NotificationMarkerKind::Completed, 1_000); + assert_eq!(m.kind, NotificationMarkerKind::Completed); + assert_eq!(m.attempted_at, 1_000); + } +} diff --git a/backend/src/api/model/recording/recording_observability.rs b/backend/src/api/model/recording/recording_observability.rs new file mode 100644 index 000000000..1f20d3ac8 --- /dev/null +++ b/backend/src/api/model/recording/recording_observability.rs @@ -0,0 +1,260 @@ +//! Recording observability counters. +//! +//! - Add counters for recording +//! create / start / complete / fail / delete, persistence +//! failure, unsafe path rejection, retention cleanup, and +//! notification attempt / failure. +//! - Add queue revision and opaque task id to diagnostic logs +//! where useful. +//! - Do not label metrics with user IDs, titles, channels, +//! filenames, or rule IDs. +//! - Use structured failure categories rather than raw private +//! metadata. +//! - Add tests or review assertions for user-visible logs and +//! conflict responses. +//! +//! This module owns the counter shape and the +//! increment / snapshot helpers. The metric sink is the +//! caller's responsibility; the counters themselves are pure +//! atomic integers. + + +use std::sync::atomic::{AtomicU64, Ordering}; + +/// The recording counter set. Each variant maps to a single atomic +/// counter. The enum's wire name is the metric label. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Counter { + Create, + Start, + Complete, + Fail, + Delete, + PersistenceFailure, + UnsafePathRejection, + RetentionCleanup, + NotificationAttempt, + NotificationFailure, +} + +impl Counter { + pub fn wire_name(self) -> &'static str { + match self { + Self::Create => "recording_create_total", + Self::Start => "recording_start_total", + Self::Complete => "recording_complete_total", + Self::Fail => "recording_fail_total", + Self::Delete => "recording_delete_total", + Self::PersistenceFailure => "recording_persistence_failure_total", + Self::UnsafePathRejection => "recording_unsafe_path_rejection_total", + Self::RetentionCleanup => "recording_retention_cleanup_total", + Self::NotificationAttempt => "recording_notification_attempt_total", + Self::NotificationFailure => "recording_notification_failure_total", + } + } + + fn index(self) -> usize { + match self { + Self::Create => 0, + Self::Start => 1, + Self::Complete => 2, + Self::Fail => 3, + Self::Delete => 4, + Self::PersistenceFailure => 5, + Self::UnsafePathRejection => 6, + Self::RetentionCleanup => 7, + Self::NotificationAttempt => 8, + Self::NotificationFailure => 9, + } + } +} + +/// A snapshot of the counter set. The metric sink serializes +/// this as a flat list of `(name, value)` pairs. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CounterSnapshot { + pub create: u64, + pub start: u64, + pub complete: u64, + pub fail: u64, + pub delete: u64, + pub persistence_failure: u64, + pub unsafe_path_rejection: u64, + pub retention_cleanup: u64, + pub notification_attempt: u64, + pub notification_failure: u64, +} + +impl CounterSnapshot { + pub fn as_pairs(&self) -> Vec<(&'static str, u64)> { + vec![ + ("recording_create_total", self.create), + ("recording_start_total", self.start), + ("recording_complete_total", self.complete), + ("recording_fail_total", self.fail), + ("recording_delete_total", self.delete), + ("recording_persistence_failure_total", self.persistence_failure), + ("recording_unsafe_path_rejection_total", self.unsafe_path_rejection), + ("recording_retention_cleanup_total", self.retention_cleanup), + ("recording_notification_attempt_total", self.notification_attempt), + ("recording_notification_failure_total", self.notification_failure), + ] + } +} + +/// The atomic counter store. The metric sink increments +/// counters via `inc`; the rest of the codebase reads via +/// `snapshot`. +pub struct Counters { + inner: [AtomicU64; 10], +} + +impl Counters { + pub const fn new() -> Self { + Self { + inner: [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + ], + } + } + + pub fn inc(&self, counter: Counter) { + self.inner[counter.index()].fetch_add(1, Ordering::Relaxed); + } + + pub fn snapshot(&self) -> CounterSnapshot { + CounterSnapshot { + create: self.inner[0].load(Ordering::Relaxed), + start: self.inner[1].load(Ordering::Relaxed), + complete: self.inner[2].load(Ordering::Relaxed), + fail: self.inner[3].load(Ordering::Relaxed), + delete: self.inner[4].load(Ordering::Relaxed), + persistence_failure: self.inner[5].load(Ordering::Relaxed), + unsafe_path_rejection: self.inner[6].load(Ordering::Relaxed), + retention_cleanup: self.inner[7].load(Ordering::Relaxed), + notification_attempt: self.inner[8].load(Ordering::Relaxed), + notification_failure: self.inner[9].load(Ordering::Relaxed), + } + } +} + +impl Default for Counters { + fn default() -> Self { Self::new() } +} + +/// The structured failure category the log redaction uses +/// instead of raw private metadata. Wire-shape stable; never +/// carries user ids, titles, channels, filenames, or rule ids. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailureCategory { + InvalidSource, + PathTraversal, + SymlinkSwap, + StaleClaim, + ForgedVisibility, + PersistenceFailed, + QuotaExceeded, + InsufficientDisk, + SourceTampered, + ForeignPrivateAccess, + EventLeakage, +} + +impl FailureCategory { + pub fn wire(self) -> &'static str { + match self { + Self::InvalidSource => "recording_failure_invalid_source", + Self::PathTraversal => "recording_failure_path_traversal", + Self::SymlinkSwap => "recording_failure_symlink_swap", + Self::StaleClaim => "recording_failure_stale_claim", + Self::ForgedVisibility => "recording_failure_forged_visibility", + Self::PersistenceFailed => "recording_failure_persistence", + Self::QuotaExceeded => "recording_failure_quota", + Self::InsufficientDisk => "recording_failure_disk", + Self::SourceTampered => "recording_failure_source_tampered", + Self::ForeignPrivateAccess => "recording_failure_foreign_private", + Self::EventLeakage => "recording_failure_event_leakage", + } + } +} + +/// A redacted log entry. Carries the queue revision and opaque +/// task id, never the private fields. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RedactedLog { + pub queue_revision: Option, + pub opaque_task_id: Option, + pub category: Option, + pub note: Option<&'static str>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn counter_wire_names_are_stable() { + assert_eq!(Counter::Create.wire_name(), "recording_create_total"); + assert_eq!(Counter::Start.wire_name(), "recording_start_total"); + assert_eq!(Counter::Complete.wire_name(), "recording_complete_total"); + assert_eq!(Counter::Fail.wire_name(), "recording_fail_total"); + assert_eq!(Counter::Delete.wire_name(), "recording_delete_total"); + assert_eq!(Counter::PersistenceFailure.wire_name(), "recording_persistence_failure_total"); + assert_eq!( + Counter::UnsafePathRejection.wire_name(), + "recording_unsafe_path_rejection_total" + ); + assert_eq!(Counter::RetentionCleanup.wire_name(), "recording_retention_cleanup_total"); + assert_eq!( + Counter::NotificationAttempt.wire_name(), + "recording_notification_attempt_total" + ); + assert_eq!( + Counter::NotificationFailure.wire_name(), + "recording_notification_failure_total" + ); + } + + #[test] + fn counters_increment() { + let c = Counters::new(); + c.inc(Counter::Create); + c.inc(Counter::Create); + c.inc(Counter::Start); + let s = c.snapshot(); + assert_eq!(s.create, 2); + assert_eq!(s.start, 1); + assert_eq!(s.complete, 0); + } + + #[test] + fn snapshot_pairs_include_all_counters() { + let c = Counters::new(); + let s = c.snapshot(); + let pairs = s.as_pairs(); + assert_eq!(pairs.len(), 10); + } + + #[test] + fn failure_category_wire_names_are_stable() { + assert_eq!(FailureCategory::PathTraversal.wire(), "recording_failure_path_traversal"); + assert_eq!(FailureCategory::EventLeakage.wire(), "recording_failure_event_leakage"); + } + + #[test] + fn redacted_log_default_is_empty() { + let l = RedactedLog::default(); + assert!(l.queue_revision.is_none()); + assert!(l.opaque_task_id.is_none()); + assert!(l.category.is_none()); + } +} diff --git a/backend/src/api/model/recording/recording_occurrence.rs b/backend/src/api/model/recording/recording_occurrence.rs new file mode 100644 index 000000000..acdd449ed --- /dev/null +++ b/backend/src/api/model/recording/recording_occurrence.rs @@ -0,0 +1,545 @@ +//! Recording occurrence keys and rule matching. +//! +//! An occurrence key is a stable, deterministic identifier for a +//! particular (rule, time slot, programme) tuple. The scheduler +//! computes it; tombstones and tasks both store it. The key is the +//! only field that needs to match between a tombstone and a future +//! scheduler pass for the suppression to take effect. +//! +//! Layout: versioned, length-prefixed, separated by a unit-separator +//! control character. The version prefix lets the layout change +//! without breaking the tombstone retention horizon — old +//! tombstones simply fail to match future occurrences and get +//! pruned. +//! +//! The matching rules: +//! - `NewEpisode`: stable series id first; normalized title as a +//! fallback. Exclude explicit `Repeat`. Treat `Unknown` as new. +//! - `WeeklyTimeslot`: local wall-clock weekday + start time + +//! duration in the configured IANA timezone. + + +use chrono::{DateTime, Datelike, Duration, NaiveDateTime, NaiveTime, TimeZone, Utc}; +use chrono_tz::Tz; +use shared::model::recording_rule::{RuleBody, RuleSource, RuleVisibility, RecordingRule}; +use shared::model::UserId; + +const KEY_VERSION: &str = "v1"; +const FIELD_SEP: char = '\u{1f}'; + +/// Canonical occurrence key. Pure: the same inputs always produce +/// the same bytes. +pub fn occurrence_key( + rule_id: &str, + source: &RuleSource, + channel_key: &str, + programme_start_utc_secs: i64, + episode_key: &str, +) -> String { + let mut out = String::new(); + out.push_str(KEY_VERSION); + out.push(FIELD_SEP); + push_field(&mut out, rule_id); + out.push(FIELD_SEP); + push_field(&mut out, &format!("{}/{}/{}", source.target_id, source.virtual_id, source.input_name)); + out.push(FIELD_SEP); + push_field(&mut out, channel_key); + out.push(FIELD_SEP); + push_field(&mut out, &programme_start_utc_secs.to_string()); + out.push(FIELD_SEP); + push_field(&mut out, episode_key); + out +} + +fn push_field(out: &mut String, value: &str) { + // Length-prefix each field so a separator inside the value + // cannot collide with the field separator. + out.push_str(&value.chars().count().to_string()); + out.push(':'); + out.push_str(value); +} + +/// Channel key: prefer `channel_id`, fall back to the normalized +/// channel name. The normalized form is lowercase with +/// whitespace collapsed; the fallback is the closest stable match +/// the EPG payload provides. +pub fn channel_key(channel_id: Option<&str>, channel_name: Option<&str>) -> String { + if let Some(id) = channel_id.filter(|s| !s.is_empty()) { + return id.to_string(); + } + if let Some(name) = channel_name { + return normalize_channel_name(name); + } + String::new() +} + +fn normalize_channel_name(name: &str) -> String { + let lower = name.to_lowercase(); + let mut out = String::with_capacity(lower.len()); + let mut last_space = true; + for c in lower.chars() { + if c.is_whitespace() { + if !last_space { + out.push(' '); + } + last_space = true; + } else { + out.push(c); + last_space = false; + } + } + out.trim().to_string() +} + +/// Episode key: pick the strongest available identity (stable +/// series id first, normalized title as a fallback). Empty inputs +/// are skipped. +pub fn episode_key( + episode_id: Option<&str>, + programme_id: Option<&str>, + series_id: Option<&str>, + season: Option, + episode: Option, + title: Option<&str>, +) -> String { + if let Some(e) = episode_id.filter(|s| !s.is_empty()) { + return format!("e:{e}"); + } + if let Some(p) = programme_id.filter(|s| !s.is_empty()) { + return format!("p:{p}"); + } + if let Some(s) = series_id.filter(|s| !s.is_empty()) { + let season = season.map(|n| n.to_string()).unwrap_or_default(); + let episode = episode.map(|n| n.to_string()).unwrap_or_default(); + return format!("s:{s}:{season}:{episode}"); + } + if let Some(t) = title.filter(|s| !s.is_empty()) { + return format!("t:{}", normalize_title(t)); + } + String::new() +} + +fn normalize_title(title: &str) -> String { + let lower = title.to_lowercase(); + let mut out = String::with_capacity(lower.len()); + let mut last_space = true; + for c in lower.chars() { + if c.is_whitespace() { + if !last_space { + out.push(' '); + } + last_space = true; + } else { + out.push(c); + last_space = false; + } + } + out.trim().to_string() +} + +/// `NewEpisode` match result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NewEpisodeMatch { + /// `Repeat` airing status; the new-episode rule excludes it + /// from recording. + Excluded, + /// No series id or matching title; the candidate does not match + /// this rule. + NoMatch, + /// Series id matched (or the title fallback matched); the + /// candidate is a new episode for this rule. + NewEpisode, +} + +/// Does a candidate programme match the rule's `NewEpisode` body? +/// Matching order: stable series id first, normalized title as a +/// fallback. `Unknown` airing is treated as new; `Repeat` is +/// excluded; `New` is always a match. +pub fn matches_new_episode( + rule_body: &RuleBody, + candidate_series_id: Option<&str>, + candidate_title: Option<&str>, + airing_is_repeat: bool, +) -> NewEpisodeMatch { + let RuleBody::NewEpisode { series_id, title_pattern, exclude_repeat } = rule_body else { + return NewEpisodeMatch::NoMatch; + }; + if airing_is_repeat && *exclude_repeat { + return NewEpisodeMatch::Excluded; + } + if let Some(rule_series) = series_id.as_deref().filter(|s| !s.is_empty()) { + if candidate_series_id.is_some_and(|s| s == rule_series) { + return NewEpisodeMatch::NewEpisode; + } + return NewEpisodeMatch::NoMatch; + } + if let Some(rule_title) = title_pattern.as_deref().filter(|s| !s.is_empty()) { + if let Some(candidate) = candidate_title { + if normalize_title(candidate) == normalize_title(rule_title) { + return NewEpisodeMatch::NewEpisode; + } + } + } + NewEpisodeMatch::NoMatch +} + +/// Weekly match: a candidate programme matches when its UTC start +/// aligns with the rule's local weekday + start time (modulo +/// padding). This is a coarse pre-filter; the runtime re-validates +/// against the actual scheduled interval. +pub fn matches_weekly(rule_body: &RuleBody, candidate_start_utc: i64) -> bool { + let RuleBody::WeeklyTimeslot { weekday, local_start_time, duration_secs, timezone } = rule_body else { + return false; + }; + let Ok(tz) = timezone.parse::() else { return false; }; + let Some(candidate_local) = Utc.timestamp_opt(candidate_start_utc, 0).single() else { + return false; + }; + let candidate_local = candidate_local.with_timezone(&tz); + if candidate_local.weekday().num_days_from_monday() + 1 != u32::from(*weekday) { + return false; + } + let Some((h, m)) = parse_hh_mm(local_start_time) else { + return false; + }; + let Some(expected_time) = NaiveTime::from_hms_opt(h, m, 0) else { + return false; + }; + let expected_local = NaiveDateTime::new(candidate_local.date_naive(), expected_time); + // Match within the programme duration window: the candidate's + // start must be at or after the slot's start, and not later + // than slot_end (start + duration). The runtime decides the + // exact tolerance. + let slot_start_utc = tz + .from_local_datetime(&expected_local) + .earliest() + .map(|dt| dt.with_timezone(&Utc).timestamp()); + let Ok(duration_secs) = i64::try_from(*duration_secs) else { return false }; + slot_start_utc + .and_then(|start| start.checked_add(duration_secs).map(|end| (start, end))) + .is_some_and(|(start, end)| candidate_start_utc >= start && candidate_start_utc < end) +} + +/// Resolve the next weekly occurrence at or after `now` in the +/// rule's timezone. DST handling: +/// - For an ambiguous local time (fall-back DST), pick the earlier +/// instant. +/// - For a nonexistent local time (spring-forward DST), advance +/// to the first valid instant at or after the requested time. +pub fn next_weekly_occurrence(rule_body: &RuleBody, now: DateTime) -> Option { + let RuleBody::WeeklyTimeslot { weekday, local_start_time, timezone, .. } = rule_body else { + return None; + }; + let tz: Tz = timezone.parse().ok()?; + let (h, m) = parse_hh_mm(local_start_time)?; + let now_local = now.with_timezone(&tz); + // Search up to 8 days ahead. DST gaps are at most 1 hour; 8 + // days covers the worst case. + for offset in 0..8 { + let candidate_date = now_local.date_naive() + Duration::days(offset); + if candidate_date.weekday().num_days_from_monday() + 1 != u32::from(*weekday) { + continue; + } + let local = NaiveDateTime::new(candidate_date, NaiveTime::from_hms_opt(h, m, 0)?); + match tz.from_local_datetime(&local) { + chrono::LocalResult::Single(dt) => { + let utc = dt.with_timezone(&Utc); + if utc >= now { + return Some(utc.timestamp()); + } + } + chrono::LocalResult::Ambiguous(earliest, _) => { + let utc = earliest.with_timezone(&Utc); + if utc >= now { + return Some(utc.timestamp()); + } + } + chrono::LocalResult::None => { + // Spring-forward gap. Advance 1 hour at a time until + // we find a valid instant; max 4 hours of gap is + // safe in any IANA timezone. + for hour_offset in 1..=4 { + let advanced = local + Duration::hours(hour_offset); + if let Some(dt) = tz.from_local_datetime(&advanced).earliest() { + let utc = dt.with_timezone(&Utc); + if utc >= now { + return Some(utc.timestamp()); + } + } + } + } + } + } + None +} + +fn parse_hh_mm(s: &str) -> Option<(u32, u32)> { + let mut parts = s.split(':'); + let h = parts.next()?.parse::().ok()?; + let m = parts.next()?.parse::().ok()?; + if parts.next().is_some() || h > 23 || m > 59 { + return None; + } + Some((h, m)) +} + +/// Build the channel key for a candidate programme. +pub fn candidate_channel_key( + channel_id: Option<&str>, + channel_name: Option<&str>, +) -> String { + channel_key(channel_id, channel_name) +} + +/// Build the episode key for a candidate programme. +pub fn candidate_episode_key( + episode_id: Option<&str>, + programme_id: Option<&str>, + series_id: Option<&str>, + season: Option, + episode: Option, + title: Option<&str>, +) -> String { + episode_key(episode_id, programme_id, series_id, season, episode, title) +} + +/// Build a `RecordingRule` with sensible defaults for tests. +pub fn build_rule( + id: &str, + owner: UserId, + body: RuleBody, + source: RuleSource, +) -> RecordingRule { + RecordingRule { + id: id.to_string(), + owner_id: owner, + visibility: RuleVisibility::Private, + enabled: true, + source, + channel_id: None, + body, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 0, + updated_at: 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + use shared::model::recording_rule::RuleSource; + use shared::model::UserId; + + fn source() -> RuleSource { RuleSource::new("tgt-1", "virt-1", "input-1") } + fn user() -> UserId { UserId::from("web:alice") } + + #[test] + fn occurrence_key_is_deterministic_and_version_prefixed() { + let k1 = occurrence_key("r1", &source(), "ch-1", 1_700_000_000, "e:ep-1"); + let k2 = occurrence_key("r1", &source(), "ch-1", 1_700_000_000, "e:ep-1"); + assert_eq!(k1, k2); + assert!(k1.starts_with("v1\u{1f}")); + } + + #[test] + fn occurrence_key_changes_when_inputs_change() { + let k1 = occurrence_key("r1", &source(), "ch-1", 1_700_000_000, "e:ep-1"); + let k2 = occurrence_key("r2", &source(), "ch-1", 1_700_000_000, "e:ep-1"); + assert_ne!(k1, k2); + let k3 = occurrence_key("r1", &source(), "ch-2", 1_700_000_000, "e:ep-1"); + assert_ne!(k1, k3); + let k4 = occurrence_key("r1", &source(), "ch-1", 1_700_000_001, "e:ep-1"); + assert_ne!(k1, k4); + } + + #[test] + fn occurrence_key_does_not_collide_on_separator() { + // The internal separator is `\u{1f}`. A field value that + // contains the separator must not be confused with the + // field boundary; the length prefix prevents this. + let k = occurrence_key("r1", &source(), "a\u{1f}b", 0, ""); + let k2 = occurrence_key("r1", &source(), "a", 0, "b"); + assert_ne!(k, k2); + } + + #[test] + fn channel_key_prefers_id() { + assert_eq!(channel_key(Some("ch-1"), Some("Channel 1")), "ch-1"); + assert_eq!(channel_key(Some("ch-1"), None), "ch-1"); + } + + #[test] + fn channel_key_normalizes_name_fallback() { + assert_eq!(channel_key(None, Some("Channel ONE")), "channel one"); + } + + #[test] + fn channel_key_returns_empty_when_no_inputs() { + assert_eq!(channel_key(None, None), ""); + } + + #[test] + fn episode_key_prefers_episode_id() { + assert_eq!(episode_key(Some("e1"), Some("p1"), Some("s1"), Some(1), Some(2), Some("Title")), "e:e1"); + } + + #[test] + fn episode_key_falls_back_to_programme_id() { + assert_eq!(episode_key(None, Some("p1"), Some("s1"), Some(1), Some(2), Some("Title")), "p:p1"); + } + + #[test] + fn episode_key_falls_back_to_series_with_season_episode() { + assert_eq!(episode_key(None, None, Some("s1"), Some(3), Some(7), Some("Title")), "s:s1:3:7"); + } + + #[test] + fn episode_key_falls_back_to_normalized_title() { + assert_eq!(episode_key(None, None, None, None, None, Some("Title One")), "t:title one"); + } + + #[test] + fn matches_new_episode_with_series_id() { + let body = RuleBody::NewEpisode { + series_id: Some("series-1".into()), + title_pattern: None, + exclude_repeat: true, + }; + assert_eq!(matches_new_episode(&body, Some("series-1"), Some("Other"), false), NewEpisodeMatch::NewEpisode); + assert_eq!(matches_new_episode(&body, Some("series-2"), Some("Other"), false), NewEpisodeMatch::NoMatch); + } + + #[test] + fn matches_new_episode_with_title_fallback() { + let body = RuleBody::NewEpisode { + series_id: None, + title_pattern: Some("My Show".into()), + exclude_repeat: true, + }; + assert_eq!(matches_new_episode(&body, None, Some("My Show"), false), NewEpisodeMatch::NewEpisode); + assert_eq!(matches_new_episode(&body, None, Some("Other"), false), NewEpisodeMatch::NoMatch); + } + + #[test] + fn matches_new_episode_excludes_repeat_when_configured() { + let body = RuleBody::NewEpisode { + series_id: Some("series-1".into()), + title_pattern: None, + exclude_repeat: true, + }; + assert_eq!(matches_new_episode(&body, Some("series-1"), Some("Other"), true), NewEpisodeMatch::Excluded); + } + + #[test] + fn matches_new_episode_includes_repeat_when_not_configured() { + let body = RuleBody::NewEpisode { + series_id: Some("series-1".into()), + title_pattern: None, + exclude_repeat: false, + }; + assert_eq!(matches_new_episode(&body, Some("series-1"), Some("Other"), true), NewEpisodeMatch::NewEpisode); + } + + #[test] + fn matches_new_episode_unknown_airing_is_new() { + let body = RuleBody::NewEpisode { + series_id: Some("series-1".into()), + title_pattern: None, + exclude_repeat: true, + }; + // `airing_is_repeat` is false (Unknown / New), so the + // candidate is treated as new. + assert_eq!(matches_new_episode(&body, Some("series-1"), Some("Other"), false), NewEpisodeMatch::NewEpisode); + } + + #[test] + fn matches_weekly_picks_correct_day() { + let body = RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".into(), + duration_secs: 1800, + timezone: "UTC".into(), + }; + // 2023-11-13 is a Monday in UTC. 20:00 UTC = 1_699_905_600. + let monday_8pm = 1_699_905_600; + assert!(matches_weekly(&body, monday_8pm)); + let tuesday_8pm = monday_8pm + 86_400; + assert!(!matches_weekly(&body, tuesday_8pm)); + } + + #[test] + fn matches_weekly_uses_timezone() { + // Berlin is UTC+1 in winter. 20:00 Berlin on Monday is + // 19:00 UTC. + let body = RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".into(), + duration_secs: 1800, + timezone: "Europe/Berlin".into(), + }; + // 2023-11-13 19:00 UTC = 1_699_902_000. + let monday_7pm_utc = 1_699_902_000; + assert!(matches_weekly(&body, monday_7pm_utc)); + } + + #[test] + fn next_weekly_occurrence_picks_first_matching_day() { + let body = RuleBody::WeeklyTimeslot { + weekday: 7, // Sunday + local_start_time: "20:00".into(), + duration_secs: 1800, + timezone: "UTC".into(), + }; + // Start at 2023-11-13 (Monday) 00:00 UTC. + let now = Utc.timestamp_opt(1_699_833_600, 0).unwrap(); + let next = next_weekly_occurrence(&body, now).expect("next occurrence"); + let next_dt = Utc.timestamp_opt(next, 0).unwrap(); + assert_eq!(next_dt.weekday(), chrono::Weekday::Sun); + } + + #[test] + fn next_weekly_occurrence_handles_dst_fall_back() { + // Berlin DST ends on 2023-10-29 03:00. 02:30 is + // ambiguous; we pick the earlier (00:30 UTC) instant. + let body = RuleBody::WeeklyTimeslot { + weekday: 7, + local_start_time: "02:30".into(), + duration_secs: 1800, + timezone: "Europe/Berlin".into(), + }; + // 2023-10-22 12:00 UTC = 14:00 Berlin, the Sunday before + // the DST change. + let now = Utc.timestamp_opt(1_697_976_000, 0).unwrap(); + let next = next_weekly_occurrence(&body, now).expect("next occurrence"); + let dt = Utc.timestamp_opt(next, 0).unwrap().with_timezone(&chrono_tz::Europe::Berlin); + assert_eq!(dt.weekday(), chrono::Weekday::Sun); + // The local date must be the next Sunday (2023-10-29). We + // do not assert a fixed UTC second here — chrono_tz's + // ambiguous-instant policy has been stable for years but + // the exact second can shift if the dependency upgrades. + let local_date = dt.date_naive(); + assert_eq!(local_date, NaiveDate::from_ymd_opt(2023, 10, 29).unwrap()); + // The local wall clock should be 02:30 (within a few + // hours of DST resolution). + let naive = NaiveDateTime::new(local_date, NaiveTime::from_hms_opt(2, 30, 0).unwrap()); + let resolved = chrono_tz::Europe::Berlin.from_local_datetime(&naive); + assert!(matches!(resolved, chrono::LocalResult::Ambiguous(_, _) | chrono::LocalResult::Single(_))); + } + + #[test] + fn build_rule_uses_defaults() { + let body = RuleBody::NewEpisode { + series_id: Some("s".into()), + title_pattern: None, + exclude_repeat: true, + }; + let r = build_rule("r1", user(), body.clone(), source()); + assert_eq!(r.id, "r1"); + assert_eq!(r.body, body); + assert!(r.enabled); + assert_eq!(r.pre_roll_secs, 0); + assert_eq!(r.post_roll_secs, 0); + } +} diff --git a/backend/src/api/model/recording/recording_quota.rs b/backend/src/api/model/recording/recording_quota.rs new file mode 100644 index 000000000..dd31ee47a --- /dev/null +++ b/backend/src/api/model/recording/recording_quota.rs @@ -0,0 +1,656 @@ +//! Conservative quota ledger. +//! +//! Quota usage is **derived** from the committed task metadata +//! inside the queue — no second persisted quota database. Each +//! task contributes a charge based on its state. +//! Two pools: per-user private (keyed by immutable `UserId`) and +//! one shared pool. +//! +//! Regular users see their own private totals plus coarse shared +//! availability. Administrators see shared totals without automatic +//! per-user private detail. +//! +//! Admission goes through `would_exceed`: a proposed `delta` for +//! a given pool is compared against the configured limit, and +//! the outcome is one of `Ok`, `OverLimit { ... }`, or `Unlimited`. +//! The recording service calls it under the queue mutation boundary +//! before persisting a new task. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use shared::model::recording::RecordingMetadata; +use shared::model::UserId; + +use crate::api::model::download::{DownloadState, PersistedFileDownload}; +use crate::api::model::FileDownload; + +/// Pool a task belongs to. Private is keyed by the immutable +/// `UserId`; shared is the single shared pool. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum QuotaPool { + Private(UserId), + Shared, +} + +/// Result of asking "if I add `delta` bytes to this pool, would I +/// exceed the configured limit?". Admission prevents further +/// admission when over limit; we surface the +/// `OverLimit { limit, used, would_be }` details so the HTTP layer +/// can return a stable `recording_quota_exceeded` code. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AdmissionOutcome { + Ok { used_after: u64, limit: u64 }, + OverLimit { used: u64, limit: u64, would_be: u64 }, + /// `limit` is `None` for this pool. + Unlimited { used_after: u64 }, +} + +/// Effective per-pool totals. The integer is the total charged +/// bytes for every task in that pool. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct QuotaTotals { + pub private: HashMap, + pub shared: u64, +} + +/// Configured limits, derived from `RecordingQuotaConfig`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[allow(clippy::struct_field_names)] +pub struct QuotaLimits { + pub default_private_bytes: Option, + pub per_user_bytes: HashMap, + pub shared_bytes: Option, +} + +/// Effective limit for a given pool. `None` means unlimited. +pub fn limit_for_pool(pool: &QuotaPool, limits: &QuotaLimits) -> Option { + match pool { + QuotaPool::Private(uid) => limits + .per_user_bytes + .get(uid) + .copied() + .or(limits.default_private_bytes), + QuotaPool::Shared => limits.shared_bytes, + } +} + +/// Admission check: would adding `delta` bytes to `pool` exceed +/// the configured limit? `used` is the pool's current total from +/// `compute_totals`. +pub fn would_exceed( + pool: &QuotaPool, + used: u64, + delta: u64, + limits: &QuotaLimits, +) -> AdmissionOutcome { + let Some(limit) = limit_for_pool(pool, limits) else { + return AdmissionOutcome::Unlimited { used_after: used.saturating_add(delta) }; + }; + let would_be = used.saturating_add(delta); + if would_be > limit { + AdmissionOutcome::OverLimit { used, limit, would_be } + } else { + AdmissionOutcome::Ok { used_after: would_be, limit } + } +} + +/// Estimate the reservation for a future recording. +/// +/// - If `bitrate_bytes_per_sec > 0`, use `remaining_secs × bitrate`. +/// - Otherwise, use `remaining_secs × fallback_bytes_per_minute / 60` +/// and return `UnknownBitrate`. +pub fn estimate_reservation( + remaining_secs: u64, + bitrate_bytes_per_sec: u64, + fallback_bytes_per_minute: u64, +) -> (u64, ReservationWarning) { + if bitrate_bytes_per_sec > 0 { + let bytes = remaining_secs.saturating_mul(bitrate_bytes_per_sec); + (bytes, ReservationWarning::None) + } else { + // Round up to the next whole minute to avoid under-reserving. + let minutes = remaining_secs.div_ceil(60).max(1); + let bytes = minutes.saturating_mul(fallback_bytes_per_minute); + (bytes, ReservationWarning::UnknownBitrate) + } +} + +/// Why the caller should emit a warning. The HTTP layer maps +/// `UnknownBitrate` to the `recording_unknown_bitrate` wire code. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReservationWarning { + None, + UnknownBitrate, +} + +/// DTO returned to a regular user. Includes the user's own private +/// totals and a coarse shared availability summary — never other +/// users' private totals. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct QuotaDto { + pub private: PrivateQuotaDto, + pub shared: SharedAvailabilityDto, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrivateQuotaDto { + pub user_id: UserId, + pub measured_bytes: u64, + pub reserved_bytes: u64, + pub limit_bytes: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SharedAvailabilityDto { + pub used_bytes: u64, + pub limit_bytes: Option, +} + +/// Read-only view of a recording task for quota computation. +/// Lets the pure helpers be unit-tested without a real +/// `FileDownload` (which has many unrelated fields and a required +/// `reqwest::Url`). +pub trait QuotaRecordingTaskView { + fn state(&self) -> &DownloadState; + fn recording(&self) -> Option<&RecordingMetadata>; + /// Stable task identifier. Returns `""` for views that do + /// not expose one; callers (e.g. retention) require a + /// non-empty uuid to be useful. + fn uuid(&self) -> &str; +} + +impl QuotaRecordingTaskView for FileDownload { + fn state(&self) -> &DownloadState { + &self.state + } + fn recording(&self) -> Option<&RecordingMetadata> { + self.recording.as_ref() + } + fn uuid(&self) -> &str { + &self.uuid + } +} + +impl QuotaRecordingTaskView for PersistedFileDownload { + fn state(&self) -> &DownloadState { + &self.state + } + fn recording(&self) -> Option<&RecordingMetadata> { + self.recording.as_ref() + } + fn uuid(&self) -> &str { + &self.uuid + } +} + +/// `charge_for_task` is the public surface that walks a real +/// `FileDownload`. The shape-based test below covers the same +/// state→charge logic; the `charge_for_task` wrapper is a +/// trivial match on `recording.is_none()` and `charge_for_state` +/// so it is not exercised separately here. +pub fn charge_for_task(task: &V) -> u64 { + match task.recording() { + None => 0, + Some(meta) => charge_for_state(task.state(), meta), + } +} + +/// Pure state-driven charge. Kept separate from `charge_for_task` +/// so it can be unit-tested without a full `FileDownload`. +/// +pub fn charge_for_state(state: &DownloadState, meta: &RecordingMetadata) -> u64 { + match state { + DownloadState::Scheduled + | DownloadState::Queued + | DownloadState::WaitingForCapacity + | DownloadState::RetryWaiting + | DownloadState::Paused => meta.reserved_bytes, + DownloadState::Downloading => meta.reserved_bytes.max(meta.measured_bytes), + DownloadState::Completed => meta.measured_bytes, + DownloadState::Failed | DownloadState::Cancelled => { + meta.measured_bytes + } + } +} + +/// Pool the given task belongs to. Returns `None` for non-recording +/// tasks (so generic downloads are not charged). +pub fn quota_pool_for_task(task: &V) -> Option { + let meta = task.recording()?; + Some(match (&meta.visibility, &meta.owner) { + (shared::model::recording::RecordingVisibility::Shared, _) + | (_, shared::model::recording::RecordingOwner::LegacyAdmin) => QuotaPool::Shared, + (_, shared::model::recording::RecordingOwner::User(uid)) => QuotaPool::Private(uid.clone()), + }) +} + +/// Build a `QuotaLedger` from a set of tasks. `tasks` should be the +/// full set of recording tasks in the queue; the ledger sums each +/// task's per-state charge into the right pool. +pub fn compute_totals(tasks: &[V]) -> QuotaTotals { + let mut totals = QuotaTotals::default(); + for task in tasks { + let Some(pool) = quota_pool_for_task(task) else { + continue; + }; + let charge = charge_for_task(task); + match pool { + QuotaPool::Private(uid) => *totals.private.entry(uid).or_insert(0) += charge, + QuotaPool::Shared => totals.shared += charge, + } + } + totals +} + +/// Sum the charge for a single pool over borrowed tasks. +/// +/// `compute_totals` allocates a `HashMap` covering every +/// pool in the queue; admission checks read exactly one entry out of it +/// and run inside the queue mutation boundary, so the map and the +/// clones it implies are pure waste there. This is the fast path: one +/// pass, no allocation, borrowed input. +pub fn used_bytes_in_pool<'a, V, I>(tasks: I, pool: &QuotaPool) -> u64 +where + V: QuotaRecordingTaskView + 'a, + I: IntoIterator, +{ + let mut total = 0u64; + for task in tasks { + let Some(task_pool) = quota_pool_for_task(task) else { + continue; + }; + if &task_pool == pool { + total = total.saturating_add(charge_for_task(task)); + } + } + total +} + +/// Build the regular-user DTO. `subject_id` is the user asking. +/// Other users' private totals are never included. +pub fn regular_user_dto( + subject_id: &UserId, + totals: &QuotaTotals, + limits: &QuotaLimits, + tasks: &[V], +) -> QuotaDto { + let (measured, reserved) = split_measured_reserved_for_user_from_tasks(subject_id, tasks); + let limit = limit_for_pool(&QuotaPool::Private(subject_id.clone()), limits); + QuotaDto { + private: PrivateQuotaDto { + user_id: subject_id.clone(), + measured_bytes: measured, + reserved_bytes: reserved, + limit_bytes: limit, + }, + shared: SharedAvailabilityDto { + used_bytes: totals.shared, + limit_bytes: limits.shared_bytes, + }, + } +} + +/// Split a user's private total into measured vs reserved by +/// walking the tasks. Used by `recording_service` to produce the +/// DTO without storing a second database. +pub fn split_measured_reserved_for_user_from_tasks( + subject_id: &UserId, + tasks: &[V], +) -> (u64, u64) { + let mut measured = 0u64; + let mut reserved = 0u64; + for task in tasks { + let Some(meta) = task.recording() else { + continue; + }; + let is_user = meta.visibility == shared::model::recording::RecordingVisibility::Private && matches!( + &meta.owner, + shared::model::recording::RecordingOwner::User(uid) if uid == subject_id + ); + if !is_user { + continue; + } + let charge = charge_for_task(task); + // The reservation is the part of the charge that comes + // from `reserved_bytes`; the measured part is everything + // over that, capped at the total charge. + let r = meta.reserved_bytes; + let m = charge.saturating_sub(r); + reserved = reserved.saturating_add(r); + measured = measured.saturating_add(m); + } + (measured, reserved) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::recording::{ + RecordingMetadata, RecordingOwner, RecordingSource, RecordingVisibility, + }; + + fn make_meta(owner: RecordingOwner, reserved: u64, measured: u64) -> RecordingMetadata { + RecordingMetadata { + owner, + visibility: RecordingVisibility::Private, + source: Some(RecordingSource::new("t1", "v1", "in1")), + program_start: None, + program_end: None, + scheduled_start: None, + scheduled_end: None, + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: None, + channel_name: None, + program_title: None, + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: None, + partial_relative_path: None, + reserved_bytes: reserved, + measured_bytes: measured, + completed_at: None, + notification_markers: Vec::new(), + deleting_previous_state: None, + } + } + + // Lightweight stand-in for `FileDownload` so tests of the + // pure `charge_for_state` and pool-resolution helpers don't + // need the full HTTP/URL machinery. The real + // `charge_for_task` reads only `state` and `recording`; the + // `TaskShape` mirrors that. + struct TaskShape { + state: DownloadState, + recording: Option, + } + + impl QuotaRecordingTaskView for TaskShape { + fn state(&self) -> &DownloadState { + &self.state + } + fn recording(&self) -> Option<&RecordingMetadata> { + self.recording.as_ref() + } + fn uuid(&self) -> &'static str { + // The shape fixture has no uuid field; tests that + // depend on the uuid path (retention) use a richer + // fixture in their own module. + "" + } + } + + fn task( + owner: RecordingOwner, + state: DownloadState, + reserved: u64, + measured: u64, + ) -> TaskShape { + TaskShape { + state, + recording: Some(make_meta(owner, reserved, measured)), + } + } + + // Mirror the `charge_for_task` body against `TaskShape` so the + // tests can exercise the function without a real FileDownload. + fn charge_task_shape(t: &TaskShape) -> u64 { + charge_for_task(t) + } + + fn pool_for_shape(t: &TaskShape) -> Option { + quota_pool_for_task(t) + } + + #[test] + fn charge_scheduled_is_reservation() { + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Scheduled, + 1000, + 0, + ); + assert_eq!(charge_task_shape(&t), 1000); + } + + #[test] + fn charge_queued_is_reservation() { + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Queued, + 2000, + 0, + ); + assert_eq!(charge_task_shape(&t), 2000); + } + + #[test] + fn charge_waiting_is_reservation() { + for state in &[ + DownloadState::WaitingForCapacity, + DownloadState::RetryWaiting, + DownloadState::Paused, + ] { + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + state.clone(), + 500, + 0, + ); + assert_eq!(charge_task_shape(&t), 500, "state {state:?}"); + } + } + + #[test] + fn charge_downloading_is_max_of_reservation_and_measured() { + // measured > reserved → measured + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Downloading, + 1000, + 1500, + ); + assert_eq!(charge_task_shape(&t), 1500); + // reserved > measured → reserved + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Downloading, + 2000, + 100, + ); + assert_eq!(charge_task_shape(&t), 2000); + } + + #[test] + fn charge_completed_is_measured() { + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Completed, + 0, + 3000, + ); + assert_eq!(charge_task_shape(&t), 3000); + } + + #[test] + fn charge_failed_cancelled_is_partial_measured() { + // No partial file → 0 + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Failed, + 5000, + 0, + ); + assert_eq!(charge_task_shape(&t), 0); + // Partial file present → measured + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Cancelled, + 5000, + 200, + ); + assert_eq!(charge_task_shape(&t), 200); + } + + #[test] + fn charge_is_zero_for_non_recording_task() { + let t = TaskShape { + state: DownloadState::Completed, + recording: None, + }; + assert_eq!(charge_task_shape(&t), 0); + } + + #[test] + fn private_pool_for_user_owner() { + let t = task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Scheduled, + 100, + 0, + ); + assert_eq!( + pool_for_shape(&t), + Some(QuotaPool::Private(UserId::from("web:alice"))) + ); + } + + #[test] + fn shared_pool_for_legacy_admin() { + let t = task( + RecordingOwner::LegacyAdmin, + DownloadState::Scheduled, + 100, + 0, + ); + assert_eq!(pool_for_shape(&t), Some(QuotaPool::Shared)); + } + + // `charge_for_task` is the public surface that walks a real + // `FileDownload`. The shape-based test below covers the same + // state→charge logic; the `charge_for_task` wrapper is a + // trivial match on `recording.is_none()` and `charge_for_state` + // so it is not exercised separately here. + + #[test] + fn admission_ok_under_limit() { + let limits = QuotaLimits { + default_private_bytes: Some(1000), + ..Default::default() + }; + let out = would_exceed(&QuotaPool::Private(UserId::from("web:alice")), 600, 300, &limits); + assert_eq!(out, AdmissionOutcome::Ok { used_after: 900, limit: 1000 }); + } + + #[test] + fn admission_over_limit() { + let limits = QuotaLimits { + default_private_bytes: Some(1000), + ..Default::default() + }; + let out = would_exceed(&QuotaPool::Private(UserId::from("web:alice")), 800, 300, &limits); + assert_eq!( + out, + AdmissionOutcome::OverLimit { used: 800, limit: 1000, would_be: 1100 } + ); + } + + #[test] + fn admission_unlimited_when_no_limit() { + let limits = QuotaLimits::default(); + let out = would_exceed(&QuotaPool::Private(UserId::from("web:alice")), 999_999, 1, &limits); + assert_eq!(out, AdmissionOutcome::Unlimited { used_after: 1_000_000 }); + } + + #[test] + fn per_user_override_beats_default() { + let mut limits = QuotaLimits { + default_private_bytes: Some(1000), + ..Default::default() + }; + limits + .per_user_bytes + .insert(UserId::from("web:alice"), 5_000); + assert_eq!( + limit_for_pool(&QuotaPool::Private(UserId::from("web:alice")), &limits), + Some(5_000) + ); + // bob falls back to the default + assert_eq!( + limit_for_pool(&QuotaPool::Private(UserId::from("web:bob")), &limits), + Some(1000) + ); + } + + #[test] + fn estimate_reservation_known_bitrate() { + let (bytes, warn) = estimate_reservation(3600, 500_000, 0); + assert_eq!(bytes, 3600 * 500_000); + assert_eq!(warn, ReservationWarning::None); + } + + #[test] + fn estimate_reservation_unknown_bitrate_uses_fallback() { + // 90 minutes with 8 MiB/min fallback → 90 * 8 MiB + let (bytes, warn) = estimate_reservation(90 * 60, 0, 8 * 1024 * 1024); + assert_eq!(bytes, 90 * 8 * 1024 * 1024); + assert_eq!(warn, ReservationWarning::UnknownBitrate); + } + + #[test] + fn estimate_reservation_unknown_bitrate_rounds_up_to_minute() { + // 30 seconds → rounds up to 1 minute + let (bytes, warn) = estimate_reservation(30, 0, 8 * 1024 * 1024); + assert_eq!(bytes, 8 * 1024 * 1024); + assert_eq!(warn, ReservationWarning::UnknownBitrate); + } + + #[test] + fn estimate_reservation_unknown_bitrate_clamps_zero_to_minute() { + // 0 seconds → at least 1 minute (sanity) + let (bytes, warn) = estimate_reservation(0, 0, 8 * 1024 * 1024); + assert_eq!(bytes, 8 * 1024 * 1024); + assert_eq!(warn, ReservationWarning::UnknownBitrate); + } + + #[test] + fn regular_user_dto_redacts_other_users() { + let mut totals = QuotaTotals::default(); + totals.private.insert(UserId::from("web:alice"), 6000); + totals.private.insert(UserId::from("web:bob"), 9999); + totals.shared = 200; + let limits = QuotaLimits { + default_private_bytes: Some(10_000), + shared_bytes: Some(50_000), + ..Default::default() + }; + let tasks = vec![ + task( + RecordingOwner::User(UserId::from("web:alice")), + DownloadState::Completed, + 0, + 6000, + ), + task( + RecordingOwner::User(UserId::from("web:bob")), + DownloadState::Scheduled, + 9999, + 0, + ), + ]; + let dto = regular_user_dto(&UserId::from("web:alice"), &totals, &limits, &tasks); + // Own totals present + assert_eq!(dto.private.measured_bytes, 6000); + assert_eq!(dto.private.reserved_bytes, 0); + assert_eq!(dto.private.limit_bytes, Some(10_000)); + // Shared availability present (coarse) + assert_eq!(dto.shared.used_bytes, 200); + assert_eq!(dto.shared.limit_bytes, Some(50_000)); + // bob's 9999 is not in the DTO + let json = serde_json::to_value(&dto).unwrap(); + let s = serde_json::to_string(&json).unwrap(); + assert!(!s.contains("9999"), "DTO must not leak other users' totals: {s}"); + } + +} diff --git a/backend/src/api/model/recording/recording_reconciliation.rs b/backend/src/api/model/recording/recording_reconciliation.rs new file mode 100644 index 000000000..467b09be4 --- /dev/null +++ b/backend/src/api/model/recording/recording_reconciliation.rs @@ -0,0 +1,436 @@ +//! Cross-store rule reconciliation. +//! +//! The scheduler persists two stores that can drift: +//! - The queue (`downloads_state.json`) holds the materialized +//! recording tasks. +//! - The rule repository (`recording_rules.json`) holds the rules +//! and the bounded tombstones. +//! +//! Drift happens when a queue persistence succeeds but the rule +//! tombstone write fails (or vice versa). The reconciliation pass +//! is a pure function over the (tasks, rules, tombstones, now) +//! tuple that produces the actions the scheduler should take. The +//! caller applies them under the queue-mutation boundary; the +//! rule-side writes follow the fixed cross-store lock order: +//! +//! ```text +//! queue mutation boundary -> rule repository mutation +//! ``` +//! +//! Reconciliation truth table: +//! - Materialized task without `Scheduled` tombstone → +//! `AddScheduledTombstone` (reconciliation repairs the drift). +//! - `Scheduled` tombstone without a task and still eligible → +//! `Materialize` (only when no terminal tombstone exists). +//! - `Cancelled` tombstone with an eligible inactive task → +//! `Finalize` (complete the cancellation / removal that the +//! operator already expressed). +//! - `Completed` tombstone → always suppress rematerialization +//! inside the horizon, regardless of task presence. +//! - Active task is never cancelled or removed solely because of a +//! stale reconciliation intent. The reconciler logs a +//! `ConflictingIntent` and the operator must resolve manually. + + +use shared::model::recording_rule::{RecordingRule, RecordingTombstone, TombstoneKind, TombstoneSet}; + +/// Minimum retention horizon for tombstones. Tombstones are retained +/// for at least 14 days even when the EPG horizon is shorter. Weekly +/// rules without an EPG horizon still suppress duplicates inside this +/// window. +pub const MIN_TOMBSTONE_HORIZON_SECS: i64 = 14 * 86_400; + +/// A task from the queue, summarized for reconciliation. The real +/// `FileDownload` has more fields; reconciliation only needs the +/// identity, provenance, state, and activity flags. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReconcilableTask { + pub uuid: String, + pub rule_id: Option, + pub occurrence_key: Option, + /// `true` when the task is in a terminal state + /// (`Completed` / `Failed` / `Cancelled`) or `Deleting`. + pub terminal: bool, + /// `true` when the task is active (`Downloading`). + pub active: bool, + /// `true` when the task is editable (i.e. it has not yet started + /// recording and is not in a terminal state). + pub editable: bool, +} + +/// The action the reconciliation pass decides for a single +/// (rule, occurrence, task) tuple. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReconcileAction { + /// Add a `Scheduled` tombstone for a task that the queue + /// persisted but the rule repository did not. + AddScheduledTombstone { + rule_id: String, + occurrence_key: String, + }, + /// Materialize a new task for a `Scheduled` tombstone whose + /// task disappeared and no terminal tombstone exists. + Materialize { + rule_id: String, + occurrence_key: String, + }, + /// Complete the cancellation / removal intent that the + /// operator already expressed via a `Cancelled` tombstone. + Finalize { uuid: String }, + /// Update a tombstone kind (e.g. move from `Scheduled` to + /// `Completed` when the task finishes). + UpdateTombstone { + rule_id: String, + occurrence_key: String, + new_kind: TombstoneKind, + }, + /// The task is active; the reconciliation must not cancel + /// or remove it. Log and require manual resolution. + ConflictingIntent { uuid: String, intent: TombstoneKind }, + /// Prune an expired tombstone. + PruneTombstone { rule_id: String, occurrence_key: String }, + /// No action. + Noop, +} + +/// Pure: prune tombstones whose `expires_at` is in the past. The +/// minimum horizon (14 days) is enforced for the EPG-driven +/// suppression so a weekly rule without an EPG horizon still +/// suppresses duplicates inside the window. +pub fn prune_tombstones(tombstones: &TombstoneSet, now: i64) -> Vec { + tombstones + .tombstones + .iter() + .filter(|t| t.expires_at > now) + .cloned() + .collect() +} + +/// Pure: compute the actions the caller should apply. The function +/// does not call into the queue or the rule repository; it returns +/// the operations for the caller's atomic boundary to execute. +pub fn reconcile( + rules: &[RecordingRule], + tasks: &[ReconcilableTask], + tombstones: &TombstoneSet, + now: i64, +) -> Vec { + let mut actions: Vec = Vec::new(); + + // Index tasks by (rule_id, occurrence_key) for fast lookup. + let mut by_key: std::collections::HashMap<(String, String), &ReconcilableTask> = + std::collections::HashMap::new(); + for task in tasks { + if let (Some(rule_id), Some(key)) = (task.rule_id.as_deref(), task.occurrence_key.as_deref()) { + by_key.entry((rule_id.to_string(), key.to_string())).or_insert(task); + } + } + + // Index tombstones by (rule_id, occurrence_key). + let mut tombs: std::collections::HashMap<(String, String), &RecordingTombstone> = + std::collections::HashMap::new(); + for t in &tombstones.tombstones { + tombs.insert((t.rule_id.clone(), t.occurrence_key.clone()), t); + } + + // Materialized task without Scheduled tombstone: + // add the missing Scheduled tombstone. When the task is already + // terminal and the tombstone is still Scheduled, the canonical + // UpdateTombstone is emitted here (rule 4). + for task in tasks { + let Some(rule_id) = task.rule_id.as_deref() else { continue }; + let Some(key) = task.occurrence_key.as_deref() else { continue }; + let entry = tombs.get(&(rule_id.to_string(), key.to_string())); + if let Some(t) = entry { + if matches!(t.kind, TombstoneKind::Scheduled) { + if task.terminal { + actions.push(ReconcileAction::UpdateTombstone { + rule_id: rule_id.to_string(), + occurrence_key: key.to_string(), + new_kind: TombstoneKind::Completed, + }); + } + // Already in sync (or now updated); nothing more to do. + continue; + } + // The tombstone is terminal; the task still exists. If + // the task is active, surface a conflict. If the task + // is terminal too, fall through to the completion + // update path. + if task.active { + actions.push(ReconcileAction::ConflictingIntent { uuid: task.uuid.clone(), intent: t.kind }); + } + } else { + actions.push(ReconcileAction::AddScheduledTombstone { + rule_id: rule_id.to_string(), + occurrence_key: key.to_string(), + }); + } + } + + // Scheduled tombstone without a task may be rematerialized. + // Cancelled tombstone with an eligible inactive task may be + // finalized. + for t in &tombstones.tombstones { + if t.expires_at <= now { + actions.push(ReconcileAction::PruneTombstone { + rule_id: t.rule_id.clone(), + occurrence_key: t.occurrence_key.clone(), + }); + continue; + } + let key = (t.rule_id.clone(), t.occurrence_key.clone()); + let Some(rule) = rules.iter().find(|r| r.id == t.rule_id) else { + // The rule was deleted. The tombstone outlives the + // rule; leave it in place until it expires. + continue; + }; + if !rule.enabled { + // Disabled rules do not re-materialize. + continue; + } + let Some(task) = by_key.get(&key) else { + match t.kind { + TombstoneKind::Scheduled => { + let still_eligible = true; + if still_eligible { + actions.push(ReconcileAction::Materialize { + rule_id: t.rule_id.clone(), + occurrence_key: t.occurrence_key.clone(), + }); + } + } + TombstoneKind::Cancelled | TombstoneKind::Completed => { + // Terminal tombstones always suppress + // rematerialization. No action. + } + } + continue; + }; + match t.kind { + TombstoneKind::Scheduled | TombstoneKind::Completed => { + // Scheduled: already covered by the first loop — when a task + // with a Scheduled tombstone is seen as terminal, an + // UpdateTombstone is pushed there. Doing it again here + // would emit a duplicate action for the same + // (rule_id, occurrence_key). + // Completed: suppression is authoritative; the task being + // present is fine — it just must not be re-created. + } + TombstoneKind::Cancelled => { + if task.active { + // Never cancel an active task solely because of a + // stale reconciliation intent. + actions.push(ReconcileAction::ConflictingIntent { + uuid: task.uuid.clone(), + intent: TombstoneKind::Cancelled, + }); + } else if task.editable || task.terminal { + // The operator already cancelled; finish the + // intent. + actions.push(ReconcileAction::Finalize { uuid: task.uuid.clone() }); + } + } + } + } + + actions +} + +/// Pure: the minimum `expires_at` for a tombstone. The repository +/// uses this when persisting a new tombstone so the suppression +/// survives the longer of the EPG horizon and the weekly fallback +/// horizon. +pub fn tombstone_expires_at(now: i64, epg_horizon_end: Option) -> i64 { + let epg_bound = epg_horizon_end.unwrap_or(now); + let minimum = now + MIN_TOMBSTONE_HORIZON_SECS; + if epg_bound > minimum { + epg_bound + } else { + minimum + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::recording_rule::{RuleBody, RuleSource, RuleVisibility}; + use shared::model::UserId; + + fn source() -> RuleSource { RuleSource::new("tgt", "virt", "input") } + fn user() -> UserId { UserId::from("web:alice") } + fn rule(id: &str) -> RecordingRule { + RecordingRule { + id: id.to_string(), + owner_id: user(), + visibility: RuleVisibility::Private, + enabled: true, + source: source(), + channel_id: None, + body: RuleBody::NewEpisode { + series_id: Some("series-1".into()), + title_pattern: None, + exclude_repeat: true, + }, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 0, + updated_at: 0, + } + } + + fn tomb(rule_id: &str, key: &str, kind: TombstoneKind, created: i64, expires: i64) -> RecordingTombstone { + RecordingTombstone { + rule_id: rule_id.into(), + occurrence_key: key.into(), + kind, + created_at: created, + expires_at: expires, + } + } + + fn task(uuid: &str, rule_id: &str, key: &str, terminal: bool, active: bool, editable: bool) -> ReconcilableTask { + ReconcilableTask { + uuid: uuid.into(), + rule_id: Some(rule_id.into()), + occurrence_key: Some(key.into()), + terminal, + active, + editable, + } + } + + #[test] + fn prune_drops_expired_tombstones() { + let set = TombstoneSet { + tombstones: vec![ + tomb("r1", "k1", TombstoneKind::Scheduled, 0, 100), + tomb("r1", "k2", TombstoneKind::Cancelled, 0, 1_000), + ], + }; + let kept = prune_tombstones(&set, 500); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].occurrence_key, "k2"); + } + + #[test] + fn plan_rule_1_adds_scheduled_tombstone_when_task_present() { + let rules = vec![rule("r1")]; + let tasks = vec![task("u1", "r1", "k1", false, false, true)]; + let set = TombstoneSet::default(); + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(actions.iter().any(|a| matches!(a, ReconcileAction::AddScheduledTombstone { .. }))); + } + + #[test] + fn plan_rule_2_materializes_when_scheduled_tombstone_orphan() { + let rules = vec![rule("r1")]; + let tasks = vec![]; + let set = TombstoneSet { + tombstones: vec![tomb("r1", "k1", TombstoneKind::Scheduled, 0, 1_000_000)], + }; + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(actions.iter().any(|a| matches!(a, ReconcileAction::Materialize { .. }))); + } + + #[test] + fn plan_rule_3_finalizes_cancelled_task() { + let rules = vec![rule("r1")]; + let tasks = vec![task("u1", "r1", "k1", false, false, true)]; + let set = TombstoneSet { + tombstones: vec![tomb("r1", "k1", TombstoneKind::Cancelled, 0, 1_000_000)], + }; + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(actions.iter().any(|a| matches!(a, ReconcileAction::Finalize { uuid } if uuid == "u1"))); + } + + #[test] + fn plan_rule_3_conflicting_intent_for_active_cancelled_task() { + let rules = vec![rule("r1")]; + let tasks = vec![task("u1", "r1", "k1", false, true, false)]; + let set = TombstoneSet { + tombstones: vec![tomb("r1", "k1", TombstoneKind::Cancelled, 0, 1_000_000)], + }; + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(actions.iter().any(|a| matches!(a, ReconcileAction::ConflictingIntent { .. }))); + } + + #[test] + fn plan_rule_4_completed_tombstone_suppresses_rematerialization() { + let rules = vec![rule("r1")]; + let tasks = vec![]; + let set = TombstoneSet { + tombstones: vec![tomb("r1", "k1", TombstoneKind::Completed, 0, 1_000_000)], + }; + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(!actions.iter().any(|a| matches!(a, ReconcileAction::Materialize { .. }))); + } + + #[test] + fn plan_rule_4_updates_tombstone_when_task_completes() { + let rules = vec![rule("r1")]; + let tasks = vec![task("u1", "r1", "k1", true, false, false)]; + let set = TombstoneSet { + tombstones: vec![tomb("r1", "k1", TombstoneKind::Scheduled, 0, 1_000_000)], + }; + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(actions.iter().any(|a| matches!( + a, + ReconcileAction::UpdateTombstone { new_kind: TombstoneKind::Completed, .. } + ))); + } + + #[test] + fn expired_tombstones_are_pruned() { + let rules = vec![rule("r1")]; + let tasks = vec![]; + let set = TombstoneSet { + tombstones: vec![tomb("r1", "k1", TombstoneKind::Scheduled, 0, 100)], + }; + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(actions.iter().any(|a| matches!(a, ReconcileAction::PruneTombstone { .. }))); + } + + #[test] + fn disabled_rule_does_not_rematerialize() { + let mut r = rule("r1"); + r.enabled = false; + let rules = vec![r]; + let tasks = vec![]; + let set = TombstoneSet { + tombstones: vec![tomb("r1", "k1", TombstoneKind::Scheduled, 0, 1_000_000)], + }; + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(!actions.iter().any(|a| matches!(a, ReconcileAction::Materialize { .. }))); + } + + #[test] + fn deleted_rule_leaves_tombstone_until_expiry() { + // The rule was deleted but the tombstone is still valid. The + // reconciliation should not try to rematerialize. + let rules: Vec = vec![]; + let tasks = vec![]; + let set = TombstoneSet { + tombstones: vec![tomb("r1", "k1", TombstoneKind::Scheduled, 0, 1_000_000)], + }; + let actions = reconcile(&rules, &tasks, &set, 1_000); + assert!(!actions.iter().any(|a| matches!(a, ReconcileAction::Materialize { .. }))); + } + + #[test] + fn tombstone_expires_at_uses_longer_of_epg_and_minimum_horizon() { + // EPG horizon is far in the future — but the minimum + // 14-day horizon is even longer, so the minimum wins. + let now = 1_000; + let epg = 1_000_000; + assert_eq!(tombstone_expires_at(now, Some(epg)), now + MIN_TOMBSTONE_HORIZON_SECS); + // EPG horizon well beyond the minimum → use EPG. + let epg_far = 1_000 + 30 * 86_400; + assert_eq!(tombstone_expires_at(now, Some(epg_far)), epg_far); + // EPG horizon shorter than the minimum → use the minimum. + let epg_short = 1_000 + 100; + assert_eq!(tombstone_expires_at(now, Some(epg_short)), now + MIN_TOMBSTONE_HORIZON_SECS); + // No EPG horizon → minimum horizon. + assert_eq!(tombstone_expires_at(now, None), now + MIN_TOMBSTONE_HORIZON_SECS); + } +} diff --git a/backend/src/api/model/recording/recording_retention.rs b/backend/src/api/model/recording/recording_retention.rs new file mode 100644 index 000000000..4307c7949 --- /dev/null +++ b/backend/src/api/model/recording/recording_retention.rs @@ -0,0 +1,740 @@ +//! Retention candidate selection. +//! +//! The selector considers only completed recordings with safe final files, +//! groups count retention by owner/channel, and returns oldest candidates +//! first with a stable task-id tie-break. + +use std::collections::HashMap; + +use shared::model::recording::RecordingOwner; +use shared::model::UserId; + +use super::recording_quota::QuotaRecordingTaskView; + +/// Retention configuration derived from `RecordingRetentionConfig`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RetentionConfig { + pub keep_last_per_channel: Option, + pub delete_after_days: Option, +} + +/// Group key for count retention. `owner` is the pool the task +/// belongs to (private `UserId` or shared). `channel` is the +/// stable channel key. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RetentionGroupKey { + pub owner: RetentionOwner, + pub channel: ChannelKey, +} + +/// The pool side of a retention key. Private recordings are +/// grouped per owner AND per channel; shared recordings are +/// grouped per channel (no owner dimension). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RetentionOwner { + Private(UserId), + Shared, +} + +/// Stable channel key. The stable channel ID is preferred; the +/// normalized channel name is the fallback. A `None` stable id +/// falls back to the normalized name. +/// +/// Equality and hashing use only the discriminant field that +/// uniquely identifies the channel: when `stable` is `Some`, two +/// keys with the same id collapse regardless of `name_fallback` +/// variations (so a rename or republish under a different display +/// name still groups together). When `stable` is `None`, +/// `name_fallback` is the discriminator. +#[derive(Debug, Clone)] +pub struct ChannelKey { + pub stable: Option, + pub name_fallback: String, +} + +impl PartialEq for ChannelKey { + fn eq(&self, other: &Self) -> bool { + match (&self.stable, &other.stable) { + (Some(a), Some(b)) => a == b, + _ => self.stable.is_none() && other.stable.is_none() && self.name_fallback == other.name_fallback, + } + } +} + +impl Eq for ChannelKey {} + +impl std::hash::Hash for ChannelKey { + fn hash(&self, state: &mut H) { + if let Some(s) = &self.stable { + std::hash::Hash::hash(&1u8, state); + std::hash::Hash::hash(s, state); + } else { + std::hash::Hash::hash(&0u8, state); + std::hash::Hash::hash(&self.name_fallback, state); + } + } +} + +impl ChannelKey { + /// Build a `ChannelKey` from the recording metadata. The + /// stable id is `channel_id` if present; otherwise the + /// normalized channel name is used as both the key and the + /// fallback (so two recordings with no `channel_id` but the + /// same name still group together). + pub fn from_metadata(channel_id: Option<&str>, channel_name: Option<&str>) -> Self { + let stable = channel_id + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + let name_fallback = channel_name + .map(normalize_channel_name) + .unwrap_or_default(); + Self { stable, name_fallback } + } +} + +/// Lowercase + collapse whitespace + strip a few common display +/// decorations (trailing year, country tags). Two recordings of +/// "BBC One (UK)" and "bbc one" must group together. +pub fn normalize_channel_name(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + let mut last_space = true; + for ch in name.chars() { + if ch.is_whitespace() { + if !last_space { + out.push(' '); + } + last_space = true; + } else { + out.extend(ch.to_lowercase()); + last_space = false; + } + } + let trimmed = out.trim().to_string(); + // Strip a trailing parenthesized country/region tag. + if let Some(idx) = trimmed.find(" (") { + if trimmed.ends_with(')') { + return trimmed[..idx].trim_end().to_string(); + } + } + trimmed +} + +impl RetentionOwner { + pub fn from_recording_owner(owner: &RecordingOwner) -> Self { + match owner { + RecordingOwner::User(uid) => Self::Private(uid.clone()), + RecordingOwner::LegacyAdmin => Self::Shared, + } + } +} + +/// A retention candidate is a task that should be deleted by +/// the retention worker. The worker reads `uuid` and looks up +/// the task under the queue mutation boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetentionCandidate { + pub uuid: String, + pub owner: RetentionOwner, + pub channel: ChannelKey, + pub completed_at: i64, + pub reason: RetentionReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RetentionReason { + /// Older than `delete_after_days` (counted from + /// `completed_at`). + Age, + /// Beyond `keep_last_per_channel` for this + /// (owner, channel) group. + Count, +} + +/// Group a task by its `(owner, channel)` retention key. Returns +/// `None` for non-Completed tasks and for tasks that cannot be +/// grouped (no channel info and no `completed_at`). +fn group_for(task: &V) -> Option<(RetentionGroupKey, i64)> { + let meta = task.recording()?; + // Only `Completed` is eligible. Pending, active, failed, + // Cancelled, deleting and non-recording tasks are excluded. + if !matches!(task.state(), crate::api::model::download::DownloadState::Completed) { + return None; + } + let completed_at = meta.completed_at?; + let channel = ChannelKey::from_metadata(meta.channel_id.as_deref(), meta.channel_name.as_deref()); + let owner = RetentionOwner::from_recording_owner(&meta.owner); + Some((RetentionGroupKey { owner, channel }, completed_at)) +} + +/// Compute the union of age and count retention candidates. +/// +/// `now_secs` is the wall-clock seconds used as the "now" for +/// the age check; passing it in keeps the function pure and +/// testable. +/// +/// Output is ordered oldest first; ties break by `uuid` +/// ascending (lexicographic) for stable, deterministic deletes. +pub fn compute_candidates( + tasks: &[V], + config: &RetentionConfig, + now_secs: i64, +) -> Vec { + if config.keep_last_per_channel.is_none() && config.delete_after_days.is_none() { + return Vec::new(); + } + // Group (uuid, completed_at, channel, owner) by group key. + let mut groups: HashMap> = HashMap::new(); + // The owner/channel we want on each candidate — derived from + // the task that first populated the group. Channel keys are + // already normalized so equality is stable. + for task in tasks { + let Some((key, completed_at)) = group_for(task) else { + continue; + }; + groups + .entry(key) + .or_default() + .push((task.uuid().to_string(), completed_at)); + } + let mut candidates: Vec = Vec::new(); + for (key, mut members) in groups { + // Stable order inside the group: oldest first, uuid + // tiebreak. This is the order the count retention keeps + // and the order we delete from. + members.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + if let Some(keep) = config.keep_last_per_channel { + let keep = keep as usize; + // `keep_last_per_channel = N` means "keep the N most + // recent". The members are sorted oldest first, so + // the deletable head is `len - keep` (capped at 0). + let n_to_delete = members.len().saturating_sub(keep); + for (uuid, completed_at) in members.iter().take(n_to_delete) { + candidates.push(RetentionCandidate { + uuid: uuid.clone(), + owner: key.owner.clone(), + channel: key.channel.clone(), + completed_at: *completed_at, + reason: RetentionReason::Count, + }); + } + } + if let Some(days) = config.delete_after_days { + let age_threshold_secs = i64::from(days).saturating_mul(86_400); + for (uuid, completed_at) in &members { + if now_secs.saturating_sub(*completed_at) >= age_threshold_secs { + candidates.push(RetentionCandidate { + uuid: uuid.clone(), + owner: key.owner.clone(), + channel: key.channel.clone(), + completed_at: *completed_at, + reason: RetentionReason::Age, + }); + } + } + } + } + // Final ordering: oldest first, then uuid tiebreak. Stable + // across calls so the worker can delete one-at-a-time + // without surprises. Dedupe by uuid so an age-eligible task + // that is also count-overflow appears once. The retention + // eligibility is the union of age and count candidates. + candidates.sort_by(|a, b| { + a.completed_at + .cmp(&b.completed_at) + .then_with(|| a.uuid.cmp(&b.uuid)) + }); + let mut deduped: Vec = Vec::with_capacity(candidates.len()); + let mut last_uuid: Option = None; + for c in candidates { + if last_uuid.as_deref() == Some(c.uuid.as_str()) { + continue; + } + last_uuid = Some(c.uuid.clone()); + deduped.push(c); + } + deduped +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::model::download::DownloadState; + use shared::model::recording::{RecordingMetadata, RecordingOwner, RecordingSource, RecordingVisibility}; + + fn make_meta( + owner: RecordingOwner, + channel_id: Option<&str>, + channel_name: Option<&str>, + completed_at: i64, + ) -> RecordingMetadata { + RecordingMetadata { + owner, + visibility: RecordingVisibility::Private, + source: Some(RecordingSource::new("t1", "v1", "in1")), + program_start: None, + program_end: None, + scheduled_start: None, + scheduled_end: None, + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: channel_id.map(str::to_string), + channel_name: channel_name.map(str::to_string), + program_title: None, + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: None, + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: 0, + completed_at: Some(completed_at), + notification_markers: Vec::new(), + deleting_previous_state: None, + } + } + + struct T { + uuid: String, + state: DownloadState, + recording: Option, + } + impl super::QuotaRecordingTaskView for T { + fn state(&self) -> &DownloadState { + &self.state + } + fn recording(&self) -> Option<&RecordingMetadata> { + self.recording.as_ref() + } + fn uuid(&self) -> &str { + &self.uuid + } + } + + fn completed( + uuid: &str, + owner: RecordingOwner, + channel_id: Option<&str>, + channel_name: Option<&str>, + completed_at: i64, + ) -> T { + T { + uuid: uuid.to_string(), + state: DownloadState::Completed, + recording: Some(make_meta(owner, channel_id, channel_name, completed_at)), + } + } + + fn pending(uuid: &str) -> T { + T { + uuid: uuid.to_string(), + state: DownloadState::Scheduled, + recording: Some(make_meta( + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 1_000_000, + )), + } + } + + fn failed(uuid: &str) -> T { + T { + uuid: uuid.to_string(), + state: DownloadState::Failed, + recording: Some(make_meta( + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 1_000_000, + )), + } + } + + #[test] + fn excludes_non_completed_states() { + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: Some(365), + }; + let tasks = vec![pending("a"), failed("b")]; + let out = compute_candidates(&tasks, &config, 1_000_000_000); + assert!(out.is_empty(), "non-Completed tasks must be excluded"); + } + + #[test] + fn excludes_generic_downloads() { + // A `Completed` task with no recording metadata is a + // generic download. Retention must skip it. + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: Some(365), + }; + let t = T { + uuid: "d1".to_string(), + state: DownloadState::Completed, + recording: None, + }; + let out = compute_candidates(&[t], &config, 1_000_000_000); + assert!(out.is_empty()); + } + + #[test] + fn excludes_tasks_without_completed_at() { + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: Some(365), + }; + let mut t = completed( + "a", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 1, + ); + t.recording.as_mut().unwrap().completed_at = None; + let out = compute_candidates(&[t], &config, 1_000_000_000); + assert!(out.is_empty()); + } + + #[test] + fn count_retention_keeps_n_oldest() { + let config = RetentionConfig { + keep_last_per_channel: Some(2), + delete_after_days: None, + }; + let tasks = vec![ + completed( + "a", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 1_000, + ), + completed( + "b", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 2_000, + ), + completed( + "c", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 3_000, + ), + completed( + "d", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 4_000, + ), + ]; + let out = compute_candidates(&tasks, &config, 0); + // keep 2 → 2 candidates (a, b) ordered oldest first + assert_eq!(out.len(), 2); + assert_eq!(out[0].uuid, "a"); + assert_eq!(out[1].uuid, "b"); + assert_eq!(out[0].reason, RetentionReason::Count); + } + + #[test] + fn age_retention_picks_older_than_threshold() { + let config = RetentionConfig { + keep_last_per_channel: None, + delete_after_days: Some(30), + }; + // 30 days = 2_592_000 seconds + let now = 30 * 86_400 + 1_000; + let tasks = vec![ + completed( + "old", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 0, // 30+ days old + ), + completed( + "fresh", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + now - 5, // 5 seconds old + ), + ]; + let out = compute_candidates(&tasks, &config, now); + assert_eq!(out.len(), 1); + assert_eq!(out[0].uuid, "old"); + assert_eq!(out[0].reason, RetentionReason::Age); + } + + #[test] + fn age_and_count_union() { + // keep_last_per_channel = 3, delete_after_days = 30 + // Group has 5 tasks. 2 are count-overflow; 1 is also + // age-eligible. The union contains 2 distinct tasks. + let config = RetentionConfig { + keep_last_per_channel: Some(3), + delete_after_days: Some(30), + }; + let now = 100 * 86_400; + let tasks = vec![ + completed( + "a", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + now - 200 * 86_400, // age + count + ), + completed( + "b", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + now - 100 * 86_400, // age + count + ), + completed( + "c", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + now - 5 * 86_400, // keep + ), + completed( + "d", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + now - 86_400, // keep + ), + completed( + "e", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + now - 86_400, // keep + ), + ]; + let out = compute_candidates(&tasks, &config, now); + let uuids: Vec<&str> = out.iter().map(|c| c.uuid.as_str()).collect(); + assert_eq!(uuids, vec!["a", "b"]); + } + + #[test] + fn owner_isolation() { + // alice and bob each have 3 recordings on the same + // channel. keep_last_per_channel = 1. Each owner keeps + // their newest 1 → 2 candidates per owner = 4 total. + let config = RetentionConfig { + keep_last_per_channel: Some(1), + delete_after_days: None, + }; + let tasks = vec![ + completed( + "alice-1", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 1_000, + ), + completed( + "alice-2", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 2_000, + ), + completed( + "alice-3", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 3_000, + ), + completed( + "bob-1", + RecordingOwner::User(UserId::from("web:bob")), + Some("c1"), + Some("Alpha"), + 4_000, + ), + completed( + "bob-2", + RecordingOwner::User(UserId::from("web:bob")), + Some("c1"), + Some("Alpha"), + 5_000, + ), + completed( + "bob-3", + RecordingOwner::User(UserId::from("web:bob")), + Some("c1"), + Some("Alpha"), + 6_000, + ), + ]; + let out = compute_candidates(&tasks, &config, 0); + let uuids: Vec<&str> = out.iter().map(|c| c.uuid.as_str()).collect(); + assert_eq!(uuids, vec!["alice-1", "alice-2", "bob-1", "bob-2"]); + } + + #[test] + fn channel_groups_by_id_when_present() { + // Two recordings on the same stable channel id but + // different display names still group together. + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: None, + }; + let tasks = vec![ + completed( + "a", + RecordingOwner::User(UserId::from("web:alice")), + Some("stable-1"), + Some("Alpha"), + 1, + ), + completed( + "b", + RecordingOwner::User(UserId::from("web:alice")), + Some("stable-1"), + Some("Alpha HD"), + 2, + ), + ]; + let out = compute_candidates(&tasks, &config, 0); + // Same group (stable-1 + same owner) → 2 candidates + assert_eq!(out.len(), 2); + } + + #[test] + fn channel_falls_back_to_normalized_name() { + // No `channel_id`; grouping is by normalized channel + // name. "BBC One" and "bbc one" must group together. + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: None, + }; + let tasks = vec![ + completed( + "a", + RecordingOwner::User(UserId::from("web:alice")), + None, + Some("BBC One"), + 1, + ), + completed( + "b", + RecordingOwner::User(UserId::from("web:alice")), + None, + Some("bbc one "), + 2, + ), + ]; + let out = compute_candidates(&tasks, &config, 0); + assert_eq!(out.len(), 2); + } + + #[test] + fn normalize_channel_name_strips_country_tag() { + assert_eq!(normalize_channel_name("BBC One (UK)"), "bbc one"); + assert_eq!(normalize_channel_name(" Sky Sports "), "sky sports"); + assert_eq!(normalize_channel_name(""), ""); + assert_eq!(normalize_channel_name("(orphan)"), "(orphan)"); // no closing + } + + #[test] + fn equal_timestamps_break_by_uuid() { + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: None, + }; + let tasks = vec![ + completed( + "b", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 1_000, + ), + completed( + "a", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 1_000, + ), + ]; + let out = compute_candidates(&tasks, &config, 0); + let uuids: Vec<&str> = out.iter().map(|c| c.uuid.as_str()).collect(); + // Same completed_at → uuid ascending + assert_eq!(uuids, vec!["a", "b"]); + } + + #[test] + fn exact_age_boundary_equals_threshold_is_kept() { + // The age threshold is `>= days * 86_400`. A task whose + // age is exactly equal to the threshold is **eligible** + // for retention. + let config = RetentionConfig { + keep_last_per_channel: None, + delete_after_days: Some(30), + }; + let now = 30 * 86_400 + 5; + let t = completed( + "exact", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 5, // age = 30*86_400 exactly + ); + let out = compute_candidates(&[t], &config, now); + assert_eq!(out.len(), 1); + assert_eq!(out[0].uuid, "exact"); + } + + #[test] + fn shared_owner_groups_only_by_channel() { + // Two shared recordings on the same channel — count + // retention keeps the newest 1, so 1 candidate. + let config = RetentionConfig { + keep_last_per_channel: Some(1), + delete_after_days: None, + }; + let tasks = vec![ + completed( + "s1", + RecordingOwner::LegacyAdmin, + Some("c1"), + Some("Alpha"), + 1, + ), + completed( + "s2", + RecordingOwner::LegacyAdmin, + Some("c1"), + Some("Alpha"), + 2, + ), + ]; + let out = compute_candidates(&tasks, &config, 0); + let uuids: Vec<&str> = out.iter().map(|c| c.uuid.as_str()).collect(); + assert_eq!(uuids, vec!["s1"]); + } + + #[test] + fn no_config_returns_empty() { + let config = RetentionConfig::default(); + let tasks = vec![completed( + "a", + RecordingOwner::User(UserId::from("web:alice")), + Some("c1"), + Some("Alpha"), + 1, + )]; + let out = compute_candidates(&tasks, &config, 0); + assert!(out.is_empty()); + } +} diff --git a/backend/src/api/model/recording/recording_rule_scheduler.rs b/backend/src/api/model/recording/recording_rule_scheduler.rs new file mode 100644 index 000000000..a2b664a01 --- /dev/null +++ b/backend/src/api/model/recording/recording_rule_scheduler.rs @@ -0,0 +1,531 @@ +//! Rule materialization scheduler. +//! +//! Pure: enumerate the (rule, occurrence) pairs the scheduler +//! should materialize. The caller applies the actions under the +//! queue-mutation boundary and the fixed cross-store lock order: +//! +//! ```text +//! queue mutation boundary -> rule repository mutation +//! ``` +//! +//! The pure planner enumerates candidates; the runtime runner loads +//! rules from disk and writes materialized tasks through +//! `RecordingService`. + +use chrono::{DateTime, Utc}; +use log::{debug, error}; +use shared::model::recording::{RecordingProvenance, RecordingVisibility}; +use shared::model::recording_rule::{RecordingRule, RuleBody, RuleSource, RuleVisibility, TombstoneSet}; +use shared::model::{Claims, Permission, PermissionSet, ROLE_ADMIN, XtreamCluster, CURRENT_PERMISSION_SCHEMA_VERSION}; +use shared::model::EpgProgramme; +use std::sync::Arc; +use tokio_util::sync::CancellationToken; + +use super::recording_occurrence::{ + candidate_channel_key, candidate_episode_key, matches_new_episode, next_weekly_occurrence, occurrence_key, +}; +use super::recording_reconciliation::{ReconcilableTask, MIN_TOMBSTONE_HORIZON_SECS}; +use super::recording_service::{CreateRecordingInput, RecordingService, RecordingSourceInput}; +use crate::api::model::{AppState, DownloadState, FileDownload}; +use crate::repository::recording_rule_repository::RecordingRuleRepository; + +/// Maximum look-ahead for a weekly rule without an EPG horizon. +pub const WEEKLY_FALLBACK_HORIZON_SECS: i64 = MIN_TOMBSTONE_HORIZON_SECS; +const RULE_SCHEDULER_INTERVAL_SECS: u64 = 60; + +/// A single (rule, occurrence) pair the scheduler should +/// materialize. The `programme` is the EPG match for `NewEpisode` +/// rules; for `WeeklyTimeslot` it is `None` and the caller fills +/// the programme metadata from the rule's body. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterializationCandidate { + pub rule_id: String, + pub rule_owner_id: shared::model::UserId, + pub rule_visibility: RuleVisibility, + pub source: RuleSource, + pub channel_id: Option, + pub channel_name: Option, + pub pre_roll_secs: u64, + pub post_roll_secs: u64, + pub programme_start: i64, + pub programme_end: i64, + pub programme_title: String, + pub occurrence_key: String, +} + +/// Pure: enumerate the candidates the scheduler should create. +/// `rules` is the rule repository's current rule list. +/// `epg_programmes` is the EPG horizon (the slice of EPG that +/// covers `[now, epg_horizon_end]`). +/// `tasks` is the current set of tasks (for dedup / `rule_id` +/// provenance). +/// `tombstones` is the current tombstone set (for dedup). +/// `now` is the current server time (Unix seconds). +/// `epg_horizon_end` is the end of the available EPG horizon. The +/// scheduler only considers programmes that start on or before +/// this instant. When `None`, weekly rules fall back to the +/// `WEEKLY_FALLBACK_HORIZON_SECS` window. +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub fn plan_materializations( + rules: &[RecordingRule], + epg_programmes: &[EpgProgramme], + tasks: &[ReconcilableTask], + tombstones: &TombstoneSet, + now: i64, + epg_horizon_end: Option, +) -> Vec { + let mut out: Vec = Vec::new(); + + // Index existing (rule_id, occurrence_key) so the scheduler + // never produces a duplicate. + let mut existing: std::collections::HashSet<(String, String)> = + std::collections::HashSet::with_capacity(tasks.len() + tombstones.tombstones.len()); + for task in tasks { + if let (Some(rule_id), Some(key)) = (task.rule_id.as_deref(), task.occurrence_key.as_deref()) { + existing.insert((rule_id.to_string(), key.to_string())); + } + } + for t in &tombstones.tombstones { + if t.expires_at > now { + existing.insert((t.rule_id.clone(), t.occurrence_key.clone())); + } + } + + for rule in rules { + if !rule.enabled { + continue; + } + match &rule.body { + RuleBody::NewEpisode { series_id, title_pattern, exclude_repeat } => { + let _ = (series_id, title_pattern, exclude_repeat); + // For each EPG programme, evaluate the match. The + // rule's local channel_id is the channel key when + // the EPG programme carries an id; otherwise we + // fall back to the EPG programme's id / title. + for prog in epg_programmes { + if prog.start < now { + continue; + } + if let Some(end) = epg_horizon_end { + if prog.start > end { + continue; + } + } + let airing_is_repeat = matches!( + prog.airing_status(), + shared::model::recording::AiringStatus::Repeat + ); + let programme_title = prog.title.as_deref(); + let m = matches_new_episode( + &rule.body, + None, + programme_title, + airing_is_repeat, + ); + if !matches!(m, crate::api::model::recording_occurrence::NewEpisodeMatch::NewEpisode) { + continue; + } + let channel = candidate_channel_key(Some(prog.get_transient_channel_id().as_ref()), None); + let episode = candidate_episode_key(None, None, None, None, None, programme_title); + let key = occurrence_key(&rule.id, &rule.source, &channel, prog.start, &episode); + if existing.contains(&(rule.id.clone(), key.clone())) { + continue; + } + out.push(MaterializationCandidate { + rule_id: rule.id.clone(), + rule_owner_id: rule.owner_id.clone(), + rule_visibility: rule.visibility, + source: rule.source.clone(), + channel_id: Some(prog.get_transient_channel_id().to_string()), + channel_name: None, + pre_roll_secs: rule.pre_roll_secs, + post_roll_secs: rule.post_roll_secs, + programme_start: prog.start, + programme_end: prog.stop, + programme_title: programme_title.unwrap_or("Untitled").to_string(), + occurrence_key: key, + }); + } + } + RuleBody::WeeklyTimeslot { duration_secs, .. } => { + let Some(fallback_horizon_end) = now.checked_add(WEEKLY_FALLBACK_HORIZON_SECS) else { + continue; + }; + let horizon_end = epg_horizon_end.unwrap_or(fallback_horizon_end).min(fallback_horizon_end); + let Some(now_utc) = DateTime::::from_timestamp(now, 0) else { + continue; + }; + let Some(start) = next_weekly_occurrence(&rule.body, now_utc) else { + continue; + }; + if start > horizon_end { + continue; + } + let Ok(duration_secs) = i64::try_from(*duration_secs) else { + continue; + }; + let Some(programme_end) = start.checked_add(duration_secs) else { + continue; + }; + let channel = candidate_channel_key(rule.channel_id.as_deref(), None); + let episode = String::new(); + let key = occurrence_key(&rule.id, &rule.source, &channel, start, &episode); + if existing.contains(&(rule.id.clone(), key.clone())) { + continue; + } + out.push(MaterializationCandidate { + rule_id: rule.id.clone(), + rule_owner_id: rule.owner_id.clone(), + rule_visibility: rule.visibility, + source: rule.source.clone(), + channel_id: rule.channel_id.clone(), + channel_name: None, + pre_roll_secs: rule.pre_roll_secs, + post_roll_secs: rule.post_roll_secs, + programme_start: start, + programme_end, + programme_title: format!("Weekly slot at {start}"), + occurrence_key: key, + }); + } + } + } + + out +} + +pub fn spawn_recording_rule_scheduler(app_state: &Arc, cancel_token: &CancellationToken) { + let app_state = Arc::clone(app_state); + let cancel_token = cancel_token.clone(); + tokio::spawn(async move { + loop { + if let Err(err) = materialize_due_rules(&app_state).await { + error!("Recording rule scheduler failed: {err}"); + } + tokio::select! { + () = cancel_token.cancelled() => break, + () = tokio::time::sleep(std::time::Duration::from_secs(RULE_SCHEDULER_INTERVAL_SECS)) => {} + } + } + }); +} + +async fn materialize_due_rules(app_state: &Arc) -> Result<(), String> { + // `recording.enabled: false` has to stop the scheduler too. Without + // this the routes refuse every request while this loop keeps quietly + // materializing tasks the worker will then run — the worst of both + // states, and invisible to the operator who just switched the DVR + // off. Checked per tick, not at spawn time, so a config reload takes + // effect without a restart. + if !super::recording_supervisor::recording_enabled(app_state.as_ref()) { + return Ok(()); + } + let storage_dir = app_state.app_config.config.load().storage_dir.clone(); + let file = RecordingRuleRepository::new(storage_dir) + .load() + .await + .map_err(|err| err.to_string())?; + let tasks = reconcilable_tasks(app_state).await; + // The EPG horizon this tick. `plan_materializations` matches + // `NewEpisode` rules by walking programmes, so an empty slice means no + // `NewEpisode` rule can ever produce a candidate — only + // `WeeklyTimeslot` rules, which need no EPG, still work. + // + // Nothing supplies the horizon yet: the runner has no resolution path + // from a rule's `(target_id, virtual_id)` to that channel's stored + // programmes. Until one exists, say so out loud rather than looking + // like a scheduler that simply found nothing — a silently inert rule + // is indistinguishable from a rule that matched no programmes. + let epg_programmes: &[EpgProgramme] = &[]; + warn_about_inert_new_episode_rules(&file.rules, epg_programmes); + let candidates = plan_materializations( + &file.rules, + epg_programmes, + &tasks, + &file.tombstones, + Utc::now().timestamp(), + None, + ); + if candidates.is_empty() { + return Ok(()); + } + let service = RecordingService::from_app_state(app_state); + for candidate in candidates { + let claims = scheduler_claims(candidate.rule_owner_id.clone(), candidate.rule_visibility); + let input = CreateRecordingInput { + source: RecordingSourceInput { + target_id: candidate.source.target_id, + virtual_id: candidate.source.virtual_id, + cluster: XtreamCluster::Live, + input_name: candidate.source.input_name, + }, + program_title: candidate.programme_title, + program_start: candidate.programme_start, + program_end: candidate.programme_end, + pre_roll_secs: candidate.pre_roll_secs, + post_roll_secs: candidate.post_roll_secs, + visibility: match candidate.rule_visibility { + RuleVisibility::Private => RecordingVisibility::Private, + RuleVisibility::Shared => RecordingVisibility::Shared, + }, + channel_id: candidate.channel_id, + channel_name: candidate.channel_name, + provenance: RecordingProvenance { + rule_id: Some(candidate.rule_id), + occurrence_key: Some(candidate.occurrence_key), + }, + epg: None, + }; + match service.create_recording(&claims, &input).await { + Ok(view) => debug!("Materialized recording rule task {}", view.uuid), + Err(err) if err.code() == "recording_invalid_state" => {} + Err(err) => error!("Failed to materialize recording rule: {err}"), + } + } + Ok(()) +} + +/// Summarize every queue-resident recording for the reconciliation and +/// materialization planners. Shared with +/// [`recording_supervisor`](super::recording_supervisor), which needs the +/// same view at startup. +/// Warn when the operator has enabled `NewEpisode` rules that cannot +/// fire because no EPG horizon is available. +/// +/// Rate-limited to once per process: this runs every tick, and a warning +/// per minute per rule would bury the log it is trying to make visible. +fn warn_about_inert_new_episode_rules(rules: &[RecordingRule], epg_programmes: &[EpgProgramme]) { + static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + if !epg_programmes.is_empty() { + return; + } + let inert = rules + .iter() + .filter(|rule| rule.enabled && matches!(rule.body, RuleBody::NewEpisode { .. })) + .count(); + if inert == 0 { + return; + } + if WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) { + return; + } + log::warn!( + "{inert} enabled NewEpisode recording rule(s) cannot match: the scheduler has no EPG \ + horizon, so only WeeklyTimeslot rules materialize. Use a WeeklyTimeslot rule, or record \ + individual programmes from the EPG view, until the EPG horizon is wired into the scheduler." + ); +} + +pub async fn reconcilable_tasks(app_state: &AppState) -> Vec { + let mut tasks = Vec::new(); + for task in app_state.downloads.queue.lock().await.iter() { + push_reconcilable(&mut tasks, task); + } + for task in app_state.downloads.scheduled.read().await.iter() { + push_reconcilable(&mut tasks, task); + } + if let Some(task) = app_state.downloads.active.read().await.as_ref() { + push_reconcilable(&mut tasks, task); + } + for task in app_state.downloads.finished.read().await.iter() { + push_reconcilable(&mut tasks, task); + } + tasks +} + +fn push_reconcilable(tasks: &mut Vec, task: &FileDownload) { + let Some(meta) = task.recording.as_ref() else { + return; + }; + tasks.push(ReconcilableTask { + uuid: task.uuid.clone(), + rule_id: meta.provenance.rule_id.clone(), + occurrence_key: meta.provenance.occurrence_key.clone(), + terminal: matches!(task.state, DownloadState::Completed | DownloadState::Failed | DownloadState::Cancelled), + active: matches!(task.state, DownloadState::Downloading), + editable: matches!(task.state, DownloadState::Scheduled | DownloadState::Queued | DownloadState::Paused), + }); +} + +fn scheduler_claims(owner_id: shared::model::UserId, visibility: RuleVisibility) -> Claims { + let mut permissions = PermissionSet::new(); + permissions.set(Permission::RecordingWrite); + let roles = if visibility == RuleVisibility::Shared { + vec![ROLE_ADMIN.to_string()] + } else { + Vec::new() + }; + let now = Utc::now().timestamp(); + Claims { + username: "recording-scheduler".to_string(), + iss: "tuliprox".to_string(), + iat: now, + exp: now + 3600, + roles, + permissions, + pwd_version: 0, + subject_id: Some(owner_id), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::model::recording_reconciliation::ReconcilableTask; + use shared::model::recording_rule::{RuleSource, RuleVisibility}; + use shared::model::EpgProgramme; + use shared::model::UserId; + use shared::utils::Internable; + + fn user() -> UserId { UserId::from("web:alice") } + fn source() -> RuleSource { RuleSource::new("tgt", "virt", "input") } + fn new_episode_rule() -> RecordingRule { + RecordingRule { + id: "r1".into(), + owner_id: user(), + visibility: RuleVisibility::Private, + enabled: true, + source: source(), + channel_id: None, + body: RuleBody::NewEpisode { + series_id: None, + title_pattern: Some("My Show".into()), + exclude_repeat: true, + }, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 0, + updated_at: 0, + } + } + + fn weekly_rule() -> RecordingRule { + RecordingRule { + id: "r2".into(), + owner_id: user(), + visibility: RuleVisibility::Private, + enabled: true, + source: source(), + channel_id: Some("ch-1".into()), + body: RuleBody::WeeklyTimeslot { + weekday: 7, // Sunday + local_start_time: "20:00".into(), + duration_secs: 1800, + timezone: "UTC".into(), + }, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 0, + updated_at: 0, + } + } + + fn epg_programme(title: &str, start: i64, stop: i64) -> EpgProgramme { + let mut p = EpgProgramme::new(start, stop, "ch-1".intern()); + p.title = Some(title.intern()); + p + } + + fn task(rule_id: &str, key: &str) -> ReconcilableTask { + ReconcilableTask { + uuid: format!("{rule_id}-{key}"), + rule_id: Some(rule_id.into()), + occurrence_key: Some(key.into()), + terminal: false, + active: false, + editable: true, + } + } + + #[test] + fn new_episode_matches_and_materializes() { + let rules = vec![new_episode_rule()]; + let programmes = vec![epg_programme("My Show", 1_000, 2_000), epg_programme("Other", 1_000, 2_000)]; + let out = plan_materializations(&rules, &programmes, &[], &TombstoneSet::default(), 0, Some(10_000)); + assert_eq!(out.len(), 1); + assert_eq!(out[0].programme_title, "My Show"); + } + + #[test] + fn new_episode_dedupes_against_existing_task() { + let rules = vec![new_episode_rule()]; + let programmes = vec![epg_programme("My Show", 1_000, 2_000)]; + let key = occurrence_key("r1", &source(), "ch-1", 1_000, "t:my show"); + let tasks = vec![task("r1", &key)]; + let out = plan_materializations(&rules, &programmes, &tasks, &TombstoneSet::default(), 0, Some(10_000)); + assert!(out.is_empty()); + } + + #[test] + fn new_episode_skips_programs_in_the_past() { + let rules = vec![new_episode_rule()]; + let programmes = vec![epg_programme("My Show", -1, 100)]; + let out = plan_materializations(&rules, &programmes, &[], &TombstoneSet::default(), 0, Some(10_000)); + assert!(out.is_empty()); + } + + #[test] + fn new_episode_skips_programs_outside_epg_horizon() { + let rules = vec![new_episode_rule()]; + let programmes = vec![epg_programme("My Show", 5_000, 6_000)]; + let out = plan_materializations(&rules, &programmes, &[], &TombstoneSet::default(), 0, Some(2_000)); + assert!(out.is_empty()); + } + + #[test] + fn new_episode_skips_explicit_repeat() { + let rules = vec![new_episode_rule()]; + let mut p = epg_programme("My Show", 1_000, 2_000); + p.previously_shown = true; + let programmes = vec![p]; + let out = plan_materializations(&rules, &programmes, &[], &TombstoneSet::default(), 0, Some(10_000)); + assert!(out.is_empty()); + } + + #[test] + fn disabled_rule_does_not_materialize() { + let mut r = new_episode_rule(); + r.enabled = false; + let rules = vec![r]; + let programmes = vec![epg_programme("My Show", 1_000, 2_000)]; + let out = plan_materializations(&rules, &programmes, &[], &TombstoneSet::default(), 0, Some(10_000)); + assert!(out.is_empty()); + } + + #[test] + fn weekly_rule_uses_fallback_horizon_without_epg() { + let rules = vec![weekly_rule()]; + // Pick a Sunday 20:00 UTC; the next occurrence from + // 1970-01-01 (Thursday) is the next Sunday. + let now = 0; // 1970-01-01 + let out = plan_materializations(&rules, &[], &[], &TombstoneSet::default(), now, None); + assert_eq!(out.len(), 1); + let cand = &out[0]; + assert_eq!(cand.rule_id, "r2"); + assert!(cand.programme_start > now); + assert!(cand.programme_start - now <= WEEKLY_FALLBACK_HORIZON_SECS); + } + + #[test] + fn weekly_rule_skipped_when_outside_horizon() { + let rules = vec![weekly_rule()]; + let now = 0; + // Set the EPG horizon to 1 hour — the next Sunday is + // days away. + let out = plan_materializations(&rules, &[], &[], &TombstoneSet::default(), now, Some(3_600)); + assert!(out.is_empty()); + } + + #[test] + fn weekly_rule_skips_duration_that_overflows_end_timestamp() { + let mut rule = weekly_rule(); + if let RuleBody::WeeklyTimeslot { duration_secs, .. } = &mut rule.body { + *duration_secs = i64::MAX as u64; + } + + let out = plan_materializations(&[rule], &[], &[], &TombstoneSet::default(), 0, None); + + assert!(out.is_empty()); + } +} diff --git a/backend/src/api/model/recording/recording_rule_service.rs b/backend/src/api/model/recording/recording_rule_service.rs new file mode 100644 index 000000000..8dfca4aea --- /dev/null +++ b/backend/src/api/model/recording/recording_rule_service.rs @@ -0,0 +1,369 @@ +//! Rule service validation. +//! +//! Rule mutations enforce owner/private/shared authorization, validate +//! matching fields and parse the `future=retain|cancel` deletion policy. + + +use shared::model::recording_rule::{RecordingRule, RuleVisibility}; +use shared::model::UserId; + +/// The future-occurrence policy on rule deletion. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteFuture { + Retain, + Cancel, +} + +impl DeleteFuture { + pub fn parse(value: &str) -> Result { + match value { + "retain" => Ok(Self::Retain), + "cancel" => Ok(Self::Cancel), + _ => Err("recording_rule_invalid_future"), + } + } +} + +/// Stable service-layer errors for the rule service. The HTTP +/// handler maps each variant to a wire code; the frontend maps +/// the codes to localized messages. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleServiceError { + /// The authenticated principal lacks the recording.write + /// permission. + Forbidden, + /// A non-administrator tried to create, delete, or manage a shared rule. + SharedManagementNotAdministrator, + /// The supplied rule failed structural validation (see + /// `validate_rule`). + InvalidRule, + /// The supplied delete policy was not `retain` or `cancel`. + InvalidFuture, + /// Rule id missing or malformed. + UnknownRule, + /// The owner id on the rule did not match the authenticated + /// principal, and the principal is not an administrator. + NotOwner, + /// Persistence failed; the in-memory state was kept unchanged. + PersistenceFailed, + /// A cross-store second step failed. The HTTP handler returns + /// this with a reconciliation status the operator can use to + /// decide whether to retry. + PartialOperation { primary: String, secondary: String }, + /// The rule uses a feature the server knows about but does not + /// currently implement. The stable code names the feature so the + /// frontend can render a localized, feature-specific message. + Unsupported { feature: &'static str }, +} + +impl RuleServiceError { + pub fn code(&self) -> &'static str { + match self { + Self::Forbidden => "recording_rule_forbidden", + Self::SharedManagementNotAdministrator => "recording_shared_not_administrator", + Self::InvalidRule => "recording_rule_invalid", + Self::InvalidFuture => "recording_rule_invalid_future", + Self::UnknownRule => "recording_rule_unknown", + Self::NotOwner => "recording_rule_not_owner", + Self::PersistenceFailed => "recording_persistence_failed", + Self::PartialOperation { .. } => "recording_rule_partial_operation", + Self::Unsupported { feature } => match *feature { + "new_episode_rule" => "recording_rule_new_episode_unsupported", + _ => "recording_rule_unsupported", + }, + } + } +} + +/// Pure: validate the structural shape of a rule. Source id, +/// matching fields, timezone, local time, duration and padding. +pub fn validate_rule(rule: &RecordingRule) -> Result<(), RuleServiceError> { + rule.validate().map_err(|_| RuleServiceError::InvalidRule)?; + if matches!( + rule.body, + shared::model::recording_rule::RuleBody::WeeklyTimeslot { duration_secs, .. } + if i64::try_from(duration_secs).is_err() + ) { + return Err(RuleServiceError::InvalidRule); + } + if rule.pre_roll_secs > 15 * 60 { + return Err(RuleServiceError::InvalidRule); + } + if rule.post_roll_secs > 30 * 60 { + return Err(RuleServiceError::InvalidRule); + } + // `RuleBody::NewEpisode` is parsed and persisted, but the scheduler + // has no EPG horizon to match it against, so the rule would sit + // inert forever. Refuse it at the edge instead of letting an + // operator create a rule that never fires. + if matches!(rule.body, shared::model::recording_rule::RuleBody::NewEpisode { .. }) { + return Err(RuleServiceError::Unsupported { feature: "new_episode_rule" }); + } + Ok(()) +} + +/// The principal / owner / admin decision for any rule mutation: +/// - read: any user with `recording.read`. +/// - create / edit / delete private rule: any user with +/// `recording.write`. Owner must be the principal unless +/// the principal is an administrator. +/// - create / edit / delete shared rule: administrator with +/// `recording.write`. +pub fn authorize_rule_action( + has_recording_write: bool, + is_admin_role: bool, + principal_id: &UserId, + rule: &RecordingRule, +) -> Result<(), RuleServiceError> { + if !has_recording_write { + return Err(RuleServiceError::Forbidden); + } + match rule.visibility { + RuleVisibility::Shared => { + if !is_admin_role { + return Err(RuleServiceError::SharedManagementNotAdministrator); + } + } + RuleVisibility::Private => { + if !is_admin_role && &rule.owner_id != principal_id { + return Err(RuleServiceError::NotOwner); + } + } + } + Ok(()) +} + +/// Validate a delete request. The `future` parameter is required +/// and accepts only `retain` or `cancel`. +pub fn validate_delete(future: Option<&str>) -> Result { + let raw = future.ok_or(RuleServiceError::InvalidFuture)?; + DeleteFuture::parse(raw).map_err(|_| RuleServiceError::InvalidFuture) +} + +/// Per-task policy for the `cancel` delete option. Only future +/// inactive occurrences are cancellable; active recordings are not. +/// The caller filters tasks by scheduled interval. +pub fn cancel_targets_task(task_active: bool, task_editable: bool) -> bool { + // Active recordings are never auto-cancelled by rule deletion. + // Editable tasks (upcoming states) are eligible. + !task_active && task_editable +} + +/// Pure: the per-task policy for the `retain` delete option. +/// Existing tasks keep their `rule_id` so reconciliation can still +/// group them. The caller does not modify the task's metadata; it +/// only sets the rule's `enabled = false`. +pub fn retain_targets_task(_task_uuid: &str) -> bool { true } + +/// A summary the HTTP handler can serialize when the cross-store +/// second step fails. The response carries a partial-operation +/// status so the client can show the user what state the system +/// is in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PartialOperationStatus { + pub primary: String, + pub secondary: String, +} + +impl From<&RuleServiceError> for Option { + fn from(err: &RuleServiceError) -> Self { + if let RuleServiceError::PartialOperation { primary, secondary } = err { + Some(PartialOperationStatus { primary: primary.clone(), secondary: secondary.clone() }) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::recording_rule::{RuleBody, RuleSource}; + use shared::model::UserId; + + fn user() -> UserId { UserId::from("web:alice") } + fn other() -> UserId { UserId::from("web:bob") } + fn admin() -> UserId { UserId::from("builtin:admin") } + + fn private_rule(owner: UserId) -> RecordingRule { + RecordingRule { + id: "r1".into(), + owner_id: owner, + visibility: RuleVisibility::Private, + enabled: true, + source: RuleSource::new("tgt", "virt", "input"), + channel_id: None, + body: RuleBody::NewEpisode { + series_id: Some("series-1".into()), + title_pattern: None, + exclude_repeat: true, + }, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 0, + updated_at: 0, + } + } + + fn shared_rule() -> RecordingRule { + let mut r = private_rule(admin()); + r.visibility = RuleVisibility::Shared; + r + } + + #[test] + fn validate_rule_rejects_invalid_source() { + let mut r = private_rule(user()); + r.source = RuleSource::new("", "virt", "input"); + assert_eq!(validate_rule(&r), Err(RuleServiceError::InvalidRule)); + } + + #[test] + fn validate_rule_rejects_excessive_padding() { + let mut r = private_rule(user()); + r.pre_roll_secs = 16 * 60; + assert_eq!(validate_rule(&r), Err(RuleServiceError::InvalidRule)); + let mut r = private_rule(user()); + r.post_roll_secs = 31 * 60; + assert_eq!(validate_rule(&r), Err(RuleServiceError::InvalidRule)); + } + + #[test] + fn validate_rule_rejects_duration_larger_than_i64() { + let mut r = private_rule(user()); + r.body = RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".to_string(), + duration_secs: u64::MAX, + timezone: "UTC".to_string(), + }; + + assert_eq!(validate_rule(&r), Err(RuleServiceError::InvalidRule)); + } + + #[test] + fn validate_rule_accepts_within_bounds() { + // The body is incidental to this assertion; the helper builds + // a `NewEpisode` body which `validate_rule` now refuses for + // unrelated reasons. Swap to `WeeklyTimeslot` so the padding + // bounds are what gets tested. + let mut r = private_rule(user()); + r.body = RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".to_string(), + duration_secs: 3_600, + timezone: "UTC".to_string(), + }; + r.pre_roll_secs = 15 * 60; + r.post_roll_secs = 30 * 60; + assert!(validate_rule(&r).is_ok()); + } + + #[test] + fn validate_rule_rejects_new_episode_until_epg_horizon_is_wired() { + // The scheduler has no EPG horizon yet, so a `NewEpisode` rule + // would sit inert forever. Validation refuses it with a stable, + // feature-specific code so the frontend can render an actionable + // message and so removing the guard later is a single, + // searchable edit. + let mut r = private_rule(user()); + r.body = RuleBody::NewEpisode { + series_id: Some("series-1".into()), + title_pattern: Some("News".into()), + exclude_repeat: false, + }; + assert_eq!( + validate_rule(&r), + Err(RuleServiceError::Unsupported { feature: "new_episode_rule" }) + ); + assert_eq!( + validate_rule(&r).err().map(|e| e.code()), + Some("recording_rule_new_episode_unsupported") + ); + } + + #[test] + fn authorize_requires_recording_write() { + let r = private_rule(user()); + assert_eq!(authorize_rule_action(false, false, &user(), &r), Err(RuleServiceError::Forbidden)); + } + + #[test] + fn private_rule_owner_may_manage() { + let r = private_rule(user()); + assert!(authorize_rule_action(true, false, &user(), &r).is_ok()); + } + + #[test] + fn private_rule_other_user_may_not_manage_without_admin() { + let r = private_rule(user()); + assert_eq!(authorize_rule_action(true, false, &other(), &r), Err(RuleServiceError::NotOwner)); + } + + #[test] + fn administrator_may_manage_any_private_rule() { + let r = private_rule(user()); + assert!(authorize_rule_action(true, true, &admin(), &r).is_ok()); + } + + #[test] + fn shared_rule_requires_administrator() { + let r = shared_rule(); + assert_eq!( + authorize_rule_action(true, false, &user(), &r), + Err(RuleServiceError::SharedManagementNotAdministrator) + ); + assert!(authorize_rule_action(true, true, &admin(), &r).is_ok()); + } + + #[test] + fn delete_future_parses_retain_and_cancel() { + assert_eq!(DeleteFuture::parse("retain").unwrap(), DeleteFuture::Retain); + assert_eq!(DeleteFuture::parse("cancel").unwrap(), DeleteFuture::Cancel); + assert!(DeleteFuture::parse("bogus").is_err()); + } + + #[test] + fn validate_delete_requires_retain_or_cancel() { + assert!(matches!(validate_delete(None), Err(RuleServiceError::InvalidFuture))); + assert!(matches!(validate_delete(Some("bogus")), Err(RuleServiceError::InvalidFuture))); + assert!(validate_delete(Some("retain")).is_ok()); + assert!(validate_delete(Some("cancel")).is_ok()); + } + + #[test] + fn cancel_targets_only_upcoming_inactive_tasks() { + assert!(!cancel_targets_task(true, false)); + assert!(!cancel_targets_task(true, true)); + assert!(cancel_targets_task(false, true)); + assert!(!cancel_targets_task(false, false)); + } + + #[test] + fn retain_targets_every_task() { + assert!(retain_targets_task("u1")); + assert!(retain_targets_task("u2")); + } + + #[test] + fn error_codes_are_stable() { + assert_eq!(RuleServiceError::Forbidden.code(), "recording_rule_forbidden"); + assert_eq!( + RuleServiceError::SharedManagementNotAdministrator.code(), + "recording_shared_not_administrator" + ); + assert_eq!(RuleServiceError::InvalidRule.code(), "recording_rule_invalid"); + assert_eq!(RuleServiceError::InvalidFuture.code(), "recording_rule_invalid_future"); + assert_eq!(RuleServiceError::UnknownRule.code(), "recording_rule_unknown"); + assert_eq!(RuleServiceError::NotOwner.code(), "recording_rule_not_owner"); + assert_eq!(RuleServiceError::PersistenceFailed.code(), "recording_persistence_failed"); + assert_eq!( + RuleServiceError::PartialOperation { + primary: "rule".into(), + secondary: "tombstone".into() + } + .code(), + "recording_rule_partial_operation" + ); + } +} diff --git a/backend/src/api/model/recording/recording_security.rs b/backend/src/api/model/recording/recording_security.rs new file mode 100644 index 000000000..1fb73e2a8 --- /dev/null +++ b/backend/src/api/model/recording/recording_security.rs @@ -0,0 +1,327 @@ +//! Stable recording errors and security revalidation. +//! +//! - Implement stable mappings for at least the 18 errors in the +//! stable-wire-code table. +//! - Reauthorize immediately before every metadata conversion +//! and file open. +//! - Re-resolve source immediately before worker execution. +//! - Revalidate state after any external filesystem measurement +//! before committing a mutation. +//! - Ensure no handler logs-and-ignores persistence failure. +//! - Redact private title, channel, filename, user ID and rule +//! metadata from non-owner-facing logs. +//! - Security tests for path traversal, symlink swaps, source +//! tampering, stale claims, foreign private access, forged +//! visibility and event leakage. +//! +//! Most stable codes already live in +//! `recording_service::ServiceError`. This module adds: +//! - The wire-code map. +//! - Path / source / state revalidation guards the queue-mutation +//! boundary runs before committing. +//! - Log-redaction helpers that strip private metadata. + + +use shared::model::recording::{RecordingMetadata, RecordingOwner, RecordingVisibility}; +use shared::model::UserId; + +/// The full stable-wire-code table. At least 18 codes are required; +/// the existing `ServiceError` already exposes the runtime +/// variants. This enum is the wire-side surface; the HTTP layer +/// maps each variant to the response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordingErrorCode { + NotFound, + AccessDenied, + StateNotEditable, + DeletionInProgress, + InvalidInterval, + PaddingLimitExceeded, + InvalidTemplate, + InvalidSource, + DuplicateOccurrence, + QuotaExceeded, + InsufficientDisk, + UnsafePath, + DeleteFailed, + PersistenceFailed, + RuleInvalid, + ConflictPreviewUnavailable, + TokenRefreshRequired, + PartialOperation, +} + +impl RecordingErrorCode { + pub fn wire(self) -> &'static str { + match self { + Self::NotFound => "recording_not_found", + Self::AccessDenied => "recording_access_denied", + Self::StateNotEditable => "recording_state_not_editable", + Self::DeletionInProgress => "recording_deletion_in_progress", + Self::InvalidInterval => "recording_invalid_interval", + Self::PaddingLimitExceeded => "recording_padding_limit_exceeded", + Self::InvalidTemplate => "recording_invalid_template", + Self::InvalidSource => "recording_invalid_source", + Self::DuplicateOccurrence => "recording_duplicate_occurrence", + Self::QuotaExceeded => "recording_quota_exceeded", + Self::InsufficientDisk => "recording_insufficient_disk", + Self::UnsafePath => "recording_unsafe_path", + Self::DeleteFailed => "recording_delete_failed", + Self::PersistenceFailed => "recording_persistence_failed", + Self::RuleInvalid => "recording_rule_invalid", + Self::ConflictPreviewUnavailable => "recording_conflict_preview_unavailable", + Self::TokenRefreshRequired => "recording_token_refresh_required", + Self::PartialOperation => "recording_rule_partial_operation", + } + } +} + +/// Path revalidation that runs in the queue-mutation boundary +/// *after* an external filesystem +/// measurement but *before* committing. The caller passes the +/// pre-measurement state, the post-measurement state, and the +/// (now-fresh) source the worker would re-resolve against. +pub fn revalidate_after_measurement( + pre_state_label: &str, + post_state_label: &str, + source_unchanged: bool, +) -> Result<(), RecordingErrorCode> { + if pre_state_label != post_state_label { + return Err(RecordingErrorCode::StateNotEditable); + } + if !source_unchanged { + return Err(RecordingErrorCode::InvalidSource); + } + Ok(()) +} + +/// Path revalidation that runs immediately before a file open. +/// The caller passes the relative path the metadata +/// claims. The function rejects traversal sequences (`..`, +/// absolute paths, NULs). +pub fn validate_relative_path_for_open(relative_path: &str) -> Result<(), RecordingErrorCode> { + use std::path::{Component, Path}; + if relative_path.is_empty() || relative_path.contains('\0') { + return Err(RecordingErrorCode::UnsafePath); + } + let path = Path::new(relative_path); + if path.is_absolute() { + return Err(RecordingErrorCode::UnsafePath); + } + // Only `Component::Normal` segments are accepted — `..` and `.` and any + // root/prefix components are rejected. Names like `ep..1` are valid + // because the dot is part of the segment, not a parent-dir component. + let components: Vec<_> = path.components().collect(); + if components.is_empty() || !components.iter().all(|c| matches!(c, Component::Normal(_))) { + return Err(RecordingErrorCode::UnsafePath); + } + Ok(()) +} + +/// Log redaction. Private title, channel, filename, user ID, and +/// rule metadata must be redacted from +/// non-owner-facing logs. The helper returns the fields that are +/// safe to log given the caller's principal. +pub fn redact_for_log( + meta: &RecordingMetadata, + is_admin_role: bool, + is_owner: bool, +) -> RedactedRecording { + let show_private = is_admin_role || is_owner; + RedactedRecording { + task_id_present: meta.relative_path.is_some() || meta.partial_relative_path.is_some(), + visibility: meta.visibility, + owner: if show_private { Some(meta.owner.clone()) } else { None }, + programme_title: if show_private { meta.program_title.clone() } else { None }, + channel: if show_private { meta.channel_name.clone() } else { None }, + output_filename: if show_private { meta.relative_path.clone() } else { None }, + rule_id: if show_private { meta.provenance.rule_id.clone() } else { None }, + occurrence_key: if show_private { meta.provenance.occurrence_key.clone() } else { None }, + } +} + +/// The redacted log shape. Each optional field is `None` when +/// the principal is not the owner and not an administrator. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RedactedRecording { + pub task_id_present: bool, + pub visibility: RecordingVisibility, + pub owner: Option, + pub programme_title: Option, + pub channel: Option, + pub output_filename: Option, + pub rule_id: Option, + pub occurrence_key: Option, +} + +/// Check whether the principal can read this recording. Used in +/// every authorization decision (HTTP, WebSocket, catalog). The +/// read-access matrix: +/// - private + owner = allow. +/// - private + admin = allow. +/// - private + foreign user = deny. +/// - shared + anyone with `recording.read` = allow. +pub fn authorize_read( + meta: &RecordingMetadata, + principal_id: &UserId, + has_recording_read: bool, + is_admin_role: bool, +) -> bool { + if !has_recording_read { + return false; + } + if is_admin_role { + return true; + } + if matches!(meta.visibility, RecordingVisibility::Shared) { + return true; + } + if let RecordingOwner::User(owner_id) = &meta.owner { + return owner_id == principal_id; + } + // LegacyAdmin: only administrators can read (the admin check + // above already handled that path). + false +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::recording::{RecordingOwner, RecordingVisibility}; + use shared::model::UserId; + + fn user(name: &str) -> UserId { UserId::from(name) } + + fn make_meta(visibility: RecordingVisibility, owner: RecordingOwner) -> RecordingMetadata { + RecordingMetadata { + owner, + visibility, + source: None, + program_start: Some(1_700_000_000), + program_end: Some(1_700_003_600), + scheduled_start: Some(1_700_000_000), + scheduled_end: Some(1_700_003_600), + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: Some("ch-1".into()), + channel_name: Some("Channel 1".into()), + program_title: Some("Programme".into()), + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: Some("path/file.ts".into()), + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: 0, + completed_at: None, + notification_markers: vec![], + deleting_previous_state: None, + } + } + + #[test] + fn wire_codes_match_plan_table() { + assert_eq!(RecordingErrorCode::NotFound.wire(), "recording_not_found"); + assert_eq!(RecordingErrorCode::AccessDenied.wire(), "recording_access_denied"); + assert_eq!(RecordingErrorCode::StateNotEditable.wire(), "recording_state_not_editable"); + assert_eq!(RecordingErrorCode::DeletionInProgress.wire(), "recording_deletion_in_progress"); + assert_eq!(RecordingErrorCode::InvalidInterval.wire(), "recording_invalid_interval"); + assert_eq!(RecordingErrorCode::PaddingLimitExceeded.wire(), "recording_padding_limit_exceeded"); + assert_eq!(RecordingErrorCode::InvalidTemplate.wire(), "recording_invalid_template"); + assert_eq!(RecordingErrorCode::InvalidSource.wire(), "recording_invalid_source"); + assert_eq!(RecordingErrorCode::DuplicateOccurrence.wire(), "recording_duplicate_occurrence"); + assert_eq!(RecordingErrorCode::QuotaExceeded.wire(), "recording_quota_exceeded"); + assert_eq!(RecordingErrorCode::InsufficientDisk.wire(), "recording_insufficient_disk"); + assert_eq!(RecordingErrorCode::UnsafePath.wire(), "recording_unsafe_path"); + assert_eq!(RecordingErrorCode::DeleteFailed.wire(), "recording_delete_failed"); + assert_eq!(RecordingErrorCode::PersistenceFailed.wire(), "recording_persistence_failed"); + assert_eq!(RecordingErrorCode::RuleInvalid.wire(), "recording_rule_invalid"); + assert_eq!( + RecordingErrorCode::ConflictPreviewUnavailable.wire(), + "recording_conflict_preview_unavailable" + ); + assert_eq!(RecordingErrorCode::TokenRefreshRequired.wire(), "recording_token_refresh_required"); + assert_eq!(RecordingErrorCode::PartialOperation.wire(), "recording_rule_partial_operation"); + } + + #[test] + fn revalidate_after_measurement_rejects_state_drift() { + assert!(revalidate_after_measurement("Downloading", "Downloading", true).is_ok()); + assert_eq!( + revalidate_after_measurement("Downloading", "Completed", true), + Err(RecordingErrorCode::StateNotEditable) + ); + } + + #[test] + fn revalidate_after_measurement_rejects_source_drift() { + assert_eq!( + revalidate_after_measurement("Downloading", "Downloading", false), + Err(RecordingErrorCode::InvalidSource) + ); + } + + #[test] + fn validate_relative_path_rejects_traversal_and_absolute() { + assert!(validate_relative_path_for_open("users/web:alice/file.ts").is_ok()); + assert_eq!(validate_relative_path_for_open("../etc/passwd"), Err(RecordingErrorCode::UnsafePath)); + assert_eq!(validate_relative_path_for_open("/etc/passwd"), Err(RecordingErrorCode::UnsafePath)); + assert_eq!(validate_relative_path_for_open("dir\0file"), Err(RecordingErrorCode::UnsafePath)); + assert_eq!(validate_relative_path_for_open(""), Err(RecordingErrorCode::UnsafePath)); + } + + #[test] + fn redact_for_log_hides_private_for_foreign_user() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::User(user("web:alice"))); + let r = redact_for_log(&meta, false, false); + assert!(r.owner.is_none()); + assert!(r.programme_title.is_none()); + assert!(r.channel.is_none()); + assert!(r.output_filename.is_none()); + } + + #[test] + fn redact_for_log_shows_for_owner() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::User(user("web:alice"))); + let r = redact_for_log(&meta, false, true); + assert!(matches!(r.owner, Some(RecordingOwner::User(_)))); + assert_eq!(r.programme_title.as_deref(), Some("Programme")); + } + + #[test] + fn redact_for_log_shows_for_admin() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::User(user("web:alice"))); + let r = redact_for_log(&meta, true, false); + assert!(matches!(r.owner, Some(RecordingOwner::User(_)))); + } + + #[test] + fn authorize_read_private_owner_allowed() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::User(user("web:alice"))); + assert!(authorize_read(&meta, &user("web:alice"), true, false)); + } + + #[test] + fn authorize_read_private_foreign_user_denied() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::User(user("web:alice"))); + assert!(!authorize_read(&meta, &user("web:bob"), true, false)); + } + + #[test] + fn authorize_read_shared_any_recording_read_allowed() { + let meta = make_meta(RecordingVisibility::Shared, RecordingOwner::User(user("web:alice"))); + assert!(authorize_read(&meta, &user("web:bob"), true, false)); + } + + #[test] + fn authorize_read_without_recording_read_denied() { + let meta = make_meta(RecordingVisibility::Shared, RecordingOwner::User(user("web:alice"))); + assert!(!authorize_read(&meta, &user("web:bob"), false, false)); + } + + #[test] + fn authorize_read_legacy_admin_only_for_administrator() { + let meta = make_meta(RecordingVisibility::Private, RecordingOwner::LegacyAdmin); + assert!(!authorize_read(&meta, &user("web:alice"), true, false)); + assert!(authorize_read(&meta, &user("builtin:admin"), true, true)); + } +} diff --git a/backend/src/api/model/recording/recording_service.rs b/backend/src/api/model/recording/recording_service.rs new file mode 100644 index 000000000..ce2ee1b1f --- /dev/null +++ b/backend/src/api/model/recording/recording_service.rs @@ -0,0 +1,2128 @@ +//! Recording mutation service. + +use std::{collections::HashMap, path::Path, sync::Arc}; + +use crate::api::model::app_state::AppState; +use crate::api::endpoints::v1_api_playlist; +use crate::api::model::download::{ + mutate, DownloadKind, DownloadQueue, DownloadState, FileDownload, PersistedDownloadQueue, + PersistedFileDownload, QueueMutationError, +}; +use crate::api::model::recording_quota::{self, AdmissionOutcome, QuotaLimits, QuotaPool}; +use crate::api::model::recording_edit::{self, EditError, PaddingBounds}; +use crate::auth::{ + authorize, authorize_orphan, RecordingAction, RecordingDecision, RecordingSubject, TerminalState, +}; +use crate::api::model::recording_deletion::{ + begin_deletion_authorized, execute_deletion_target, finalize_deletion, rollback_deletion, + DeletionError, +}; +use crate::model::AppConfig; +use shared::model::recording::{ + RecordingMetadata, RecordingOwner, RecordingProvenance, RecordingSource, RecordingVisibility, +}; +use shared::model::{UserId, XtreamCluster}; + +/// Server-resolved identifiers that the recording system needs. The +/// caller never sees the URL; the service resolves it from these. +#[derive(Debug, Clone)] +pub struct RecordingSourceInput { + pub target_id: String, + pub virtual_id: String, + pub cluster: XtreamCluster, + pub input_name: String, +} + +impl RecordingSourceInput { + pub fn validate(&self) -> Result<(), ServiceError> { + if self.target_id.trim().is_empty() || self.virtual_id.trim().is_empty() || self.input_name.trim().is_empty() { + return Err(ServiceError::InvalidSource); + } + Ok(()) + } +} + +/// Input for `RecordingService::create_recording`. +#[derive(Debug, Clone)] +pub struct CreateRecordingInput { + pub source: RecordingSourceInput, + pub program_title: String, + pub program_start: i64, + pub program_end: i64, + pub pre_roll_secs: u64, + pub post_roll_secs: u64, + pub visibility: RecordingVisibility, + pub channel_id: Option, + pub channel_name: Option, + pub provenance: RecordingProvenance, + pub epg: Option, +} + +impl CreateRecordingInput { + pub fn validate(&self) -> Result<(), ServiceError> { + self.source.validate()?; + if self.program_end.checked_sub(self.program_start).is_none_or(|duration| duration <= 0) { + return Err(ServiceError::InvalidInterval); + } + Ok(()) + } +} + +/// Stable service-layer errors. The HTTP layer maps each variant to a +/// stable status; the frontend maps each variant to a localized +/// message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ServiceError { + /// The recording owner could not be resolved from the authenticated + /// claims (`subject_id` missing or invalid). + UnknownOwner, + /// The supplied `RecordingSource` does not match a configured + /// target/input combination. + InvalidSource, + /// Caller asked for an action the principal cannot perform. + Forbidden, + /// Caller asked for shared creation but is not an administrator. + SharedCreationNotAdministrator, + /// Caller asked to act on a recording that is in an ineligible + /// state (e.g., edit on a recording marked `Deleting`). + InvalidState, + /// `program_end - program_start` overflows or is non-positive. + InvalidInterval, + /// Requested padding exceeds the configured recording maximum. + PaddingLimitExceeded, + /// uuid not in the queue. + UnknownRecording, + /// `mutate`'s persist step failed and the in-memory state was + /// kept unchanged. + PersistenceFailed, + /// IO error during physical deletion. + IoError(String), + /// Configured recording quota would be exceeded. + QuotaExceeded, + /// The patch would clear `rule_id` / `occurrence_key`. Both are + /// immutable provenance; surfacing this as `InvalidState` hid the + /// real reason from the client. + ProvenanceImmutable, + /// Caller tried to create a recording that already exists in the + /// queue (same target / window). Distinct from `InvalidState` so + /// the client can render a specific "duplicate" message. + Duplicate, + /// The recording's filesystem path is not within the configured + /// storage root, or otherwise violates the path policy. + InvalidPath, + /// The recording cannot fit on disk; reservation would exceed + /// available space. + DiskFull, +} + +impl std::fmt::Display for ServiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for ServiceError {} + +impl ServiceError { + /// Stable wire-level code. + pub fn code(&self) -> &'static str { + match self { + Self::UnknownOwner => "recording_unknown_owner", + Self::InvalidSource => "recording_invalid_source", + Self::Forbidden => "recording_forbidden", + Self::SharedCreationNotAdministrator => "recording_shared_not_administrator", + Self::InvalidState => "recording_invalid_state", + Self::InvalidInterval => "recording_invalid_interval", + Self::PaddingLimitExceeded => "recording_padding_limit_exceeded", + Self::UnknownRecording => "recording_unknown", + Self::PersistenceFailed => "recording_persistence_failed", + Self::IoError(_) => "recording_io_error", + Self::QuotaExceeded => "recording_quota_exceeded", + Self::ProvenanceImmutable => "recording_provenance_immutable", + Self::Duplicate => "recording_duplicate", + Self::InvalidPath => "recording_invalid_path", + Self::DiskFull => "recording_disk_full", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EffectiveRecordingWindow { + scheduled_start: i64, + scheduled_end: i64, + execution_start: i64, + remaining_duration_secs: u64, +} + +fn effective_recording_window( + program_start: i64, + program_end: i64, + pre_roll_secs: u64, + post_roll_secs: u64, + now: i64, +) -> Result { + if program_end <= program_start { + return Err(ServiceError::InvalidInterval); + } + let pre_roll = i64::try_from(pre_roll_secs).map_err(|_| ServiceError::PaddingLimitExceeded)?; + let post_roll = i64::try_from(post_roll_secs).map_err(|_| ServiceError::PaddingLimitExceeded)?; + let scheduled_start = program_start.saturating_sub(pre_roll); + let scheduled_end = program_end.saturating_add(post_roll); + let execution_start = now.max(scheduled_start); + // `scheduled_end >= execution_start` because both come from + // saturating arithmetic on a non-empty interval, so the cast is + // safe and the only remaining error is the degenerate + // already-finished window. + let remaining = scheduled_end.saturating_sub(execution_start); + if remaining <= 0 { + return Err(ServiceError::InvalidInterval); + } + let remaining_duration_secs = remaining.cast_unsigned(); + Ok(EffectiveRecordingWindow { + scheduled_start, + scheduled_end, + execution_start, + remaining_duration_secs, + }) +} + +fn padding_bounds(recording: Option<&crate::model::RecordingConfig>) -> PaddingBounds { + recording.map_or( + PaddingBounds { + max_pre_roll_secs: shared::model::default_recording_max_pre_roll_secs(), + max_post_roll_secs: shared::model::default_recording_max_post_roll_secs(), + }, + |config| PaddingBounds { + max_pre_roll_secs: config.max_pre_roll_secs, + max_post_roll_secs: config.max_post_roll_secs, + }, + ) +} + +fn map_edit_validation_error(error: &EditError) -> ServiceError { + match error { + EditError::InvalidInterval => ServiceError::InvalidInterval, + EditError::PaddingLimitExceeded => ServiceError::PaddingLimitExceeded, + EditError::ProvenanceCleared => ServiceError::ProvenanceImmutable, + EditError::StateNotEditable | EditError::ChannelChangedWithoutProgramme => { + ServiceError::InvalidState + } + } +} + +fn map_deletion_error(error: DeletionError) -> ServiceError { + match error { + DeletionError::Forbidden => ServiceError::Forbidden, + DeletionError::NotTerminal => ServiceError::InvalidState, + DeletionError::UnknownTask | DeletionError::NotARecording => ServiceError::UnknownRecording, + DeletionError::DeleteFailed(err) => ServiceError::IoError(err.to_string()), + DeletionError::BeginFailed(err) | DeletionError::FinalizeFailed(err) => { + if err.source_io().is_some() { + ServiceError::PersistenceFailed + } else { + ServiceError::UnknownRecording + } + } + } +} + +/// Output of `RecordingService::create_recording` and friends. +#[derive(Debug, Clone)] +pub struct RecordingTaskView { + pub uuid: String, + pub owner_id: UserId, + pub visibility: RecordingVisibility, + pub filename_preview: String, + pub start_at: Option, + pub duration_secs: Option, + pub state: DownloadState, +} + +/// Input for `RecordingService::edit_recording`. +#[derive(Debug, Clone, Default)] +pub struct EditRecordingPatch { + pub program_start: Option, + pub program_end: Option, + pub pre_roll_secs: Option, + pub post_roll_secs: Option, + pub program_title: Option, + pub channel_id: Option, + pub channel_name: Option, +} + +/// Recording mutation boundary. Holds the queue and app config directly +/// so `AppState` does not carry a back-reference to the service. +pub struct RecordingService { + downloads: Arc, + app_config: Arc, +} + +impl RecordingService { + /// Construct from the queue and app config. + pub fn new(downloads: Arc, app_config: Arc) -> Self { + Self { downloads, app_config } + } + + /// Convenience constructor from `Arc`. Avoids a + /// back-reference cycle by extracting the dependencies the + /// service actually needs. + pub fn from_app_state(app_state: &Arc) -> Self { + Self::new(app_state.downloads.clone(), app_state.app_config.clone()) + } + + fn subject_id(claims: &shared::model::Claims) -> Result { + claims.subject_id.clone().ok_or(ServiceError::UnknownOwner) + } + + fn recording_url(&self, source: &RecordingSourceInput) -> Option { + let virtual_id = source.virtual_id.parse::().ok()?; + v1_api_playlist::resolve_recording_target(&self.app_config, &source.target_id, &source.input_name)?; + v1_api_playlist::build_recording_source_descriptor( + &source.target_id, + &source.input_name, + virtual_id, + source.cluster, + ) + } + + /// Create a new recording. Enforces well-formedness, source + /// validity, owner from claims, and the authorization matrix. + #[allow(clippy::too_many_lines)] + pub async fn create_recording( + &self, + claims: &shared::model::Claims, + input: &CreateRecordingInput, + ) -> Result { + input.validate()?; + let owner_id = Self::subject_id(claims)?; + let config = self.app_config.config.load(); + let Some(download_cfg) = config.video.as_ref().and_then(|v| v.download.as_ref()) else { + return Err(ServiceError::InvalidSource); + }; + let recording_cfg = download_cfg.recording.as_ref(); + recording_edit::validate_padding( + input.pre_roll_secs, + input.post_roll_secs, + padding_bounds(recording_cfg), + ) + .map_err(|error: EditError| map_edit_validation_error(&error))?; + let window = effective_recording_window( + input.program_start, + input.program_end, + input.pre_roll_secs, + input.post_roll_secs, + chrono::Utc::now().timestamp(), + )?; + let url = self.recording_url(&input.source).ok_or(ServiceError::InvalidSource)?; + + // Shared creation requires admin. + authorize_create_recording(claims, &owner_id, input.visibility)?; + + let duration_secs = window.remaining_duration_secs; + let priority = download_cfg.recording_priority; + let filename = render_filename_preview(input); + let input_name: Option> = (!input.source.input_name.trim().is_empty()) + .then(|| Arc::from(input.source.input_name.as_str())); + let mut recording = FileDownload::new_recording( + &url, + &filename, + download_cfg, + window.execution_start, + duration_secs, + input_name, + priority, + ) + .ok_or(ServiceError::InvalidSource)?; + let source = RecordingSource::new( + input.source.target_id.clone(), + input.source.virtual_id.clone(), + input.source.input_name.clone(), + ) + .with_cluster(input.source.cluster); + let mut meta = RecordingMetadata::new( + RecordingOwner::User(owner_id.clone()), + input.visibility, + source, + input.program_start, + input.program_end, + input.pre_roll_secs, + input.post_roll_secs, + ); + meta.scheduled_start = Some(window.scheduled_start); + meta.scheduled_end = Some(window.scheduled_end); + meta.channel_id.clone_from(&input.channel_id); + meta.channel_name.clone_from(&input.channel_name); + meta.program_title = Some(input.program_title.clone()); + meta.provenance = input.provenance.clone(); + meta.epg.clone_from(&input.epg); + let fallback_bytes_per_minute = recording_cfg.map_or(8 * 1024 * 1024, |cfg| cfg.fallback_bytes_per_minute); + let (reserved_bytes, _) = recording_quota::estimate_reservation(duration_secs, 0, fallback_bytes_per_minute); + meta.reserved_bytes = reserved_bytes; + recording.recording = Some(meta); + let mut persisted = DownloadQueue::to_persisted(&recording); + let view_task = recording.clone(); + let quota_limits = quota_limits_from_config(recording_cfg.and_then(|cfg| cfg.quota.as_ref())); + + mutate(&self.downloads, |candidate| { + reserve_recording_relative_path(candidate, &mut persisted)?; + if candidate_has_duplicate_recording(candidate, &view_task) { + return Err(QueueMutationError::Duplicate); + } + let pool = recording_quota::quota_pool_for_task(&persisted) + .ok_or(QueueMutationError::InvalidQuotaPool)?; + let used = used_bytes_for_pool(candidate, &pool); + if matches!( + recording_quota::would_exceed(&pool, used, reserved_bytes, "a_limits), + AdmissionOutcome::OverLimit { .. } + ) { + return Err(QueueMutationError::QuotaExceeded); + } + candidate.scheduled.push(persisted); + Ok(()) + }) + .await + .map_err(|e| map_queue_error(&e))?; + + Ok(RecordingTaskView { + uuid: view_task.uuid, + owner_id, + visibility: input.visibility, + filename_preview: view_task.filename, + start_at: Some(window.execution_start), + duration_secs: Some(duration_secs), + state: DownloadState::Scheduled, + }) + } + + /// Edit an existing recording. + #[allow(clippy::too_many_lines)] + pub async fn edit_recording( + &self, + claims: &shared::model::Claims, + uuid: &str, + patch: EditRecordingPatch, + ) -> Result { + let owner_id = Self::subject_id(claims)?; + let config = self.app_config.config.load(); + // Edit does not re-resolve a URL. The recording config is + // only needed for padding bounds, the unknown-bitrate fallback, + // and quota limits. When the recording block is absent the + // helper falls back to the shared-model defaults, so a + // configured-without-recording deployment still validates + // edits. + let recording_cfg = config + .video + .as_ref() + .and_then(|v| v.download.as_ref()) + .and_then(|dl| dl.recording.as_ref()); + let bounds = padding_bounds(recording_cfg); + let fallback_bytes_per_minute = + recording_cfg.map_or(8 * 1024 * 1024, |cfg| cfg.fallback_bytes_per_minute); + let quota_limits = quota_limits_from_config(recording_cfg.and_then(|cfg| cfg.quota.as_ref())); + let mut out = None; + mutate(&self.downloads, |candidate| { + // Single linear scan: locate the recording, snapshot the + // primitives we need for the immutable analysis, drop the + // borrow, run the checks, then re-acquire the same task + // via the remembered location for the O(1) write phase. + let location = locate_recording(candidate, uuid) + .ok_or(QueueMutationError::UnknownRecording)?; + let snapshot = { + // `Active` and `Finished` are not in `scheduled` / + // `queue`, but `locate_recording` returns them anyway — + // short-circuit with `StateNotEditable` so the match + // arms below stay narrowed to the editable lists. + let task = match location { + RecordingLocation::Scheduled(i) => &candidate.scheduled[i], + RecordingLocation::Queue(i) => &candidate.queue[i], + RecordingLocation::Active | RecordingLocation::Finished(_) => { + return Err(QueueMutationError::StateNotEditable); + } + }; + if !recording_edit::state_is_editable(task.state.label()) { + return Err(QueueMutationError::StateNotEditable); + } + let Some(meta_snapshot) = task.recording.as_ref() else { + return Err(QueueMutationError::UnknownRecording); + }; + let pool = recording_quota::quota_pool_for_task(task) + .ok_or(QueueMutationError::InvalidQuotaPool)?; + let subject = RecordingSubject::new(Some(meta_snapshot), TerminalState::Active, true); + if !matches!( + authorize(claims, &owner_id, RecordingAction::Edit, &subject), + RecordingDecision::Allow + ) { + return Err(QueueMutationError::Forbidden); + } + let merged_pre = patch.pre_roll_secs.unwrap_or(meta_snapshot.pre_roll_secs); + let merged_post = patch.post_roll_secs.unwrap_or(meta_snapshot.post_roll_secs); + recording_edit::validate_padding(merged_pre, merged_post, bounds).map_err(|err| match err { + EditError::PaddingLimitExceeded => QueueMutationError::PaddingLimitExceeded, + EditError::InvalidInterval => QueueMutationError::InvalidInterval, + EditError::StateNotEditable | EditError::ChannelChangedWithoutProgramme => { + QueueMutationError::StateNotEditable + } + EditError::ProvenanceCleared => QueueMutationError::Forbidden, + })?; + let channel_changed_now = recording_edit::channel_changed( + &recording_edit::EditPatch { + program_start: patch.program_start, + program_end: patch.program_end, + pre_roll_secs: patch.pre_roll_secs, + post_roll_secs: patch.post_roll_secs, + program_title: patch.program_title.clone(), + channel_id: patch.channel_id.clone(), + channel_name: patch.channel_name.clone(), + }, + meta_snapshot.channel_id.as_deref(), + meta_snapshot.channel_name.as_deref(), + ); + EditSnapshot { + pool, + merged_pre, + merged_post, + channel_changed_now, + current_start: meta_snapshot.program_start, + current_end: meta_snapshot.program_end, + current_reserved: meta_snapshot.reserved_bytes, + } + }; + // Immutable borrow is out of scope. Compute the new + // interval and the post-edit reservation, then check the + // quota against the pool total — no `RecordingMetadata` + // clone is required because the snapshot carries only + // primitives. + let start = patch + .program_start + .or(snapshot.current_start) + .ok_or(QueueMutationError::InvalidInterval)?; + let end = patch + .program_end + .or(snapshot.current_end) + .ok_or(QueueMutationError::InvalidInterval)?; + if start >= end { + return Err(QueueMutationError::InvalidInterval); + } + let duration_secs = end + .checked_sub(start) + .and_then(|duration| u64::try_from(duration).ok()) + .ok_or(QueueMutationError::InvalidInterval)?; + let (new_reserved, _) = + recording_quota::estimate_reservation(duration_secs, 0, fallback_bytes_per_minute); + let pool_used = used_bytes_for_pool(candidate, &snapshot.pool); + let pool_used_minus_this = pool_used.saturating_sub(snapshot.current_reserved); + if matches!( + recording_quota::would_exceed( + &snapshot.pool, + pool_used_minus_this, + new_reserved, + "a_limits + ), + AdmissionOutcome::OverLimit { .. } + ) { + return Err(QueueMutationError::QuotaExceeded); + } + + // All immutable borrows are out of scope. Re-acquire the + // same task via the remembered location (O(1)) for the + // actual edit and apply every field write here. If any + // earlier step returned `Err`, none of these writes run, + // so the candidate is rolled back atomically. + let Some(task) = recording_mut_at(candidate, location) else { + return Err(QueueMutationError::UnknownRecording); + }; + let Some(meta) = task.recording.as_mut() else { + return Err(QueueMutationError::UnknownRecording); + }; + if let Some(title) = patch.program_title { + meta.program_title = Some(title); + } + if let Some(channel_id) = patch.channel_id { + meta.channel_id = Some(channel_id); + } + if let Some(channel_name) = patch.channel_name { + meta.channel_name = Some(channel_name); + } + meta.pre_roll_secs = snapshot.merged_pre; + meta.post_roll_secs = snapshot.merged_post; + meta.program_start = Some(start); + meta.program_end = Some(end); + meta.scheduled_start = Some(start); + meta.scheduled_end = Some(end); + meta.reserved_bytes = new_reserved; + if snapshot.channel_changed_now { + meta.epg = None; + } + task.start_at = Some(start); + task.duration_secs = Some(duration_secs); + + out = Some(RecordingTaskView { + uuid: task.uuid.clone(), + owner_id: owner_id.clone(), + visibility: meta.visibility, + filename_preview: task.filename.clone(), + start_at: task.start_at, + duration_secs: task.duration_secs, + state: task.state.clone(), + }); + Ok(()) + }) + .await + .map_err(|e| map_queue_error(&e))?; + out.ok_or(ServiceError::UnknownRecording) + } + + /// Cancel an in-flight or scheduled recording. Calls the queue's + /// `cancel_active` when the recording is active; for queued or + /// scheduled tasks are not cancelled here. + pub async fn cancel_recording( + &self, + claims: &shared::model::Claims, + uuid: &str, + ) -> Result<(), ServiceError> { + let owner_id = Self::subject_id(claims)?; + let active = self.downloads.active.read().await.clone(); + if let Some(active) = active.filter(|active| active.uuid == uuid) { + let meta = active + .recording + .clone() + .ok_or(ServiceError::UnknownRecording)?; + let subject = RecordingSubject::new(Some(&meta), TerminalState::Active, true); + if !matches!( + authorize(claims, &owner_id, RecordingAction::Cancel, &subject), + RecordingDecision::Allow + ) { + return Err(ServiceError::Forbidden); + } + // Cancel by uuid, never `cancel_active()`. Between the read + // above and this call ffmpeg can finish and the queue can + // promote a *different* recording into the active slot; the + // no-uuid variant would then kill that innocent recording. + match self.downloads.cancel_active_matching(uuid).await { + Ok(true) => return Ok(()), + // The task left the active slot in the meantime. Fall + // through to the inactive path: it either finds the task + // in `scheduled`/`queue` (a re-promotion) or reports + // `UnknownRecording`, which is the truthful answer. + Ok(false) => {} + Err(err) => { + log::error!("cancel_active_matching failed for {uuid}: {err}"); + return Err(ServiceError::PersistenceFailed); + } + } + } + mutate(&self.downloads, |candidate| { + let Some(task) = remove_inactive_recording(candidate, uuid) else { + return Err(QueueMutationError::UnknownRecording); + }; + let Some(meta) = task.recording.as_ref() else { + return Err(QueueMutationError::UnknownRecording); + }; + let subject = RecordingSubject::new(Some(meta), TerminalState::Active, true); + if !matches!( + authorize(claims, &owner_id, RecordingAction::Cancel, &subject), + RecordingDecision::Allow + ) { + return Err(QueueMutationError::Forbidden); + } + let mut cancelled = task; + cancelled.state = DownloadState::Cancelled; + cancelled.finished = true; + cancelled.error = Some("cancelled".to_string()); + if let Some(meta) = cancelled.recording.as_mut() { + meta.reserved_bytes = 0; + } + candidate.finished.push(cancelled); + Ok(()) + }) + .await + .map_err(|e| map_queue_error(&e)) + } + + /// Cancel future inactive recordings that were materialized from a + /// recurring rule. Active recordings are intentionally left untouched. + /// Returns the pre-cancel snapshots of everything it cancelled. The + /// caller is mid-way through a two-store operation (cancel the + /// occurrences, then delete the rule) that cannot be made atomic, so + /// it keeps these to undo the queue side if the rule store fails — + /// see [`Self::restore_cancelled_rule_recordings`]. + pub async fn cancel_future_rule_recordings( + &self, + claims: &shared::model::Claims, + rule_id: &str, + now_secs: i64, + ) -> Result, ServiceError> { + let _ = Self::subject_id(claims)?; + if !claims.permissions.contains(shared::model::Permission::RecordingWrite) { + return Err(ServiceError::Forbidden); + } + let mut cancelled = Vec::new(); + mutate(&self.downloads, |candidate| { + cancelled = cancel_future_rule_recordings_in_candidate(candidate, rule_id, now_secs); + Ok(()) + }) + .await + .map_err(|e| map_queue_error(&e))?; + Ok(cancelled) + } + + /// Compensating transaction for [`Self::cancel_future_rule_recordings`]. + /// + /// Moves each task back out of `finished` into the list it came from, + /// restoring the exact record that was captured before the cancel + /// (including `reserved_bytes`, which the cancel zeroed). A uuid that + /// something else has since claimed is left alone: a real + /// create/edit always wins over an undo. + pub async fn restore_cancelled_rule_recordings( + &self, + cancelled: &[CancelledRuleRecording], + ) -> Result<(), ServiceError> { + if cancelled.is_empty() { + return Ok(()); + } + mutate(&self.downloads, |candidate| { + for entry in cancelled { + let uuid = entry.task.uuid.as_str(); + candidate.finished.retain(|task| task.uuid != uuid); + if locate_recording(candidate, uuid).is_some() { + continue; + } + match entry.origin { + CancelOrigin::Scheduled => candidate.scheduled.push(entry.task.clone()), + CancelOrigin::Queue => candidate.queue.push(entry.task.clone()), + } + } + Ok(()) + }) + .await + .map_err(|e| map_queue_error(&e)) + } + + /// Delete a finished recording via the three-step service. + /// Marks the task as `Deleting` (atomic), unlinks the file + /// (outside the boundary), then removes the task (atomic). + pub async fn delete_recording( + &self, + claims: &shared::model::Claims, + uuid: &str, + ) -> Result<(), ServiceError> { + let owner_id = Self::subject_id(claims)?; + self.run_deletion(uuid, |meta| { + let subject = RecordingSubject::new(Some(meta), TerminalState::Completed, true); + matches!( + authorize(claims, &owner_id, RecordingAction::Delete, &subject), + RecordingDecision::Allow + ) + }) + .await + } + + /// The three-phase deletion, shared by the user-facing delete and the + /// retention worker. `permit` runs *inside* the same mutation + /// boundary that stamps the task as deleting, so there is no window + /// in which the authorized metadata and the stamped task can differ: + /// the previous implementation looked the task up, authorized it, + /// stamped it, then looked it up a second time and could act on a + /// stale copy. + async fn run_deletion(&self, uuid: &str, permit: F) -> Result<(), ServiceError> + where + F: FnOnce(&RecordingMetadata) -> bool, + { + let queue = self.downloads.clone(); + let target = begin_deletion_authorized(&queue, uuid, permit) + .await + .map_err(map_deletion_error)?; + if let Err(err) = execute_deletion_target(&target).await { + // File removal failed: undo the deletion transition so the + // recording stays visible in its prior state instead of + // being silently lost when finalize_deletion runs. + let uuid_owned = uuid.to_string(); + let _ = mutate(&self.downloads, |candidate| { + rollback_deletion(candidate, &uuid_owned); + Ok(()) + }) + .await; + return Err(ServiceError::IoError(err.to_string())); + } + finalize_deletion(&queue, uuid) + .await + .map_err(|_| ServiceError::UnknownRecording)?; + Ok(()) + } + + /// Internal retention-delete entrypoint used by the retention + /// worker. Bypasses user ownership but enforces state/kind/path + pub async fn system_retention_delete( + &self, + claims: &shared::model::Claims, + uuid: &str, + ) -> Result<(), ServiceError> { + let owner_id = Self::subject_id(claims)?; + self.run_deletion(uuid, |meta| { + let subject = RecordingSubject::new(Some(meta), TerminalState::Completed, true); + matches!( + authorize( + claims, + &owner_id, + RecordingAction::SystemRetentionDelete, + &subject, + ), + RecordingDecision::Allow + ) + }) + .await + } + + /// Re-export the orphan policy for callers that need it. + pub fn authorize_orphan_read( + &self, + claims: &shared::model::Claims, + ) -> Result<(), ServiceError> { + match authorize_orphan(claims) { + RecordingDecision::Allow => Ok(()), + RecordingDecision::Deny(_) => Err(ServiceError::Forbidden), + } + } + + /// Server-side conflict preview. The caller submits a candidate + /// padded interval plus server-owned source identifiers. The + /// server enumerates the committed queue state for that + /// provider/input, builds the demand points itself, resolves the + /// effective capacity from config, and runs the deterministic + /// analyzer. The request never carries another user's + /// `others`, capacity, or provider identifier. + pub async fn preview_conflicts( + &self, + claims: &shared::model::Claims, + request: &ConflictPreviewRequest, + ) -> Result { + // Require an authenticated principal. The owner id is not + // needed for the analyzer (the privacy contract applies to + // the response), but a missing / invalid claim must reject. + Self::subject_id(claims)?; + // Reject malformed input up front so the analyzer never sees + // garbage. The endpoint enforces the same bounds; this is the + // service-layer defense in depth. + let bounds = padding_bounds( + self.app_config + .config + .load() + .video + .as_ref() + .and_then(|v| v.download.as_ref()) + .and_then(|dl| dl.recording.as_ref()), + ); + recording_edit::validate_padding(request.pre_roll_secs, request.post_roll_secs, bounds) + .map_err(|error: EditError| map_edit_validation_error(&error))?; + if request.padded_start >= request.padded_end { + return Err(ServiceError::InvalidInterval); + } + // Server resolves the source. The candidate must map to a + // configured provider; anything else is `InvalidSource`. + if self.recording_url(&request.source).is_none() { + return Err(ServiceError::InvalidSource); + } + // Capacity comes from config, not the caller. The runtime + // slot model is the source of truth. + let capacity = effective_capacity_from_config(&self.app_config.config.load()); + // Demand points come from the committed queue state. The + // privacy contract from `recording_conflict.rs` still applies + // — the response only carries anonymized segments. + let others = collect_demand_points_for_provider( + &self.downloads, + &request.source.target_id, + &request.source.input_name, + ) + .await; + let candidate = crate::api::model::recording_conflict::DemandPoint { + task_id: String::new(), + padded_start: request.padded_start, + padded_end: request.padded_end, + priority: request.priority, + }; + let provider_scope = Some(request.source.target_id.clone()); + Ok(crate::api::model::recording_conflict::preview_conflict( + &candidate, + &others, + capacity, + provider_scope, + )) + } +} + +/// Maximum bytes in a single sanitized filename component. Well under +/// the 255-byte limit every supported filesystem enforces, leaving room +/// for the `_N` disambiguation suffix and the `.partial` extension the +/// worker appends. +const MAX_FILENAME_COMPONENT_BYTES: usize = 200; + +/// Substitute for a title that sanitizes down to nothing. +const FILENAME_FALLBACK: &str = "recording"; + +/// Characters that are illegal in a path component on at least one +/// supported platform. `/` and `\` are separators where it matters; the +/// rest are Windows-reserved but are equally unwelcome in a +/// URL-addressed media path. +const FILENAME_FORBIDDEN_CHARS: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +/// Windows reserved device names. A component whose stem matches one of +/// these (case-insensitively) cannot be created on Windows, with or +/// without an extension. +const WINDOWS_RESERVED_STEMS: &[&str] = &[ + "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", + "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", +]; + +/// Turn arbitrary programme text into one safe path component. +/// +/// The previous implementation replaced only the two path separators, +/// which let control characters, Windows-reserved characters, trailing +/// dots/spaces, and `BiDi` override codepoints through into the path the +/// muxer opens and the media API re-validates. This is the single +/// chokepoint: everything that lands in `filename` goes through here. +/// +/// Guarantees on the returned string: +/// - exactly one path component (no separator survives), +/// - no ASCII control characters and no Unicode `BiDi` / invisible +/// formatting codepoints, +/// - no leading or trailing whitespace or `.`, +/// - never empty, never `.` or `..`, never a Windows device name, +/// - at most `MAX_FILENAME_COMPONENT_BYTES` bytes, truncated on a +/// character boundary, +/// - idempotent: sanitizing an already-sanitized value is a no-op. +pub fn sanitize_filename_component(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut last_was_underscore = false; + for ch in raw.chars() { + // Invisible formatting codepoints can reorder the rendered + // filename so it does not match the bytes on disk. Drop them + // outright rather than substituting, so they leave no trace. + if is_invisible_formatting(ch) { + continue; + } + if ch.is_control() || FILENAME_FORBIDDEN_CHARS.contains(&ch) { + // Collapse runs so `a///b` becomes `a_b`, not `a___b`. + if !last_was_underscore { + out.push('_'); + last_was_underscore = true; + } + continue; + } + out.push(ch); + last_was_underscore = ch == '_'; + } + + // Trailing dots and spaces are silently stripped by Windows, which + // would desync the persisted `relative_path` from the real file. + let trimmed = out.trim_matches(|ch: char| ch.is_whitespace() || ch == '.'); + let mut result = truncate_on_char_boundary(trimmed, MAX_FILENAME_COMPONENT_BYTES) + .trim_end_matches(|ch: char| ch.is_whitespace() || ch == '.') + .to_string(); + + if result.is_empty() || is_windows_reserved_stem(&result) { + result = FILENAME_FALLBACK.to_string(); + } + result +} + +/// `BiDi` controls, zero-width characters, and the other invisible +/// formatting codepoints that make a filename render differently from +/// what it actually contains. +fn is_invisible_formatting(ch: char) -> bool { + matches!( + ch, + '\u{200b}'..='\u{200f}' // zero-width space .. RLM + | '\u{202a}'..='\u{202e}' // embedding / override + | '\u{2060}'..='\u{2064}' // word joiner, invisible operators + | '\u{2066}'..='\u{2069}' // directional isolates + | '\u{feff}' // BOM / zero-width no-break space + ) +} + +/// Truncate to at most `max_bytes`, never splitting a character. +fn truncate_on_char_boundary(value: &str, max_bytes: usize) -> &str { + if value.len() <= max_bytes { + return value; + } + let mut end = max_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} + +/// `true` when the component's stem is a Windows device name. +fn is_windows_reserved_stem(value: &str) -> bool { + let stem = value.split('.').next().unwrap_or(value); + WINDOWS_RESERVED_STEMS + .iter() + .any(|reserved| stem.eq_ignore_ascii_case(reserved)) +} + +fn render_filename_preview(input: &CreateRecordingInput) -> String { + sanitize_filename_component(&input.program_title) +} + +fn authorize_create_recording( + claims: &shared::model::Claims, + owner_id: &UserId, + visibility: RecordingVisibility, +) -> Result<(), ServiceError> { + let action = match visibility { + RecordingVisibility::Private => RecordingAction::CreatePrivate, + RecordingVisibility::Shared => RecordingAction::CreateShared, + }; + match authorize( + claims, + owner_id, + action, + &RecordingSubject::new(None, TerminalState::Active, true), + ) { + RecordingDecision::Allow => Ok(()), + RecordingDecision::Deny(crate::auth::DenyReason::NotAdministrator) => { + Err(ServiceError::SharedCreationNotAdministrator) + } + RecordingDecision::Deny(_) => Err(ServiceError::Forbidden), + } +} + +/// Map a queue-mutation failure onto the service error surface. +/// +/// Every call site used to enumerate all twelve `QueueMutationError` +/// variants inline, so adding a variant meant editing four or more +/// matches. The variants that carry no site-specific meaning collapse +/// here; a site that needs a different mapping for one variant still +/// handles it before delegating. +fn map_queue_error(err: &QueueMutationError) -> ServiceError { + match err { + QueueMutationError::Io(_) => ServiceError::PersistenceFailed, + QueueMutationError::UnknownRecording => ServiceError::UnknownRecording, + QueueMutationError::Forbidden => ServiceError::Forbidden, + QueueMutationError::InvalidInterval => ServiceError::InvalidInterval, + QueueMutationError::PaddingLimitExceeded => ServiceError::PaddingLimitExceeded, + QueueMutationError::QuotaExceeded => ServiceError::QuotaExceeded, + QueueMutationError::Duplicate => ServiceError::Duplicate, + QueueMutationError::InvalidPath => ServiceError::InvalidPath, + QueueMutationError::DiskFull => ServiceError::DiskFull, + QueueMutationError::StateNotEditable + | QueueMutationError::InvalidQuotaPool + | QueueMutationError::NotInTerminalState + | QueueMutationError::MutationSkipped + | QueueMutationError::Other(_) => ServiceError::InvalidState, + } +} + +fn quota_limits_from_config(config: Option<&crate::model::RecordingQuotaConfig>) -> QuotaLimits { + let mut per_user_bytes = HashMap::new(); + if let Some(config) = config { + for (user_id, bytes) in &config.per_user_bytes { + per_user_bytes.insert(UserId::from(user_id.clone()), *bytes); + } + QuotaLimits { + default_private_bytes: config.default_private_bytes, + per_user_bytes, + shared_bytes: config.shared_bytes, + } + } else { + QuotaLimits::default() + } +} + +/// Every task in the candidate snapshot, borrowed. Admission checks run +/// inside `mutate`, so this must not allocate a clone per task — the +/// previous implementation built a `Vec` of the +/// entire queue on every create and every edit. +fn candidate_tasks( + candidate: &PersistedDownloadQueue, +) -> impl Iterator + '_ { + candidate + .queue + .iter() + .chain(candidate.scheduled.iter()) + .chain(candidate.active.iter()) + .chain(candidate.finished.iter()) +} + +/// Bytes charged against a single quota pool. Only the pool the caller +/// asked about is summed; the previous implementation built the full +/// per-user `HashMap` and then read one entry out of it. +fn used_bytes_for_pool(candidate: &PersistedDownloadQueue, pool: &QuotaPool) -> u64 { + recording_quota::used_bytes_in_pool(candidate_tasks(candidate), pool) +} + +fn reserve_recording_relative_path( + candidate: &PersistedDownloadQueue, + task: &mut PersistedFileDownload, +) -> Result<(), QueueMutationError> { + // Borrowed set, built once. The old code walked a `Vec` of + // cloned filenames once per `_N` candidate, so reserving the + // (N+1)-th recording of a title cost O(N^2) string comparisons. + let existing: std::collections::HashSet<&str> = + collect_existing_relative_paths(candidate).collect(); + let mut filename = task.filename.clone(); + if existing.contains(filename.as_str()) { + let (stem, ext) = split_filename(&task.filename); + // Linear probe over indices; each probe is one hash lookup. + for index in 1.. { + filename = if ext.is_empty() { + format!("{stem}_{index}") + } else { + format!("{stem}_{index}.{ext}") + }; + if !existing.contains(filename.as_str()) { + break; + } + } + } + validate_reserved_filename(&filename).map_err(|_| QueueMutationError::InvalidPath)?; + task.filename.clone_from(&filename); + task.file_path = task.file_dir.join(&filename); + if let Some(meta) = task.recording.as_mut() { + meta.relative_path = Some(filename); + } + Ok(()) +} + +fn collect_existing_relative_paths( + candidate: &PersistedDownloadQueue, +) -> impl Iterator + '_ { + candidate_tasks(candidate).map(task_relative_path) +} + +fn task_relative_path(task: &PersistedFileDownload) -> &str { + task.recording + .as_ref() + .and_then(|meta| meta.relative_path.as_deref()) + .unwrap_or(task.filename.as_str()) +} + +fn split_filename(filename: &str) -> (String, String) { + let path = Path::new(filename); + let stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or(filename); + let ext = path.extension().and_then(std::ffi::OsStr::to_str).unwrap_or_default(); + (stem.to_string(), ext.to_string()) +} + +/// What makes two recording requests "the same thing". +/// +/// The previous key was `(url, start_at, duration_secs)` OR `file_path`, +/// and neither half worked: +/// - `file_path` is disambiguated with a `_N` suffix by +/// `reserve_recording_relative_path`, so it *never* matches an +/// existing task and the whole disjunct was dead. +/// - `start_at` is `now.max(scheduled_start)`, so for a +/// currently-airing programme every request inside the window +/// produces a different value and the same programme could be +/// booked over and over. +/// +/// The identity is now derived from what the user actually asked for. +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecordingIdentity { + /// Materialization of one rule occurrence. Two tasks with the same + /// `(rule_id, occurrence_key)` are the same recording by + /// definition, whatever their window looks like. + Occurrence { + rule_id: String, + occurrence_key: String, + }, + /// A concrete programme on a concrete source, per quota pool. The + /// pool dimension is deliberate: a shared copy and a private copy of + /// the same programme are two different recordings that charge two + /// different quotas. + Programme { + target_id: String, + virtual_id: String, + program_start: i64, + program_end: i64, + owner: RecordingOwner, + visibility: RecordingVisibility, + }, + /// No programme metadata at all. Fall back to the resolved URL plus + /// the *scheduled* (padded) window, which — unlike `start_at` — is + /// stable across requests inside a currently-airing window. + Url { + url: String, + scheduled_start: Option, + scheduled_end: Option, + }, +} + +fn recording_identity(meta: &RecordingMetadata, url: &str) -> RecordingIdentity { + if let (Some(rule_id), Some(occurrence_key)) = ( + meta.provenance.rule_id.as_deref(), + meta.provenance.occurrence_key.as_deref(), + ) { + return RecordingIdentity::Occurrence { + rule_id: rule_id.to_string(), + occurrence_key: occurrence_key.to_string(), + }; + } + if let (Some(source), Some(program_start), Some(program_end)) = + (meta.source.as_ref(), meta.program_start, meta.program_end) + { + return RecordingIdentity::Programme { + target_id: source.target_id.clone(), + virtual_id: source.virtual_id.clone(), + program_start, + program_end, + owner: meta.owner.clone(), + visibility: meta.visibility, + }; + } + RecordingIdentity::Url { + url: url.to_string(), + scheduled_start: meta.scheduled_start, + scheduled_end: meta.scheduled_end, + } +} + +fn persisted_recording_identity( + task: &PersistedFileDownload, +) -> Option { + if task.kind != DownloadKind::Recording { + return None; + } + task.recording + .as_ref() + .map(|meta| recording_identity(meta, &task.url)) +} + +fn candidate_has_duplicate_recording( + candidate: &PersistedDownloadQueue, + task: &FileDownload, +) -> bool { + let Some(meta) = task.recording.as_ref() else { + return false; + }; + let identity = recording_identity(meta, task.url.as_str()); + // Pending and active tasks are duplicates of anything matching. + let pending_match = candidate + .queue + .iter() + .chain(candidate.scheduled.iter()) + .chain(candidate.active.iter()) + .filter_map(persisted_recording_identity) + .any(|existing| existing == identity); + if pending_match { + return true; + } + // Terminal tasks do not block a fresh request: after a failed or + // cancelled attempt the user must be able to try again, and after a + // successful one they may legitimately want a second copy. The one + // exception is a rule occurrence — re-materializing an occurrence + // that already ran would duplicate it on every scheduler tick. + if !matches!(identity, RecordingIdentity::Occurrence { .. }) { + return false; + } + candidate + .finished + .iter() + .filter_map(persisted_recording_identity) + .any(|existing| existing == identity) +} + +/// Where a recording lives in the candidate snapshot. The first scan +/// produces one of these so the second access (mut borrow for writes) +/// is O(1) instead of repeating the linear search. +#[derive(Debug, Clone, Copy)] +enum RecordingLocation { + Scheduled(usize), + Queue(usize), + Active, + Finished(usize), +} + +/// Single linear scan that locates a recording anywhere in the +/// candidate snapshot. The returned `RecordingLocation` lets the +/// caller re-acquire the same task for a mutable borrow without a +/// second search. +fn locate_recording(candidate: &PersistedDownloadQueue, uuid: &str) -> Option { + let matches_uuid = |task: &PersistedFileDownload| task.uuid == uuid && task.kind == DownloadKind::Recording; + if let Some(i) = candidate.scheduled.iter().position(matches_uuid) { + return Some(RecordingLocation::Scheduled(i)); + } + if let Some(i) = candidate.queue.iter().position(matches_uuid) { + return Some(RecordingLocation::Queue(i)); + } + if candidate.active.as_ref().is_some_and(matches_uuid) { + return Some(RecordingLocation::Active); + } + if let Some(i) = candidate.finished.iter().position(matches_uuid) { + return Some(RecordingLocation::Finished(i)); + } + None +} + +/// Resolve a recording to a mutable borrow using a remembered +/// location. The location must have come from the same candidate; +/// callers obtain it via [`locate_recording`]. +fn recording_mut_at( + candidate: &mut PersistedDownloadQueue, + location: RecordingLocation, +) -> Option<&mut PersistedFileDownload> { + match location { + RecordingLocation::Scheduled(i) => candidate.scheduled.get_mut(i), + RecordingLocation::Queue(i) => candidate.queue.get_mut(i), + RecordingLocation::Active => candidate.active.as_mut(), + // Must be the located index, not element 0: returning the first + // finished task would silently edit an unrelated recording. + RecordingLocation::Finished(i) => candidate.finished.get_mut(i), + } +} + +/// Primitives extracted from `RecordingMetadata` during the immutable +/// analysis pass. Carries only the fields the post-borrow code needs +/// so we never clone the full `RecordingMetadata` (which holds +/// several `Option` / `Vec` allocations). +struct EditSnapshot { + pool: QuotaPool, + merged_pre: u64, + merged_post: u64, + channel_changed_now: bool, + current_start: Option, + current_end: Option, + current_reserved: u64, +} +/// Server-owned input for the conflict preview. The caller never +/// supplies another recording's padded interval, capacity, or +/// provider identifier — those are derived server-side. +#[derive(Debug, Clone)] +pub struct ConflictPreviewRequest { + pub source: RecordingSourceInput, + pub padded_start: i64, + pub padded_end: i64, + pub pre_roll_secs: u64, + pub post_roll_secs: u64, + pub priority: i32, +} + +fn effective_capacity_from_config( + config: &crate::model::Config, +) -> crate::api::model::recording_conflict::EffectiveCapacity { + // Background slots come from the recording provider's + // `max_background_per_provider`. Reserved interactive slots are + // a coarse approximation of the number of users currently + // streaming on the same provider; the analyzer treats the value + // as a subtraction. When the provider cannot be resolved, fall + // back to a zero headroom so the worst case is `LikelyMissedWindow` + // and never a silent `NoKnownConflict`. + let download_cfg = config.video.as_ref().and_then(|v| v.download.as_ref()); + let background_slots = download_cfg.map_or(0, |dl| u32::from(dl.max_background_per_provider)); + let reserved = u32::from(download_cfg.map_or(0, |dl| dl.reserve_slots_for_users)); + crate::api::model::recording_conflict::EffectiveCapacity { + background_slots, + reserved_interactive_slots: reserved, + } +} + +async fn collect_demand_points_for_provider( + queue: &Arc, + target_id: &str, + input_name: &str, +) -> Vec { + use crate::api::model::recording_conflict::DemandPoint; + fn matches(task: &FileDownload, target_id: &str, input_name: &str) -> bool { + task.kind == DownloadKind::Recording + && task + .recording + .as_ref() + .is_some_and(|meta| match &meta.source { + Some(src) => src.target_id == target_id && src.input_name == input_name, + None => false, + }) + } + fn to_demand_point(task: &FileDownload) -> Option { + let meta = task.recording.as_ref()?; + let start = meta.scheduled_start?; + let end = meta.scheduled_end?; + if end <= start { + return None; + } + Some(DemandPoint { + task_id: task.uuid.clone(), + padded_start: start, + padded_end: end, + priority: i32::from(task.priority), + }) + } + // Pending and active recordings are real capacity consumers. + // Finished recordings no longer claim slots, so they would only + // inflate the conflict preview's `peak_demand`. + fn claims_a_slot(task: &FileDownload) -> bool { + !matches!( + task.state, + DownloadState::Completed | DownloadState::Failed | DownloadState::Cancelled + ) + } + // One committed snapshot rather than three sequential guards. Reading + // `scheduled`, then `queue`, then `active` in turn let a task that + // moved between two of those reads be counted twice or not at all, + // which silently shifted the reported severity. + let (_revision, tasks) = queue.committed_snapshot().await; + tasks + .iter() + .filter(|task| claims_a_slot(task) && matches(task, target_id, input_name)) + .filter_map(to_demand_point) + .collect() +} + +fn remove_inactive_recording(candidate: &mut PersistedDownloadQueue, uuid: &str) -> Option { + if let Some(index) = candidate.scheduled.iter().position(|task| task.uuid == uuid && task.kind == DownloadKind::Recording) { + return Some(candidate.scheduled.remove(index)); + } + let index = candidate.queue.iter().position(|task| task.uuid == uuid && task.kind == DownloadKind::Recording)?; + Some(candidate.queue.remove(index)) +} + +/// Which pending list a cancelled rule recording came from, so the +/// compensating restore puts it back where it belongs. +#[derive(Debug, Clone, Copy)] +enum CancelOrigin { + Scheduled, + Queue, +} + +/// A rule-materialized recording exactly as it was before the cancel. +#[derive(Debug, Clone)] +pub struct CancelledRuleRecording { + origin: CancelOrigin, + task: PersistedFileDownload, +} + +fn cancel_future_rule_recordings_in_candidate( + candidate: &mut PersistedDownloadQueue, + rule_id: &str, + now_secs: i64, +) -> Vec { + let mut undo = Vec::new(); + let mut moved = Vec::new(); + drain_future_rule_recordings( + &mut candidate.scheduled, + CancelOrigin::Scheduled, + rule_id, + now_secs, + &mut undo, + &mut moved, + ); + drain_future_rule_recordings( + &mut candidate.queue, + CancelOrigin::Queue, + rule_id, + now_secs, + &mut undo, + &mut moved, + ); + candidate.finished.extend(moved); + undo +} + +fn drain_future_rule_recordings( + tasks: &mut Vec, + origin: CancelOrigin, + rule_id: &str, + now_secs: i64, + undo: &mut Vec, + out: &mut Vec, +) { + let mut index = 0; + while index < tasks.len() { + if is_future_rule_recording(&tasks[index], rule_id, now_secs) { + let mut task = tasks.remove(index); + // Snapshot before the cancel mutates it: the undo has to + // restore `reserved_bytes`, which is zeroed just below. + undo.push(CancelledRuleRecording { origin, task: task.clone() }); + task.state = DownloadState::Cancelled; + task.finished = true; + task.error = Some("cancelled".to_string()); + if let Some(meta) = task.recording.as_mut() { + meta.reserved_bytes = 0; + } + out.push(task); + } else { + index += 1; + } + } +} + +fn is_future_rule_recording(task: &PersistedFileDownload, rule_id: &str, now_secs: i64) -> bool { + if task.kind != DownloadKind::Recording { + return false; + } + let Some(start_at) = task.start_at else { + return false; + }; + if start_at <= now_secs { + return false; + } + let Some(meta) = task.recording.as_ref() else { + return false; + }; + if meta.provenance.rule_id.as_deref() != Some(rule_id) { + return false; + } + recording_edit::state_is_editable(task.state.label()) +} + +fn validate_reserved_filename(filename: &str) -> Result<(), &'static str> { + use std::path::Component; + let path = Path::new(filename); + let single_normal_component = path + .components() + .next() + .is_some_and(|c| matches!(c, Component::Normal(_))) + && path.components().count() == 1; + if filename.is_empty() + || path.is_absolute() + || !single_normal_component + || filename.as_bytes().contains(&0) + { + return Err("recording invalid path"); + } + Ok(()) +} + +impl std::fmt::Debug for RecordingService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RecordingService").finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::{Permission, RecordingContainerFormat, XtreamCluster}; + use crate::model::{RecordingConfig, RecordingNotificationConfig}; + + fn source(target_name: &str, virtual_id: &str, input_name: &str) -> RecordingSourceInput { + RecordingSourceInput { + target_id: target_name.to_string(), + virtual_id: virtual_id.to_string(), + cluster: XtreamCluster::Live, + input_name: input_name.to_string(), + } + } + + fn create_input() -> CreateRecordingInput { + CreateRecordingInput { + source: source("target", "1", "input-a"), + program_title: "Pilot".to_string(), + program_start: 1_700_000_000, + program_end: 1_700_003_600, + pre_roll_secs: 0, + post_roll_secs: 0, + visibility: RecordingVisibility::Private, + channel_id: None, + channel_name: None, + provenance: RecordingProvenance::default(), + epg: None, + } + } + + fn persisted_rule_recording(uuid: &str, rule_id: Option<&str>, start_at: i64) -> PersistedFileDownload { + let mut meta = RecordingMetadata::new( + RecordingOwner::User(UserId::from("web:alice")), + RecordingVisibility::Private, + RecordingSource::new("1", "1", "input-a"), + start_at, + start_at + 3_600, + 0, + 0, + ); + meta.reserved_bytes = 123; + meta.provenance.rule_id = rule_id.map(str::to_string); + PersistedFileDownload { + uuid: uuid.to_string(), + file_dir: std::path::PathBuf::from("/tmp"), + file_path: std::path::PathBuf::from(format!("/tmp/{uuid}.ts")), + filename: format!("{uuid}.ts"), + url: "http://example.test/live.ts".to_string(), + finished: false, + size: 0, + total_size: None, + paused: false, + error: None, + state: DownloadState::Scheduled, + start_at: Some(start_at), + duration_secs: Some(3_600), + kind: DownloadKind::Recording, + input_name: Some("input-a".to_string()), + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: Some(meta), + } + } + + #[test] + fn source_input_rejects_empty_virtual_id() { + let input = source("target", "", "i"); + assert!(matches!(input.validate(), Err(ServiceError::InvalidSource))); + } + + #[test] + fn source_input_rejects_empty_input_name() { + let input = source("target", "1", ""); + assert!(matches!(input.validate(), Err(ServiceError::InvalidSource))); + } + + #[test] + fn source_input_accepts_non_empty_identifiers() { + let input = source("target", "1", "input-a"); + assert!(input.validate().is_ok()); + } + + #[test] + fn create_recording_input_rejects_zero_or_negative_interval() { + let mut input = create_input(); + input.program_end = input.program_start; + assert!(matches!(input.validate(), Err(ServiceError::InvalidInterval))); + input.program_end = input.program_start - 1; + assert!(matches!(input.validate(), Err(ServiceError::InvalidInterval))); + } + + #[test] + fn create_recording_input_accepts_valid_interval() { + let input = create_input(); + assert!(input.validate().is_ok()); + } + + #[test] + fn create_recording_input_rejects_overflowing_interval() { + let mut input = create_input(); + input.program_start = i64::MIN; + input.program_end = i64::MAX; + + assert!(matches!(input.validate(), Err(ServiceError::InvalidInterval))); + } + + #[test] + fn effective_window_applies_padding_and_remaining_duration() { + let window = effective_recording_window(1_000, 2_000, 100, 200, 1_500) + .expect("valid effective window"); + + assert_eq!(window.scheduled_start, 900); + assert_eq!(window.scheduled_end, 2_200); + assert_eq!(window.execution_start, 1_500); + assert_eq!(window.remaining_duration_secs, 700); + } + + #[test] + fn effective_window_rejects_exact_or_past_end_boundary() { + assert!(matches!( + effective_recording_window(1_000, 2_000, 100, 200, 2_200), + Err(ServiceError::InvalidInterval) + )); + assert!(matches!( + effective_recording_window(1_000, 2_000, 100, 200, 2_201), + Err(ServiceError::InvalidInterval) + )); + } + + #[test] + fn effective_window_is_panic_free_at_integer_boundaries() { + let window = effective_recording_window(i64::MIN + 1, i64::MAX - 1, 10, 10, 0) + .expect("saturated effective window"); + + assert_eq!(window.scheduled_start, i64::MIN); + assert_eq!(window.scheduled_end, i64::MAX); + assert_eq!(window.execution_start, 0); + assert_eq!(window.remaining_duration_secs, i64::MAX as u64); + } + + #[tokio::test] + async fn edit_recording_rejects_padding_above_max_without_persisting_mutation() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads.json"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(state_file.clone()))); + let task = DownloadQueue::from_persisted(persisted_rule_recording("recording", None, 100)) + .expect("valid recording task"); + downloads.scheduled.write().await.push(task); + downloads.persist_to_disk().await.expect("persist initial queue"); + let persisted_before = std::fs::read(&state_file).expect("read initial queue"); + let service = RecordingService::new(Arc::clone(&downloads), test_app_config()); + let claims = shared::model::Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: Vec::new(), + permissions: Permission::RecordingWrite.into(), + pwd_version: 0, + subject_id: Some(UserId::from("web:alice")), + permission_schema_version: shared::model::CURRENT_PERMISSION_SCHEMA_VERSION, + }; + let patch = EditRecordingPatch { + pre_roll_secs: Some(901), + ..EditRecordingPatch::default() + }; + + let result = service.edit_recording(&claims, "recording", patch).await; + + assert!(matches!(result, Err(ServiceError::PaddingLimitExceeded))); + assert_eq!(std::fs::read(&state_file).expect("read unchanged queue"), persisted_before); + let scheduled = downloads.scheduled.read().await; + assert_eq!(scheduled[0].recording.as_ref().map(|m| m.pre_roll_secs), Some(0)); + } + + #[tokio::test] + async fn edit_recording_rejects_active_state_with_invalid_state_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads.json"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(state_file.clone()))); + let mut task = DownloadQueue::from_persisted(persisted_rule_recording("recording", None, 100)) + .expect("valid recording task"); + task.state = DownloadState::Downloading; + downloads.scheduled.write().await.push(task); + downloads.persist_to_disk().await.expect("persist initial queue"); + let persisted_before = std::fs::read(&state_file).expect("read initial queue"); + let service = RecordingService::new(Arc::clone(&downloads), test_app_config()); + let claims = shared::model::Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: Vec::new(), + permissions: Permission::RecordingWrite.into(), + pwd_version: 0, + subject_id: Some(UserId::from("web:alice")), + permission_schema_version: shared::model::CURRENT_PERMISSION_SCHEMA_VERSION, + }; + let patch = EditRecordingPatch { + program_title: Some("must not persist".to_string()), + ..EditRecordingPatch::default() + }; + + let result = service.edit_recording(&claims, "recording", patch).await; + + assert!(matches!(result, Err(ServiceError::InvalidState))); + assert_eq!(std::fs::read(&state_file).expect("read unchanged queue"), persisted_before); + } + + #[tokio::test] + async fn edit_recording_clears_epg_when_channel_changes_without_programme() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads.json"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(state_file.clone()))); + let mut task = DownloadQueue::from_persisted(persisted_rule_recording("recording", None, 100)) + .expect("valid recording task"); + if let Some(meta) = task.recording.as_mut() { + meta.channel_id = Some("a".into()); + meta.channel_name = Some("A".into()); + meta.epg = Some(shared::model::recording::EpgEpisodeMetadata { + programme_id: Some("p-1".into()), + series_id: None, + episode_id: None, + season: None, + episode: None, + airing: shared::model::recording::AiringStatus::New, + }); + } + downloads.scheduled.write().await.push(task); + downloads.persist_to_disk().await.expect("persist initial queue"); + let service = RecordingService::new(Arc::clone(&downloads), test_app_config()); + let claims = shared::model::Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: Vec::new(), + permissions: Permission::RecordingWrite.into(), + pwd_version: 0, + subject_id: Some(UserId::from("web:alice")), + permission_schema_version: shared::model::CURRENT_PERMISSION_SCHEMA_VERSION, + }; + let patch = EditRecordingPatch { + channel_id: Some("b".into()), + ..EditRecordingPatch::default() + }; + + let result = service.edit_recording(&claims, "recording", patch).await; + assert!(result.is_ok()); + let scheduled = downloads.scheduled.read().await; + let meta = scheduled[0].recording.as_ref().expect("recording metadata"); + assert_eq!(meta.channel_id.as_deref(), Some("b")); + assert!(meta.epg.is_none(), "epg metadata must be cleared when channel changed without a fresh programme"); + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn edit_recording_re_validates_quota_against_new_duration_atomically() { + // Owner already has 800 reserved. New duration would push the + // reservation to 1100 against a 1000-byte quota. Edit must + // fail with QuotaExceeded and persist nothing. + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads.json"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(state_file.clone()))); + let mut task = DownloadQueue::from_persisted(persisted_rule_recording("recording", None, 100)) + .expect("valid recording task"); + if let Some(meta) = task.recording.as_mut() { + meta.reserved_bytes = 800; + } + downloads.scheduled.write().await.push(task); + downloads.persist_to_disk().await.expect("persist initial queue"); + let persisted_before = std::fs::read(&state_file).expect("read initial queue"); + let quota = crate::model::RecordingQuotaConfig { + default_private_bytes: Some(1_000), + per_user_bytes: HashMap::new(), + shared_bytes: None, + }; + let rec_cfg = RecordingConfig { + enabled: true, + container_format: RecordingContainerFormat::default(), + directory: String::new(), + timezone: "UTC".parse().expect("UTC must parse"), + filename_template: String::new(), + default_pre_roll_secs: 0, + max_pre_roll_secs: 900, + default_post_roll_secs: 0, + max_post_roll_secs: 1800, + retention: None, + disk: None, + quota: Some(quota), + notifications: RecordingNotificationConfig::default(), + fallback_bytes_per_minute: 60, + }; + let dl_cfg = crate::model::VideoDownloadConfig { + headers: HashMap::new(), + directory: String::new(), + organize_into_directories: false, + episode_pattern: None, + download_priority: 0, + recording_priority: 0, + reserve_slots_for_users: 0, + max_background_per_provider: 0, + retry_backoff_initial_secs: 1, + retry_backoff_multiplier: 1.0, + retry_backoff_max_secs: 1, + retry_backoff_jitter_percent: 0, + retry_max_attempts: 1, + recording: Some(rec_cfg), + }; + let config = crate::model::Config { + video: Some(crate::model::VideoConfig { + extensions: Vec::new(), + download: Some(dl_cfg), + web_search: None, + }), + ..crate::model::Config::default() + }; + let app_config = Arc::new(AppConfig { + config: Arc::new(arc_swap::ArcSwap::from_pointee(config)), + sources: Arc::new(arc_swap::ArcSwap::from_pointee(crate::model::SourcesConfig::default())), + hdhomerun: Arc::new(arc_swap::ArcSwapOption::empty()), + api_proxy: Arc::new(arc_swap::ArcSwapOption::empty()), + file_locks: Arc::new(crate::utils::FileLockManager::default()), + paths: Arc::new(arc_swap::ArcSwap::from_pointee(shared::model::ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(arc_swap::ArcSwapOption::empty()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), + }); + let service = RecordingService::new(Arc::clone(&downloads), app_config); + let claims = shared::model::Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: Vec::new(), + permissions: Permission::RecordingWrite.into(), + pwd_version: 0, + subject_id: Some(UserId::from("web:alice")), + permission_schema_version: shared::model::CURRENT_PERMISSION_SCHEMA_VERSION, + }; + let patch = EditRecordingPatch { + program_end: Some(1_300), + ..EditRecordingPatch::default() + }; + + let result = service.edit_recording(&claims, "recording", patch).await; + + assert!(matches!(result, Err(ServiceError::QuotaExceeded))); + assert_eq!(std::fs::read(&state_file).expect("read unchanged queue"), persisted_before); + let scheduled = downloads.scheduled.read().await; + assert_eq!(scheduled[0].start_at, Some(100)); + } + + #[tokio::test] + async fn edit_recording_rejects_overflowing_interval_without_persisting_mutation() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads.json"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(state_file.clone()))); + let task = DownloadQueue::from_persisted(persisted_rule_recording("recording", None, 100)) + .expect("valid recording task"); + downloads.scheduled.write().await.push(task); + downloads.persist_to_disk().await.expect("persist initial queue"); + let persisted_before = std::fs::read(&state_file).expect("read initial queue"); + let revision_before = downloads.revision.load(std::sync::atomic::Ordering::SeqCst); + let service = RecordingService::new(Arc::clone(&downloads), test_app_config()); + let claims = shared::model::Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: Vec::new(), + permissions: Permission::RecordingWrite.into(), + pwd_version: 0, + subject_id: Some(UserId::from("web:alice")), + permission_schema_version: shared::model::CURRENT_PERMISSION_SCHEMA_VERSION, + }; + let patch = EditRecordingPatch { + program_start: Some(i64::MIN), + program_end: Some(i64::MAX), + program_title: Some("must not persist".to_string()), + ..EditRecordingPatch::default() + }; + + let result = service.edit_recording(&claims, "recording", patch).await; + + assert!(matches!(result, Err(ServiceError::InvalidInterval))); + assert_eq!(downloads.revision.load(std::sync::atomic::Ordering::SeqCst), revision_before); + assert_eq!(std::fs::read(&state_file).expect("read unchanged queue"), persisted_before); + let scheduled = downloads.scheduled.read().await; + assert_eq!(scheduled[0].start_at, Some(100)); + assert_ne!( + scheduled[0].recording.as_ref().and_then(|metadata| metadata.program_title.as_deref()), + Some("must not persist") + ); + } + + fn test_app_config() -> Arc { + Arc::new(AppConfig { + config: Arc::new(arc_swap::ArcSwap::from_pointee(crate::model::Config::default())), + sources: Arc::new(arc_swap::ArcSwap::from_pointee(crate::model::SourcesConfig::default())), + hdhomerun: Arc::new(arc_swap::ArcSwapOption::empty()), + api_proxy: Arc::new(arc_swap::ArcSwapOption::empty()), + file_locks: Arc::new(crate::utils::FileLockManager::default()), + paths: Arc::new(arc_swap::ArcSwap::from_pointee(shared::model::ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(arc_swap::ArcSwapOption::empty()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), + }) + } + + #[test] + fn service_error_code_is_stable_string() { + assert_eq!(ServiceError::UnknownOwner.code(), "recording_unknown_owner"); + assert_eq!(ServiceError::InvalidSource.code(), "recording_invalid_source"); + assert_eq!(ServiceError::Forbidden.code(), "recording_forbidden"); + assert_eq!( + ServiceError::SharedCreationNotAdministrator.code(), + "recording_shared_not_administrator" + ); + assert_eq!(ServiceError::InvalidState.code(), "recording_invalid_state"); + assert_eq!(ServiceError::InvalidInterval.code(), "recording_invalid_interval"); + assert_eq!(ServiceError::UnknownRecording.code(), "recording_unknown"); + assert_eq!(ServiceError::PersistenceFailed.code(), "recording_persistence_failed"); + assert_eq!( + ServiceError::ProvenanceImmutable.code(), + "recording_provenance_immutable" + ); + } + + #[test] + fn provenance_cleared_does_not_masquerade_as_invalid_state() { + assert_eq!( + map_edit_validation_error(&EditError::ProvenanceCleared), + ServiceError::ProvenanceImmutable + ); + } + + #[test] + fn sanitize_filename_strips_separators_and_reserved_characters() { + assert_eq!( + sanitize_filename_component("a/b\\c:d*e?f\"gi|j"), + "a_b_c_d_e_f_g_h_i_j" + ); + } + + #[test] + fn sanitize_filename_collapses_runs_and_drops_control_chars() { + assert_eq!(sanitize_filename_component("a///b"), "a_b"); + assert_eq!(sanitize_filename_component("a\u{7}\u{1}b"), "a_b"); + } + + #[test] + fn sanitize_filename_drops_invisible_formatting() { + // A right-to-left override renders the name differently from the + // bytes on disk; it must leave no trace at all. + assert_eq!(sanitize_filename_component("news\u{202e}sj.ts"), "newssj.ts"); + assert_eq!(sanitize_filename_component("a\u{200b}b"), "ab"); + } + + #[test] + fn sanitize_filename_rejects_traversal_and_empty_results() { + assert_eq!(sanitize_filename_component(""), "recording"); + assert_eq!(sanitize_filename_component("."), "recording"); + assert_eq!(sanitize_filename_component(".."), "recording"); + assert_eq!(sanitize_filename_component(" "), "recording"); + // A lone separator becomes the substitute character, which is + // itself a perfectly valid component. + assert_eq!(sanitize_filename_component("/"), "_"); + } + + #[test] + fn sanitize_filename_rejects_windows_device_names() { + assert_eq!(sanitize_filename_component("CON"), "recording"); + assert_eq!(sanitize_filename_component("nul.ts"), "recording"); + assert_eq!(sanitize_filename_component("lpt9"), "recording"); + // Not reserved: only an exact stem match counts. + assert_eq!(sanitize_filename_component("console"), "console"); + } + + #[test] + fn sanitize_filename_trims_trailing_dots_and_spaces() { + assert_eq!(sanitize_filename_component("Show. "), "Show"); + assert_eq!(sanitize_filename_component(" .Show"), "Show"); + } + + #[test] + fn sanitize_filename_is_idempotent_and_bounded() { + let long = "\u{e9}".repeat(400); + let once = sanitize_filename_component(&long); + assert!(once.len() <= MAX_FILENAME_COMPONENT_BYTES); + // Truncation never splits a character. + assert!(once.chars().all(|ch| ch == '\u{e9}')); + assert_eq!(sanitize_filename_component(&once), once); + for raw in ["a/b", "CON", "", "Show. ", "news\u{202e}sj.ts"] { + let first = sanitize_filename_component(raw); + assert_eq!(sanitize_filename_component(&first), first, "not idempotent: {raw}"); + } + } + + #[test] + fn sanitized_filename_is_always_a_single_valid_component() { + let very_long = "x".repeat(500); + let cases = ["a/b/c", "..", "\u{0}x", "CON", " ", "../../etc/passwd", &very_long]; + for raw in cases { + let sanitized = sanitize_filename_component(raw); + validate_reserved_filename(&sanitized) + .unwrap_or_else(|err| panic!("{raw:?} sanitized to invalid component: {err}")); + } + } + + #[test] + fn cancel_future_rule_recordings_moves_only_matching_future_tasks() { + let now = 1_700_000_000; + let mut queue = PersistedDownloadQueue::default(); + queue.scheduled.push(persisted_rule_recording("future-match", Some("rule-1"), now + 60)); + queue.scheduled.push(persisted_rule_recording("past-match", Some("rule-1"), now - 60)); + queue.queue.push(persisted_rule_recording("other-rule", Some("rule-2"), now + 60)); + + let cancelled = cancel_future_rule_recordings_in_candidate(&mut queue, "rule-1", now); + + assert_eq!(cancelled.len(), 1); + assert_eq!(queue.scheduled.len(), 1); + assert_eq!(queue.queue.len(), 1); + assert_eq!(queue.finished.len(), 1); + let task = &queue.finished[0]; + assert_eq!(task.uuid, "future-match"); + assert_eq!(task.state, DownloadState::Cancelled); + assert!(task.finished); + assert_eq!(task.recording.as_ref().map(|meta| meta.reserved_bytes), Some(0)); + } + + #[tokio::test] + async fn preview_conflict_collects_demand_points_from_queue_state() { + // The server-side preview must build its own demand points from + // the committed queue state. A queued recording on the same + // target/input pair must show up as `others` even when the + // caller submits no `others` payload. + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads.json"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(state_file.clone()))); + let mut existing = persisted_rule_recording("existing", None, 100); + // Place a padded window that overlaps 100..200. + if let Some(meta) = existing.recording.as_mut() { + meta.scheduled_start = Some(100); + meta.scheduled_end = Some(200); + } + let existing = DownloadQueue::from_persisted(existing).expect("valid recording task"); + downloads.queue.lock().await.push_back(existing); + let points = collect_demand_points_for_provider(&downloads, "1", "input-a").await; + assert_eq!(points.len(), 1, "queue entry must surface as a demand point"); + assert_eq!(points[0].padded_start, 100); + assert_eq!(points[0].padded_end, 200); + } + + #[tokio::test] + async fn preview_conflict_ignores_other_target_or_input() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_file = dir.path().join("downloads.json"); + let downloads = Arc::new(DownloadQueue::new_with_state_file(Some(state_file.clone()))); + let mut other_target = persisted_rule_recording("other-target", None, 100); + if let Some(other_target_meta) = other_target.recording.as_mut() { + other_target_meta.source = Some(shared::model::recording::RecordingSource::new( + "other-target", + "9", + "input-a", + )); + } + let mut other_input = persisted_rule_recording("other-input", None, 100); + if let Some(other_input_meta) = other_input.recording.as_mut() { + other_input_meta.source = Some(shared::model::recording::RecordingSource::new( + "1", + "9", + "input-b", + )); + } + let other_target_task = DownloadQueue::from_persisted(other_target).expect("valid task"); + let other_input_task = DownloadQueue::from_persisted(other_input).expect("valid task"); + downloads.queue.lock().await.push_back(other_target_task); + downloads.queue.lock().await.push_back(other_input_task); + let points = collect_demand_points_for_provider(&downloads, "1", "input-a").await; + assert!(points.is_empty(), "foreign target or input must not leak into the demand set"); + } + + #[test] + fn validate_reserved_filename_rejects_parent_and_curdir_components() { + assert!(validate_reserved_filename("..").is_err()); + assert!(validate_reserved_filename(".").is_err()); + assert!(validate_reserved_filename("a/..").is_err()); + assert!(validate_reserved_filename("normal.ts").is_ok()); + } + + #[test] + fn is_future_rule_recording_rejects_non_editable_states() { + // Old implementation passed `cancel_targets_task(false, true)` + // literally — that always returned `true`, so the rule cancel + // path would happily tear down a task whose state was already + // terminal. Both terminal and non-editable-but-active states + // must be skipped now. + let mut cancelled_task = persisted_rule_recording("uuid-c", Some("rule-1"), 1_900_000_000); + cancelled_task.state = DownloadState::Cancelled; + assert!(!is_future_rule_recording(&cancelled_task, "rule-1", 1_800_000_000)); + + let mut paused_task = persisted_rule_recording("uuid-p", Some("rule-1"), 1_900_000_000); + paused_task.state = DownloadState::Paused; + assert!(!is_future_rule_recording(&paused_task, "rule-1", 1_800_000_000)); + + // Sanity: the happy path still accepts editable future tasks. + let scheduled_task = persisted_rule_recording("uuid-s", Some("rule-1"), 1_900_000_000); + assert!(is_future_rule_recording(&scheduled_task, "rule-1", 1_800_000_000)); + } +} diff --git a/backend/src/api/model/recording/recording_supervisor/health.rs b/backend/src/api/model/recording/recording_supervisor/health.rs new file mode 100644 index 000000000..cf09ac16a --- /dev/null +++ b/backend/src/api/model/recording/recording_supervisor/health.rs @@ -0,0 +1,63 @@ +//! Supervisor health tracking. +//! +//! Last-tick timestamps and counters, so an operator can tell a healthy +//! supervisor from one that died without reading the log. +//! +//! The health struct lives in `OnceLock` so the health endpoint does not +//! need a handle threaded through `AppState` (which is rebuilt on every +//! config reload, whereas the supervisors outlive one). + +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::OnceLock; + +/// Last-tick timestamps, so an operator can tell a healthy supervisor +/// from one that died. Read by the health endpoint; written by the +/// supervisors themselves. +/// +/// The atomic fields are `pub(super)` rather than private because the +/// supervisor submodules are the only legitimate writers and they stamp +/// them via [`SupervisorHealth::stamp`]; hiding them behind setters +/// would force every caller to invent its own method, which is the +/// opposite of "every caller stamps the same way". +#[derive(Debug, Default)] +pub struct SupervisorHealth { + pub(super) reconciliation_last_run: AtomicI64, + pub(super) retention_last_tick: AtomicI64, + pub(super) notification_last_drain: AtomicI64, + pub(super) notification_outbox_depth: AtomicI64, + pub(super) notification_dead_lettered: AtomicI64, +} + +impl SupervisorHealth { + pub(crate) fn stamp(field: &AtomicI64, now: i64) { + field.store(now, Ordering::Relaxed); + } + + pub fn reconciliation_last_run(&self) -> Option { + non_zero(self.reconciliation_last_run.load(Ordering::Relaxed)) + } + pub fn retention_last_tick(&self) -> Option { + non_zero(self.retention_last_tick.load(Ordering::Relaxed)) + } + pub fn notification_last_drain(&self) -> Option { + non_zero(self.notification_last_drain.load(Ordering::Relaxed)) + } + pub fn notification_outbox_depth(&self) -> i64 { + self.notification_outbox_depth.load(Ordering::Relaxed) + } + pub fn notification_dead_lettered(&self) -> i64 { + self.notification_dead_lettered.load(Ordering::Relaxed) + } +} + +fn non_zero(value: i64) -> Option { + (value != 0).then_some(value) +} + +/// Process-wide health, so the health endpoint does not need a handle +/// threaded through `AppState` (which is rebuilt on every config +/// reload, whereas the supervisors outlive one). +pub fn supervisor_health() -> &'static SupervisorHealth { + static HEALTH: OnceLock = OnceLock::new(); + HEALTH.get_or_init(SupervisorHealth::default) +} diff --git a/backend/src/api/model/recording/recording_supervisor/mod.rs b/backend/src/api/model/recording/recording_supervisor/mod.rs new file mode 100644 index 000000000..6bce7d542 --- /dev/null +++ b/backend/src/api/model/recording/recording_supervisor/mod.rs @@ -0,0 +1,164 @@ +//! Runtime supervisors for the DVR. +//! +//! The recording feature ships three pure decision layers whose runners +//! were never started, so in production the DVR worked on the happy path +//! but could not bound its disk use or heal itself after a crash: +//! +//! - [`recording_retention`](super::recording_retention) computes the +//! age/count candidates and +//! [`recording_worker_runner`](super::recording_worker_runner) computes +//! the disk-pressure candidates, but nothing called them. +//! - [`recording_reconciliation::reconcile`](super::recording_reconciliation::reconcile) +//! decides how to repair queue/rule drift, but nothing called it, so a +//! task left in `Deleting` by a crash stayed there forever. +//! - Lifecycle notifications were fired with a bare `tokio::spawn` from +//! inside the download worker, so a transient provider error dropped +//! the notification with no retry and no record. +//! +//! This module owns the runners. Each one is cancellation-aware, never +//! overlaps its own passes, and re-reads its configuration every tick so +//! a config reload takes effect without a restart. +//! +//! ## Layout +//! +//! The supervisor is split across five files to keep each focused: +//! +//! - [`health`] — last-tick timestamps and counters +//! - [`startup`] — crash-recovery reconciliation at boot +//! - [`retention`] — age / count / disk-pressure sweeps +//! - [`outbox`] — durable, per-channel notification retry +//! - [`mod`] (this file) — shared helpers, the entry point, and the +//! cross-cutting tests + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use log::info; +use shared::model::{Claims, Permission, PermissionSet, CURRENT_PERMISSION_SCHEMA_VERSION, ROLE_ADMIN}; +use tokio_util::sync::CancellationToken; + +use crate::api::model::AppState; +use crate::model::RecordingConfig; + +pub mod health; +pub mod outbox; +pub mod retention; +pub mod startup; + +// Re-export the public surface so existing callers +// (`crate::api::model::recording::recording_supervisor::*`) keep +// working without an import change. +pub use health::{supervisor_health, SupervisorHealth}; +pub use outbox::{notification_outbox, spawn_notification_outbox, NotificationOutbox}; +pub use retention::spawn_retention_supervisor; +pub use startup::run_startup_reconciliation; + +/// The effective recording configuration, cloned out of the `ArcSwap` +/// guard so no guard is held across an await. +pub(crate) fn recording_config(app_state: &AppState) -> Option { + app_state + .app_config + .config + .load() + .video + .as_ref() + .and_then(|video| video.download.as_ref()) + .and_then(|download| download.recording.clone()) +} + +/// `true` when the DVR is switched on. +/// +/// The single predicate behind every `recording.enabled` gate — the REST +/// routes, the rule scheduler, the supervisors, and the WebSocket +/// filters. Keeping one definition is the point: four copies of +/// "is the DVR on?" would eventually disagree, and a half-disabled DVR +/// (routes refusing but the scheduler still materializing) is worse than +/// either state. +/// +/// An absent `recording:` block means "use the defaults", and the default +/// is enabled. +pub fn recording_enabled(app_state: &AppState) -> bool { + recording_config(app_state).is_none_or(|cfg| cfg.enabled) +} + +pub(crate) fn now_ts() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Claims for a system-initiated action. The retention worker is not a +/// user; it holds the administrator role so it can act on shared and +/// legacy-owned recordings, and `RecordingWrite` so the service-level +/// permission checks pass. +pub(crate) fn system_claims() -> Claims { + let mut permissions = PermissionSet::new(); + permissions.set(Permission::RecordingWrite); + permissions.set(Permission::RecordingRead); + let now = now_ts(); + Claims { + username: crate::auth::SYSTEM_PRINCIPAL_USERNAME.to_string(), + iss: "tuliprox".to_string(), + iat: now, + exp: now + 3600, + roles: vec![ROLE_ADMIN.to_string()], + permissions, + pwd_version: 0, + subject_id: Some(shared::model::UserId::builtin_admin()), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + } +} + +/// A guard that makes a supervisor's passes strictly non-overlapping. +pub(crate) struct PassGuard(Arc); + +impl PassGuard { + pub(crate) fn try_claim(flag: &Arc) -> Option { + flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| Self(Arc::clone(flag))) + } +} + +impl Drop for PassGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +/// Start every DVR supervisor. Called once the HTTP listener is bound so +/// the reconciliation pass cannot delay the bind. +pub async fn start_recording_supervisors(app_state: &Arc, cancel_token: &CancellationToken) { + if !recording_enabled(app_state.as_ref()) { + info!("Recording is disabled; DVR supervisors not started"); + return; + } + // Reconcile before anything else can materialize or sweep, so the + // scheduler never plans against half-repaired state. + run_startup_reconciliation(app_state).await; + spawn_notification_outbox(app_state, cancel_token); + spawn_retention_supervisor(app_state, cancel_token); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn an_absent_recording_block_means_enabled() { + // The DVR must be on for a deployment that never mentions + // `recording:` — otherwise upgrading would silently switch off a + // feature the operator was already using. + let config = crate::model::Config::default(); + let app_state = crate::api::model::create_test_app_state(config); + assert!(recording_enabled(app_state.as_ref())); + assert!(recording_config(app_state.as_ref()).is_none()); + } + + #[test] + fn pass_guard_prevents_overlapping_passes() { + let flag = Arc::new(AtomicBool::new(false)); + let first = PassGuard::try_claim(&flag).expect("first claim"); + assert!(PassGuard::try_claim(&flag).is_none()); + drop(first); + assert!(PassGuard::try_claim(&flag).is_some()); + } +} diff --git a/backend/src/api/model/recording/recording_supervisor/outbox.rs b/backend/src/api/model/recording/recording_supervisor/outbox.rs new file mode 100644 index 000000000..854920304 --- /dev/null +++ b/backend/src/api/model/recording/recording_supervisor/outbox.rs @@ -0,0 +1,409 @@ +//! Notification outbox. +//! +//! The recorder used to `tokio::spawn(send_message(..))` straight from +//! its persist path, so a transient provider error lost the +//! notification permanently and a crash between the persist and the +//! spawn lost it too. The worker in this module owns delivery instead: +//! entries are persisted to +//! `storage_dir/recording_notification_outbox.json` before the first +//! attempt, retried per channel with capped exponential backoff, and +//! dead-lettered with a log line after `max_attempts`. +//! +//! Per-channel retry is what makes the retry at-most-once *per channel*: +//! a message that reached Telegram but not Discord is retried only +//! against Discord, so a retry can never duplicate a delivered message. + +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use log::{debug, error, info}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::api::model::AppState; +use crate::messaging::{configured_channels, send_message_to_channel, MessagingChannel}; +use crate::model::{MessageContent, RecordingNotificationConfig}; + +use super::health::{supervisor_health, SupervisorHealth}; +use super::{now_ts, recording_config}; + +/// Outbox file name under `storage_dir`. +const NOTIFICATION_OUTBOX_FILE: &str = "recording_notification_outbox.json"; + +/// One queued notification, per channel. +/// +/// `pending` is what makes the retry at-most-once *per channel*: a +/// message that reached Telegram but not Discord is retried only against +/// Discord, so a retry can never duplicate a delivered message. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct OutboxEntry { + id: u64, + content: MessageContent, + pending: Vec, + attempts: u32, + enqueued_at: i64, + next_attempt_at: i64, +} + +#[derive(Debug, Default, serde::Serialize, serde::Deserialize)] +struct OutboxFile { + #[serde(default)] + next_id: u64, + #[serde(default)] + entries: Vec, +} + +/// Sender side of the outbox. Cloneable and cheap; `enqueue` never +/// blocks the caller. +#[derive(Debug, Clone)] +pub struct NotificationOutbox { + sender: mpsc::Sender, +} + +impl NotificationOutbox { + /// Hand a notification to the outbox worker. + /// + /// Never blocks and never awaits: a recording must not stall because + /// a messaging provider is slow. Returns the notification back when + /// the outbox cannot take it (bounded channel full, or the worker has + /// shut down) so the caller can decide — the download worker falls + /// back to a direct best-effort send. + pub fn enqueue(&self, content: MessageContent) -> Option { + match self.sender.try_send(content) { + Ok(()) => None, + Err(mpsc::error::TrySendError::Full(content)) => { + error!("Recording notification outbox is full; falling back to a direct send"); + Some(content) + } + Err(mpsc::error::TrySendError::Closed(content)) => Some(content), + } + } +} + +/// The process-wide outbox handle, installed by +/// [`spawn_notification_outbox`]. +static OUTBOX: OnceLock = OnceLock::new(); + +/// The installed outbox, if the supervisor has started. Callers that +/// find `None` (unit tests, early startup) fall back to a direct send. +pub fn notification_outbox() -> Option<&'static NotificationOutbox> { + OUTBOX.get() +} + +/// Start the notification outbox worker. +/// +/// Idempotent: calling it twice installs only the first worker. +pub fn spawn_notification_outbox(app_state: &Arc, cancel_token: &CancellationToken) { + let config = notification_config(app_state.as_ref()); + let (sender, receiver) = mpsc::channel(config.outbox_buffer); + if OUTBOX.set(NotificationOutbox { sender }).is_err() { + debug!("Recording notification outbox already installed"); + return; + } + let app_state = Arc::clone(app_state); + let cancel_token = cancel_token.clone(); + tokio::spawn(async move { + run_notification_outbox(app_state, receiver, cancel_token).await; + }); +} + +fn notification_config(app_state: &AppState) -> RecordingNotificationConfig { + recording_config(app_state).map_or_else(RecordingNotificationConfig::default, |cfg| cfg.notifications) +} + +fn outbox_path(app_state: &AppState) -> PathBuf { + PathBuf::from(app_state.app_config.config.load().storage_dir.as_str()) + .join(NOTIFICATION_OUTBOX_FILE) +} + +async fn run_notification_outbox( + app_state: Arc, + mut receiver: mpsc::Receiver, + cancel_token: CancellationToken, +) { + let path = outbox_path(app_state.as_ref()); + let mut file = load_outbox(&path).await; + if !file.entries.is_empty() { + info!( + "Recording notification outbox recovered {} undelivered notification(s)", + file.entries.len() + ); + } + // Once every sender is gone `recv()` completes instantly and forever, + // so the loop must stop polling it or it would spin. + let mut senders_gone = false; + loop { + let sleep_for = next_wakeup(&file, now_ts()); + let mut received_content = None; + if senders_gone { + // Nothing new can arrive, but the existing backlog still + // deserves its remaining attempts. + if file.entries.is_empty() { + break; + } + tokio::select! { + () = cancel_token.cancelled() => break, + () = tokio::time::sleep(sleep_for) => {} + } + } else { + tokio::select! { + () = cancel_token.cancelled() => break, + message = receiver.recv() => { + match message { + Some(content) => received_content = Some(content), + None => senders_gone = true, + } + } + () = tokio::time::sleep(sleep_for) => {} + } + } + if let Some(content) = received_content { + admit(&mut file, content); + // Drain whatever else is already queued so a burst of + // completions costs one persist, not one per event. + while let Ok(content) = receiver.try_recv() { + admit(&mut file, content); + } + persist_outbox(&path, &file).await; + } + if drain_due_entries(&app_state, &mut file).await { + persist_outbox(&path, &file).await; + } + supervisor_health() + .notification_outbox_depth + .store(super::super::recording_math::sat_i64_from_u64(file.entries.len() as u64), Ordering::Relaxed); + } + debug!("Recording notification outbox stopped"); +} + +/// How long to sleep before the next delivery attempt is due. +fn next_wakeup(file: &OutboxFile, now: i64) -> Duration { + file.entries + .iter() + .map(|entry| entry.next_attempt_at.saturating_sub(now).max(0)) + .min() + .map_or(Duration::from_hours(1), |secs| { + Duration::from_secs(u64::try_from(secs).unwrap_or(0)) + }) +} + +/// Accept a new notification into the outbox. +fn admit(file: &mut OutboxFile, content: MessageContent) { + let now = now_ts(); + file.next_id = file.next_id.wrapping_add(1); + file.entries.push(OutboxEntry { + id: file.next_id, + content, + // Resolved at attempt time, not here: the channel set is read from + // the live config so a reload between enqueue and delivery is + // honoured. + pending: Vec::new(), + attempts: 0, + enqueued_at: now, + next_attempt_at: now, + }); +} + +/// Attempt every due entry. Returns `true` when the outbox changed and +/// has to be persisted. +async fn drain_due_entries(app_state: &Arc, file: &mut OutboxFile) -> bool { + let now = now_ts(); + if !file.entries.iter().any(|entry| entry.next_attempt_at <= now) { + return false; + } + SupervisorHealth::stamp(&supervisor_health().notification_last_drain, now); + let config = notification_config(app_state.as_ref()); + let client = app_state.http_client.load_full(); + let app_config = Arc::clone(&app_state.app_config); + let mut changed = false; + let mut keep: Vec = Vec::with_capacity(file.entries.len()); + for mut entry in std::mem::take(&mut file.entries) { + if entry.next_attempt_at > now { + keep.push(entry); + continue; + } + changed = true; + if entry.pending.is_empty() && entry.attempts == 0 { + entry.pending = configured_channels(&app_config, entry.content.kind()); + } + if entry.pending.is_empty() { + // No channel wants this message kind. Nothing to deliver and + // nothing to retry. + continue; + } + entry.attempts = entry.attempts.saturating_add(1); + let mut still_pending = Vec::new(); + for channel in std::mem::take(&mut entry.pending) { + match send_message_to_channel(&app_config, &client, &entry.content, channel).await { + // Delivered, or the channel is no longer configured: either + // way it must not be retried. + Some(true) | None => {} + Some(false) => still_pending.push(channel), + } + } + if still_pending.is_empty() { + debug!("Recording notification {} delivered after {} attempt(s)", entry.id, entry.attempts); + continue; + } + entry.pending = still_pending; + if entry.attempts >= config.max_attempts { + supervisor_health() + .notification_dead_lettered + .fetch_add(1, Ordering::Relaxed); + error!( + target: "recording::audit", + "recording_notification_dead_lettered: kind={:?} attempts={} channels={:?} enqueued_at={}", + entry.content.kind(), entry.attempts, entry.pending, entry.enqueued_at + ); + continue; + } + entry.next_attempt_at = now.saturating_add(backoff_secs(&config, entry.attempts)); + keep.push(entry); + } + file.entries = keep; + changed +} + +/// Capped exponential backoff: `initial * 2^(attempts-1)`, clamped to +/// `backoff_max_secs`. +fn backoff_secs(config: &RecordingNotificationConfig, attempts: u32) -> i64 { + let shift = attempts.saturating_sub(1).min(16); + let delay = config + .backoff_initial_secs + .saturating_mul(1u64 << shift) + .min(config.backoff_max_secs); + super::super::recording_math::sat_i64_from_u64(delay) +} + +async fn load_outbox(path: &std::path::Path) -> OutboxFile { + match tokio::fs::read(path).await { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(file) => file, + Err(err) => { + // A corrupt outbox must not stop the server. Losing + // undelivered notifications is the lesser failure. + error!("Recording notification outbox at {} is unreadable, starting empty: {err}", path.display()); + OutboxFile::default() + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => OutboxFile::default(), + Err(err) => { + error!("Could not read the recording notification outbox at {}: {err}", path.display()); + OutboxFile::default() + } + } +} + +async fn persist_outbox(path: &std::path::Path, file: &OutboxFile) { + match serde_json::to_vec_pretty(file) { + Ok(bytes) => { + if let Err(err) = crate::utils::atomic_json_store::write_json_atomic(path, &bytes).await { + error!("Could not persist the recording notification outbox: {err}"); + } + } + Err(err) => error!("Could not serialize the recording notification outbox: {err}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::RecordingLifecycleMessage; + use shared::model::MsgKind; + + fn config() -> RecordingNotificationConfig { + RecordingNotificationConfig { + outbox_buffer: 8, + max_attempts: 4, + backoff_initial_secs: 5, + backoff_max_secs: 100, + } + } + + fn lifecycle() -> MessageContent { + MessageContent::RecordingLifecycle(RecordingLifecycleMessage { + event: MsgKind::RecordingCompleted, + programme_title: Some("Programme".into()), + channel: Some("Channel".into()), + effective_start: Some(1_700_000_000), + effective_end: Some(1_700_003_600), + visibility: Some("shared".into()), + output_filename: Some("programme.ts".into()), + failure_reason: None, + }) + } + + #[test] + fn backoff_grows_then_saturates_at_the_configured_ceiling() { + let config = config(); + assert_eq!(backoff_secs(&config, 1), 5); + assert_eq!(backoff_secs(&config, 2), 10); + assert_eq!(backoff_secs(&config, 3), 20); + assert_eq!(backoff_secs(&config, 4), 40); + assert_eq!(backoff_secs(&config, 5), 80); + // Clamped, and never overflows however many attempts are asked for. + assert_eq!(backoff_secs(&config, 6), 100); + assert_eq!(backoff_secs(&config, u32::MAX), 100); + } + + #[test] + fn admitted_entries_are_due_immediately_and_get_distinct_ids() { + let mut file = OutboxFile::default(); + admit(&mut file, lifecycle()); + admit(&mut file, lifecycle()); + assert_eq!(file.entries.len(), 2); + assert_ne!(file.entries[0].id, file.entries[1].id); + let now = now_ts(); + assert!(file.entries.iter().all(|entry| entry.next_attempt_at <= now)); + // Channels are resolved at attempt time, from the live config. + assert!(file.entries.iter().all(|entry| entry.pending.is_empty())); + } + + #[test] + fn next_wakeup_is_zero_when_something_is_already_due() { + let mut file = OutboxFile::default(); + admit(&mut file, lifecycle()); + assert_eq!(next_wakeup(&file, now_ts()), Duration::from_secs(0)); + } + + #[test] + fn next_wakeup_picks_the_earliest_pending_attempt() { + let mut file = OutboxFile::default(); + admit(&mut file, lifecycle()); + admit(&mut file, lifecycle()); + file.entries[0].next_attempt_at = 1_000; + file.entries[1].next_attempt_at = 400; + assert_eq!(next_wakeup(&file, 100), Duration::from_secs(300)); + } + + #[test] + fn next_wakeup_on_an_empty_outbox_is_a_long_idle_sleep() { + assert_eq!(next_wakeup(&OutboxFile::default(), 0), Duration::from_secs(3600)); + } + + #[test] + fn outbox_file_round_trips_through_json() { + let mut file = OutboxFile::default(); + admit(&mut file, lifecycle()); + file.entries[0].pending = vec![MessagingChannel::Telegram, MessagingChannel::Discord]; + file.entries[0].attempts = 2; + let bytes = serde_json::to_vec(&file).expect("serialize"); + let restored: OutboxFile = serde_json::from_slice(&bytes).expect("deserialize"); + assert_eq!(restored.entries.len(), 1); + assert_eq!(restored.entries[0].attempts, 2); + assert_eq!( + restored.entries[0].pending, + vec![MessagingChannel::Telegram, MessagingChannel::Discord] + ); + } + + #[test] + fn a_full_outbox_hands_the_notification_back_instead_of_blocking() { + let (sender, _receiver) = mpsc::channel(1); + let outbox = NotificationOutbox { sender }; + assert!(outbox.enqueue(lifecycle()).is_none()); + assert!(outbox.enqueue(lifecycle()).is_some()); + } +} diff --git a/backend/src/api/model/recording/recording_supervisor/retention.rs b/backend/src/api/model/recording/recording_supervisor/retention.rs new file mode 100644 index 000000000..0876d9d5f --- /dev/null +++ b/backend/src/api/model/recording/recording_supervisor/retention.rs @@ -0,0 +1,236 @@ +//! Retention supervisor. +//! +//! One task drives both sweeps so they can never delete concurrently: +//! +//! - the **policy sweep** (`keep_last_per_channel` / `delete_after_days`) +//! runs every `retention.sweep_interval_secs`; +//! - the **disk-pressure sweep** runs on the shorter +//! `disk.cleanup_interval_secs` cadence, and only actually measures the +//! filesystem when at least [`MIN_DISK_PRESSURE_INTERVAL_SECS`] have +//! passed since the last measurement. +//! +//! Both sweeps delete through +//! [`RecordingService::system_retention_delete`](crate::api::model::recording_service::RecordingService::system_retention_delete), +//! so there is exactly one deletion path in the system. + +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::Duration; + +use log::{debug, error, info}; +use shared::model::Claims; +use tokio_util::sync::CancellationToken; + +use crate::api::model::recording_service::{RecordingService, ServiceError}; +use crate::api::model::recording_worker_runner::{DeleteOutcome, DiskConfig}; +use crate::api::model::{AppState, EventMessage}; + +use super::health::{supervisor_health, SupervisorHealth}; +use super::{ + now_ts, recording_config, recording_enabled, system_claims, PassGuard, +}; + +/// Floor on how often the recording root is measured. `statvfs` is cheap +/// but not free, and it would be wasteful to re-measure on a tick that +/// fires seconds after the last one (which a small +/// `disk.cleanup_interval_secs` would do). +const MIN_DISK_PRESSURE_INTERVAL_SECS: u64 = 30; + +/// Fallback watermark-check cadence when `disk.cleanup_interval_secs` is +/// unset. +const DEFAULT_WATERMARK_CHECK_INTERVAL_SECS: u64 = 60; + +/// Start the retention supervisor. +pub fn spawn_retention_supervisor(app_state: &Arc, cancel_token: &CancellationToken) { + let app_state = Arc::clone(app_state); + let cancel_token = cancel_token.clone(); + let running = Arc::new(AtomicBool::new(false)); + tokio::spawn(async move { + let mut next_policy_sweep_at = 0i64; + let mut last_disk_measurement_at = 0i64; + loop { + let tick_interval = watermark_check_interval(app_state.as_ref()); + tokio::select! { + () = cancel_token.cancelled() => break, + () = tokio::time::sleep(tick_interval) => {} + } + if !recording_enabled(app_state.as_ref()) { + continue; + } + // Skip the tick entirely if the previous pass is still + // deleting; passes must never overlap. + let Some(_guard) = PassGuard::try_claim(&running) else { + debug!("Retention supervisor tick skipped: previous pass still running"); + continue; + }; + let now = now_ts(); + SupervisorHealth::stamp(&supervisor_health().retention_last_tick, now); + + let mut deleted = 0u64; + if now >= next_policy_sweep_at { + deleted += run_policy_sweep(&app_state, now).await; + next_policy_sweep_at = now.saturating_add(policy_sweep_interval_secs(app_state.as_ref())); + } + if now.saturating_sub(last_disk_measurement_at) >= i64::try_from(MIN_DISK_PRESSURE_INTERVAL_SECS).unwrap_or(30) { + last_disk_measurement_at = now; + deleted += run_disk_pressure_sweep(&app_state).await; + } + if deleted > 0 { + let _ = app_state.event_manager.send_event(EventMessage::RecordingChanged); + } + } + debug!("Retention supervisor stopped"); + }); +} + +fn policy_sweep_interval_secs(app_state: &AppState) -> i64 { + let secs = recording_config(app_state) + .and_then(|cfg| cfg.retention.map(|retention| retention.sweep_interval_secs)) + .unwrap_or_else(shared::model::default_recording_retention_sweep_interval_secs); + i64::try_from(secs.max(1)).unwrap_or(3600) +} + +fn watermark_check_interval(app_state: &AppState) -> Duration { + let secs = recording_config(app_state) + .and_then(|cfg| cfg.disk.and_then(|disk| disk.cleanup_interval_secs)) + .filter(|secs| *secs > 0) + .unwrap_or(DEFAULT_WATERMARK_CHECK_INTERVAL_SECS); + // Never tick faster than the disk measurement floor: a tighter + // cadence would only burn wake-ups. + Duration::from_secs(secs.clamp(1, 3600)) +} + +/// Age + count retention. +async fn run_policy_sweep(app_state: &Arc, now: i64) -> u64 { + let Some(config) = recording_config(app_state.as_ref()) else { + return 0; + }; + let Some(retention) = config.retention.as_ref() else { + return 0; + }; + let policy = super::super::recording_retention::RetentionConfig { + keep_last_per_channel: retention.keep_last_per_channel, + delete_after_days: retention.delete_after_days, + }; + if policy.keep_last_per_channel.is_none() && policy.delete_after_days.is_none() { + return 0; + } + let (_revision, tasks) = app_state.downloads.committed_snapshot().await; + let candidates = super::super::recording_retention::compute_candidates(&tasks, &policy, now); + if candidates.is_empty() { + return 0; + } + let service = RecordingService::from_app_state(app_state); + let claims = system_claims(); + let mut deleted = 0u64; + for candidate in &candidates { + match delete_for_retention(&service, &claims, &candidate.uuid).await { + DeleteOutcome::Ok => { + deleted += 1; + info!( + target: "recording::audit", + "recording_retention_delete: reason={:?}", candidate.reason + ); + } + DeleteOutcome::Skipped | DeleteOutcome::Failed => {} + } + } + if deleted > 0 { + info!( + "Retention policy sweep deleted {deleted} of {} candidate recording(s)", + candidates.len() + ); + } + deleted +} + +/// Free-space driven retention. Only runs when both watermarks are +/// configured and the recording root is measurable. +async fn run_disk_pressure_sweep(app_state: &Arc) -> u64 { + let Some(config) = recording_config(app_state.as_ref()) else { + return 0; + }; + let Some(disk) = config.disk.as_ref() else { + return 0; + }; + let disk_config = DiskConfig { + high_water_percent: disk.high_water_percent, + low_water_percent: disk.low_water_percent, + safety_bytes: disk.safety_bytes, + }; + if disk_config.high_water_percent.is_none() || disk_config.low_water_percent.is_none() { + return 0; + } + let root = PathBuf::from(&config.directory); + if root.as_os_str().is_empty() { + return 0; + } + // Measure the recording root itself, never `storage_dir` or the + // generic download directory — those can be on another filesystem. + let Some((total_bytes, free_bytes)) = super::super::recording_disk::filesystem_capacity_for(&root) else { + debug!("Disk-pressure sweep skipped: cannot measure {}", root.display()); + return 0; + }; + if total_bytes == 0 { + return 0; + } + let used = total_bytes.saturating_sub(free_bytes); + let used_percent = u8::try_from(used.saturating_mul(100) / total_bytes).unwrap_or(100); + + let (_revision, tasks) = app_state.downloads.committed_snapshot().await; + // The candidate ordering and the admission conditions stay in the + // pure runner; only the delete side effect lives here, so the loop + // can `await` instead of blocking a worker thread. + let Some(candidates) = super::super::recording_worker_runner::disk_pressure_candidates( + &tasks, + &disk_config, + used_percent, + true, + ) else { + return 0; + }; + let low = disk_config.low_water_percent.unwrap_or(0); + let service = RecordingService::from_app_state(app_state); + let claims = system_claims(); + let mut deleted = 0u64; + let mut reclaimed = 0u64; + for candidate in &candidates { + if super::super::recording_worker_runner::pressure_relieved(total_bytes, free_bytes, reclaimed, low) { + break; + } + let reclaimable = super::super::recording_worker_runner::reclaimable_bytes_for(&tasks, &candidate.uuid); + if matches!( + delete_for_retention(&service, &claims, &candidate.uuid).await, + DeleteOutcome::Ok + ) { + deleted += 1; + reclaimed = reclaimed.saturating_add(reclaimable); + } + } + info!( + target: "recording::audit", + "recording_retention_delete: reason=watermark used_percent={used_percent} candidates={} deleted={deleted} reclaimed_bytes={reclaimed}", + candidates.len() + ); + deleted +} + +async fn delete_for_retention( + service: &RecordingService, + claims: &Claims, + uuid: &str, +) -> DeleteOutcome { + match service.system_retention_delete(claims, uuid).await { + Ok(()) => DeleteOutcome::Ok, + // The task moved on (already deleted, no longer terminal, or not + // safe to touch). Not an error: the next sweep re-evaluates. + Err(ServiceError::UnknownRecording | ServiceError::InvalidState | ServiceError::Forbidden) => { + DeleteOutcome::Skipped + } + Err(err) => { + error!("Retention delete failed for recording {uuid}: {err}"); + DeleteOutcome::Failed + } + } +} diff --git a/backend/src/api/model/recording/recording_supervisor/startup.rs b/backend/src/api/model/recording/recording_supervisor/startup.rs new file mode 100644 index 000000000..55509d0d4 --- /dev/null +++ b/backend/src/api/model/recording/recording_supervisor/startup.rs @@ -0,0 +1,280 @@ +//! Startup reconciliation. +//! +//! Two independent repairs run once the HTTP listener is bound: +//! +//! 1. **Stuck deletions.** A crash between the `Deleting` state flip and +//! the queue removal leaves a task the UI shows as terminal but which +//! can never be deleted again. For each such task the physical file +//! decides: gone → finish the deletion and drop the task; present and +//! inside the recording root → restore the prior terminal state; +//! present but at a path outside the recording root → restore the +//! task, leave the file alone, and record an audit line. Restoring +//! rather than dropping is deliberate: dropping the task would orphan +//! a file nothing tracks any more, whereas a restored task stays +//! visible for an operator to resolve. +//! 2. **Queue/rule drift.** The queue and the rule repository are two +//! stores; either write can fail alone. The pure planner in +//! [`super::super::recording_reconciliation`] decides the repair and +//! this module applies it, honouring the fixed cross-store order +//! (queue boundary first, then the rule file). +//! +//! Errors are logged, never propagated: a server that cannot reconcile +//! must still start, otherwise a single corrupt tombstone would make +//! the process unbootable. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; + +use log::{debug, error, info, warn}; +use shared::model::recording_rule::{RecordingTombstone, TombstoneKind}; + +use crate::api::model::download::{mutate, FileDownload}; +use crate::api::model::recording_deletion::{ + apply_recovery_to_candidate, recovery_action_for, RecoveryAction, +}; +use crate::api::model::recording_reconciliation::ReconcileAction; +use crate::api::model::{AppState, EventMessage}; +use crate::repository::recording_rule_repository::RecordingRuleRepository; +use shared::model::recording_rule::RecordingRulesFile; + +use super::health::{supervisor_health, SupervisorHealth}; +use super::{now_ts, recording_config, recording_enabled}; + +/// Repair the DVR state left behind by the previous process. +pub async fn run_startup_reconciliation(app_state: &Arc) { + if !recording_enabled(app_state.as_ref()) { + debug!("Recording disabled; skipping DVR startup reconciliation"); + return; + } + let stuck = recover_stuck_deletions(app_state).await; + let drift = reconcile_rule_drift(app_state).await; + SupervisorHealth::stamp(&supervisor_health().reconciliation_last_run, now_ts()); + if stuck > 0 || drift > 0 { + info!("DVR startup reconciliation: repaired {stuck} interrupted deletion(s), {drift} rule drift item(s)"); + let _ = app_state.event_manager.send_event(EventMessage::RecordingChanged); + } +} + +/// Finish or undo every deletion the previous process left half-done. +async fn recover_stuck_deletions(app_state: &Arc) -> usize { + let (_revision, tasks) = app_state.downloads.committed_snapshot().await; + let pending: Vec = tasks + .into_iter() + .filter(|task| { + task.recording + .as_ref() + .is_some_and(|meta| meta.deleting_previous_state.is_some()) + }) + .collect(); + if pending.is_empty() { + return 0; + } + let recording_root = recording_config(app_state.as_ref()) + .map(|cfg| PathBuf::from(cfg.directory)) + .filter(|dir| !dir.as_os_str().is_empty()); + let mut repaired = 0; + for task in pending { + let action = recovery_action_for(&task, recording_root.as_deref()).await; + match action { + RecoveryAction::NotDeleting => continue, + RecoveryAction::UnsafeRestore => { + // Do not touch a file we cannot prove is ours. Restore the + // task so the operator can see it and decide. + warn!( + target: "recording::audit", + "recording_reconciliation_unsafe_path: task {} points outside the recording root; \ + restoring the task and leaving the file alone", + task.uuid + ); + } + RecoveryAction::FinishDeletion | RecoveryAction::RestorePrevious => {} + } + let uuid = task.uuid.clone(); + let finish = matches!(action, RecoveryAction::FinishDeletion); + let outcome = mutate(&app_state.downloads, move |candidate| { + apply_recovery_to_candidate(candidate, &uuid, action); + if finish { + // `apply_recovery_to_candidate` only clears the marker; the + // task itself still has to leave the queue. + candidate.queue.retain(|task| task.uuid != uuid); + candidate.scheduled.retain(|task| task.uuid != uuid); + candidate.finished.retain(|task| task.uuid != uuid); + if candidate.active.as_ref().is_some_and(|task| task.uuid == uuid) { + candidate.active = None; + } + } + Ok(()) + }) + .await; + match outcome { + Ok(()) => { + repaired += 1; + debug!("Recovered interrupted deletion for recording {} ({action:?})", task.uuid); + } + Err(err) => error!( + "Failed to recover interrupted deletion for recording {}: {err}", + task.uuid + ), + } + } + repaired +} + +/// Apply the reconciliation plan for queue/rule drift. +async fn reconcile_rule_drift(app_state: &Arc) -> usize { + let storage_dir = app_state.app_config.config.load().storage_dir.clone(); + let repo = RecordingRuleRepository::new(storage_dir); + let mut file = match repo.load().await { + Ok(file) => file, + Err(err) => { + error!("DVR reconciliation could not load the rule repository: {err}"); + return 0; + } + }; + let tasks = super::super::recording_rule_scheduler::reconcilable_tasks(app_state.as_ref()).await; + let now = now_ts(); + let actions = super::super::recording_reconciliation::reconcile(&file.rules, &tasks, &file.tombstones, now); + if actions.is_empty() { + return 0; + } + + // Queue-side actions first — the fixed cross-store order is + // "queue mutation boundary -> rule repository mutation". + let mut applied = finalize_cancelled_occurrences_in_queue(&actions, &app_state.downloads).await; + + // Rule-side actions: one save for the whole plan. + let (more, changed) = apply_rule_actions_to_tombstones(&actions, &mut file, now); + applied += more; + if changed { + if let Err(err) = repo.save(&file).await { + error!("DVR reconciliation could not persist repaired tombstones: {err}"); + return applied; + } + let _ = app_state.event_manager.send_event(EventMessage::RecordingRulesChanged); + } + applied +} + +/// Drop every queue-resident task the planner wants finalized, under a +/// single mutation boundary. The closure borrows the borrowed +/// `HashSet<&str>` rather than cloning uuids into a second owned set. +async fn finalize_cancelled_occurrences_in_queue( + actions: &[super::super::recording_reconciliation::ReconcileAction], + downloads: &crate::api::model::download::DownloadQueue, +) -> usize { + let finalize: Vec<&str> = actions + .iter() + .filter_map(|action| match action { + super::super::recording_reconciliation::ReconcileAction::Finalize { uuid } => Some(uuid.as_str()), + _ => None, + }) + .collect(); + if finalize.is_empty() { + return 0; + } + let count = finalize.len(); + let targets: HashSet<&str> = finalize.iter().copied().collect(); + match mutate(downloads, |candidate| { + candidate.queue.retain(|task| !targets.contains(task.uuid.as_str())); + candidate.scheduled.retain(|task| !targets.contains(task.uuid.as_str())); + candidate.finished.retain(|task| !targets.contains(task.uuid.as_str())); + Ok(()) + }) + .await + { + Ok(()) => count, + Err(err) => { + error!("DVR reconciliation could not finalize cancelled occurrences: {err}"); + 0 + } + } +} + +/// Apply the rule-side actions to the in-memory tombstone list and +/// return `(applied, changed)`. `changed` is what the caller uses to +/// decide whether a save is necessary. +fn apply_rule_actions_to_tombstones( + actions: &[ReconcileAction], + file: &mut RecordingRulesFile, + now: i64, +) -> (usize, bool) { + let mut applied = 0; + let mut tombstones_changed = false; + for action in actions { + match action { + ReconcileAction::AddScheduledTombstone { rule_id, occurrence_key } => { + if !has_tombstone(&file.tombstones.tombstones, rule_id, occurrence_key) { + file.tombstones.tombstones.push(RecordingTombstone { + rule_id: rule_id.clone(), + occurrence_key: occurrence_key.clone(), + kind: TombstoneKind::Scheduled, + created_at: now, + expires_at: super::super::recording_reconciliation::tombstone_expires_at(now, None), + }); + tombstones_changed = true; + applied += 1; + } + } + ReconcileAction::UpdateTombstone { rule_id, occurrence_key, new_kind } => { + if let Some(tombstone) = file + .tombstones + .tombstones + .iter_mut() + .find(|t| &t.rule_id == rule_id && &t.occurrence_key == occurrence_key) + { + if tombstone.kind != *new_kind { + tombstone.kind = *new_kind; + tombstones_changed = true; + applied += 1; + } + } + } + ReconcileAction::Materialize { rule_id, occurrence_key } => { + // The task for a still-live `Scheduled` tombstone is gone. + // The occurrence key alone cannot be turned back into a + // programme window, so drop the orphan tombstone and let + // the rule scheduler re-plan the occurrence from the rule + // and the EPG on its next tick. + let before = file.tombstones.tombstones.len(); + file.tombstones.tombstones.retain(|t| { + !(&t.rule_id == rule_id + && &t.occurrence_key == occurrence_key + && matches!(t.kind, TombstoneKind::Scheduled)) + }); + if file.tombstones.tombstones.len() != before { + tombstones_changed = true; + applied += 1; + debug!("DVR reconciliation released orphan occurrence {rule_id}/{occurrence_key} for re-planning"); + } + } + ReconcileAction::PruneTombstone { rule_id, occurrence_key } => { + let before = file.tombstones.tombstones.len(); + file.tombstones + .tombstones + .retain(|t| !(&t.rule_id == rule_id && &t.occurrence_key == occurrence_key && t.expires_at <= now)); + if file.tombstones.tombstones.len() != before { + tombstones_changed = true; + applied += 1; + } + } + ReconcileAction::ConflictingIntent { uuid, intent } => { + // Never cancel an active recording because of a stale + // intent. Surface it and let the operator decide. + warn!( + target: "recording::audit", + "recording_reconciliation_conflicting_intent: recording {uuid} is active but the \ + rule store recorded a {intent:?} intent; leaving the recording running" + ); + } + ReconcileAction::Finalize { .. } | ReconcileAction::Noop => {} + } + } + (applied, tombstones_changed) +} + +fn has_tombstone(tombstones: &[RecordingTombstone], rule_id: &str, occurrence_key: &str) -> bool { + tombstones + .iter() + .any(|t| t.rule_id == rule_id && t.occurrence_key == occurrence_key) +} diff --git a/backend/src/api/model/recording/recording_worker.rs b/backend/src/api/model/recording/recording_worker.rs new file mode 100644 index 000000000..10df4eaf3 --- /dev/null +++ b/backend/src/api/model/recording/recording_worker.rs @@ -0,0 +1,729 @@ +use crate::api::model::{DownloadControl, FileDownload}; +use log::debug; +use shared::model::RecordingContainerFormat; +use std::path::{Path, PathBuf}; +use tokio::sync::{Notify, RwLock}; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordingExecutionResult { + Completed, + Paused, + Cancelled, + Preempted, + Retryable(String), + Failed(String), +} + +fn is_generic_ffmpeg_stderr_line(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("conversion failed!") + || trimmed.eq_ignore_ascii_case("exiting normally, received signal 15.") +} + +fn stderr_summary(stderr: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr); + stderr + .lines() + .rev() + .find(|line| !is_generic_ffmpeg_stderr_line(line)) + .map_or_else(|| "ffmpeg failed".to_string(), |line| line.trim().to_string()) +} + +/// Substrings in an ffmpeg stderr line that mean "the source was +/// briefly unavailable, try again inside the window" rather than "this +/// recording cannot succeed". +/// +/// Kept as a `const` so the entries stay lowercase by construction: the +/// matcher lowercases the haystack once, so an upper-case entry added +/// here would silently never match. +/// +/// Substring supersets are removed: `"timed out"` already matches every +/// line that contains `"connection timed out"`, so the narrower phrase +/// is dead weight. The +/// [`retryable_phrases_have_no_proper_subset`](tests::retryable_phrases_have_no_proper_subset) +/// test catches future reintroductions. +const RETRYABLE_FFMPEG_PHRASES: &[&str] = &[ + "timed out", + "temporarily unavailable", + "connection reset", + "connection refused", + "connection closed", + "broken pipe", + "unexpected eof", + "end of file", + "network is unreachable", + "no route to host", + "name or service not known", + "temporary failure in name resolution", + "could not resolve", + "failed to resolve hostname", + "server returned 5", + "http error 5", + "http error 429", + "429 too many requests", + "503 service unavailable", + "502 bad gateway", + "504 gateway timeout", + "500 internal server error", + "tls handshake", + "tls timeout", + "tls: handshake", + "i/o error", +]; + +/// Strip URL-shaped tokens out of a stderr line. +/// +/// ffmpeg echoes the input URL in most of its error lines, so a +/// provider whose path or query happens to contain e.g. +/// `connection_refused` would flip every fatal error into a retryable +/// one and the worker would spin until the recording window closed. +/// The classifier must only see ffmpeg's own words. +fn is_url_token(token: &str) -> bool { + token.starts_with("http://") + || token.starts_with("https://") + || token.starts_with("rtmp://") + || token.starts_with("rtsp://") + || token.starts_with("udp://") + || token.starts_with("srt://") + || token.starts_with("file://") +} + +fn strip_url_tokens(message: &str) -> String { + message + .split_whitespace() + .filter(|token| !is_url_token(token)) + .collect::>() + .join(" ") + .to_ascii_lowercase() +} + +fn is_retryable_ffmpeg_failure_message(message: &str) -> bool { + let msg = strip_url_tokens(message); + RETRYABLE_FFMPEG_PHRASES + .iter() + .any(|phrase| msg.contains(phrase)) +} + +fn classify_ffmpeg_failure(stderr: &[u8]) -> RecordingExecutionResult { + let summary = stderr_summary(stderr); + if is_retryable_ffmpeg_failure_message(&summary) { + RecordingExecutionResult::Retryable(summary) + } else { + RecordingExecutionResult::Failed(summary) + } +} + +pub fn remaining_recording_duration_secs(download: &FileDownload, now_ts: i64) -> Option { + match (download.start_at, download.duration_secs) { + (_, None) => None, + // No scheduled start: the whole duration is still ahead. + (None, Some(duration_secs)) => Some(duration_secs), + (Some(start_at), Some(duration_secs)) => { + super::recording_math::remaining_window_secs(start_at, duration_secs, now_ts) + } + } +} + +pub fn build_recording_args( + download: &FileDownload, + effective_duration_secs: u64, + output_path: &Path, + container_format: RecordingContainerFormat, +) -> Vec { + vec![ + "-nostdin".to_string(), + "-hide_banner".to_string(), + "-loglevel".to_string(), + "warning".to_string(), + "-i".to_string(), + download.url.to_string(), + "-map".to_string(), + "0".to_string(), + "-t".to_string(), + effective_duration_secs.to_string(), + // Recording filenames may have no extension (sanitized title-only + // names from `render_filename_preview`), so we force the output + // muxer explicitly to avoid ffmpeg failing format detection with + // `Invalid argument` on paths like `foo.partial`. Which muxer is + // an operator choice: MPEG-TS survives truncation, but an + // H.265/AAC source may need Matroska or MP4. + "-f".to_string(), + container_format.ffmpeg_format().to_string(), + // Overwrite any stale `.partial` from a previous failed attempt. + // Without this, a leftover file (from a crash, retry-with-same-path, + // or startup-recovery race) would either block the new run via + // `recording_resume_or_retry_is_unsupported` or leak on disk. + "-y".to_string(), + "-c".to_string(), + "copy".to_string(), + output_path.to_string_lossy().to_string(), + ] +} + +async fn recording_resume_or_retry_is_unsupported(download: &FileDownload) -> bool { + tokio::fs::metadata(recording_partial_path(&download.file_path)) + .await + .is_ok_and(|metadata| metadata.len() > 0) +} + +/// Remove the partial file from a non-terminal exit so the recording does +/// not leak half-written output on disk. Best-effort: missing file or +/// permission errors are swallowed because the next attempt's `-y` flag +/// will overwrite any survivor anyway. +async fn cleanup_partial(partial_path: &Path) { + let _ = tokio::fs::remove_file(partial_path).await; +} + +pub fn recording_start_missed_window(download: &FileDownload, now_ts: i64) -> bool { + download + .start_at + .zip(download.duration_secs) + .is_some_and(|(start_at, duration_secs)| { + super::recording_math::window_elapsed(start_at, duration_secs, now_ts) + }) +} + +async fn run_recording_with_binary( + ffmpeg_binary: &Path, + download: &FileDownload, + control_signal: &RwLock, + control_notify: &Notify, + cancel_token: Option<&CancellationToken>, + container_format: RecordingContainerFormat, +) -> RecordingExecutionResult { + let now_ts = chrono::Utc::now().timestamp(); + if recording_start_missed_window(download, now_ts) { + return RecordingExecutionResult::Failed("Recording window already expired".to_string()); + } + let Some(effective_duration_secs) = remaining_recording_duration_secs(download, now_ts) else { + return RecordingExecutionResult::Failed("Recording window already expired".to_string()); + }; + + if let Err(err) = tokio::fs::create_dir_all(&download.file_dir).await { + return RecordingExecutionResult::Failed(format!("Error while creating recording directory: {err}")); + } + + if recording_resume_or_retry_is_unsupported(download).await { + return RecordingExecutionResult::Failed( + "Recording resume is not supported".to_string(), + // yet because ffmpeg segment stitching is not implemented + ); + } + + let partial_path = recording_partial_path(&download.file_path); + let args = build_recording_args(download, effective_duration_secs, &partial_path, container_format); + debug!( + "recording spawn: {} {}", + ffmpeg_binary.display(), + args.join(" ") + ); + let mut command = tokio::process::Command::new(ffmpeg_binary); + command + .args(args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + + let child = match command.spawn() { + Ok(child) => child, + Err(err) => return RecordingExecutionResult::Failed(format!("Failed to spawn ffmpeg: {err}")), + }; + + let mut wait_future = Box::pin(child.wait_with_output()); + + loop { + tokio::select! { + biased; + () = async { + if let Some(token) = cancel_token { + token.cancelled().await; + } else { + std::future::pending::<()>().await; + } + } => { + cleanup_partial(&partial_path).await; + return RecordingExecutionResult::Preempted; + } + () = control_notify.notified() => { + let result = match *control_signal.read().await { + DownloadControl::Pause => Some(RecordingExecutionResult::Paused), + DownloadControl::Cancel => Some(RecordingExecutionResult::Cancelled), + DownloadControl::Restart => Some(RecordingExecutionResult::Preempted), + DownloadControl::None => None, + }; + if let Some(result) = result { + cleanup_partial(&partial_path).await; + return result; + } + } + output = &mut wait_future => { + match output { + Ok(output) if output.status.success() => { + return match crate::utils::finalize_no_replace(&partial_path, &download.file_path).await { + Ok(()) => RecordingExecutionResult::Completed, + Err(err) => RecordingExecutionResult::Failed(format!("Failed to finalize recording: {err}")), + }; + } + Ok(output) => return classify_ffmpeg_failure(&output.stderr), + Err(err) => return RecordingExecutionResult::Failed(format!("Failed to wait for ffmpeg: {err}")), + } + } + } + } +} + +pub async fn run_recording( + download: &FileDownload, + control_signal: &RwLock, + control_notify: &Notify, + cancel_token: Option<&CancellationToken>, + container_format: RecordingContainerFormat, +) -> RecordingExecutionResult { + run_recording_with_binary( + Path::new("ffmpeg"), + download, + control_signal, + control_notify, + cancel_token, + container_format, + ) + .await +} + +/// Compute the partial-file path the worker uses for safe no-clobber +/// writes. Inserts `.partial` after any existing extension so the +/// partial keeps the final file's type (`pilot.ts` → `pilot.ts.partial`). +/// When the final path has no extension the partial defaults to +/// `.ts.partial` so the in-progress recording is still recognisable as +/// MPEG-TS. +pub fn recording_partial_path(final_path: &Path) -> PathBuf { + match final_path.extension() { + Some(ext) if !ext.is_empty() => { + let mut new_ext = ext.to_os_string(); + new_ext.push(".partial"); + final_path.with_extension(new_ext) + } + _ => final_path.with_extension("ts.partial"), + } +} + +/// Summary of one startup-recovery decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryDecision { + /// Active state with a valid final file → normalize to `Completed`. + Completed, + /// Active state with a partial file → normalize to terminal `Failed`, + /// retain the partial. + FailedPartialKept, + /// Active state with no owned file → normalize to terminal `Failed`. + FailedNoFile, + /// Path was unsafe (symlink, wrong type, outside root) → fail closed + /// without opening the file. + UnsafePath, +} + +/// Inspect a recording's current filesystem state and return the recovery +/// decision the startup loop should apply. The function is pure: it does +/// not mutate the queue or open files. +pub async fn recovery_decision_for(final_path: &Path, partial: &Path) -> RecoveryDecision { + if crate::utils::no_follow_regular_file(final_path).await.is_some() { + return RecoveryDecision::Completed; + } + if crate::utils::no_follow_regular_file(partial).await.is_some() { + return RecoveryDecision::FailedPartialKept; + } + RecoveryDecision::FailedNoFile +} + +#[cfg(test)] +mod tests { + use super::{ + RecordingExecutionResult, RecoveryDecision, build_recording_args, classify_ffmpeg_failure, + recording_partial_path, recording_resume_or_retry_is_unsupported, recording_start_missed_window, + recovery_decision_for, remaining_recording_duration_secs, run_recording_with_binary, + }; + use crate::api::model::{DownloadControl, DownloadKind, DownloadState, FileDownload}; + use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, + }; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use tokio::sync::{Notify, RwLock}; + use tokio_util::sync::CancellationToken; + use shared::model::RecordingContainerFormat; + use crate::api::model::recording_worker::RETRYABLE_FFMPEG_PHRASES; + + fn unique_recording_output() -> (PathBuf, PathBuf, String) { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let file_dir = std::env::temp_dir().join(format!("tuliprox_recording_test_{nanos}")); + let filename = format!("recording_{nanos}.ts"); + let file_path = file_dir.join(&filename); + (file_dir, file_path, filename) + } + + fn make_recording(start_at: i64, duration_secs: u64) -> FileDownload { + let (file_dir, file_path, filename) = unique_recording_output(); + FileDownload { + uuid: "id".to_string(), + file_dir, + file_path, + filename, + url: reqwest::Url::parse("https://example.com/live/1").expect("valid url"), + finished: false, + size: 0, + total_size: None, + paused: false, + error: None, + state: DownloadState::Scheduled, + start_at: Some(start_at), + duration_secs: Some(duration_secs), + kind: DownloadKind::Recording, + input_name: None, + priority: 0, + retry_attempts: 0, + next_retry_at: None, + recording: None, + } + } + + #[test] + fn build_recording_args_maps_duration_and_output_path() { + let recording = make_recording(1_000, 5400); + let output = recording_partial_path(&recording.file_path); + let args = build_recording_args(&recording, 5400, &output, RecordingContainerFormat::default()); + + assert!(args.contains(&"-y".to_string())); + assert!(args.windows(2).any(|pair| pair == ["-t", "5400"])); + assert!(args.windows(2).any(|pair| pair == ["-i", "https://example.com/live/1"])); + assert!(args.windows(2).any(|pair| pair == ["-f", "mpegts"])); + assert_eq!(args.last(), Some(&output.to_string_lossy().to_string())); + } + + #[test] + fn classify_ffmpeg_failure_skips_generic_trailer_lines() { + let result = classify_ffmpeg_failure(b"Connection timed out\nConversion failed!\n"); + + assert_eq!( + result, + RecordingExecutionResult::Retryable("Connection timed out".to_string()) + ); + } + + #[test] + fn recording_start_missed_window_rejects_overdue_recording() { + let recording = make_recording(1_000, 60); + assert!(!recording_start_missed_window(&recording, 1_059)); + assert!(recording_start_missed_window(&recording, 1_060)); + } + + #[test] + fn remaining_recording_duration_tracks_remaining_window() { + let recording = make_recording(1_000, 60); + assert_eq!(remaining_recording_duration_secs(&recording, 900), Some(60)); + assert_eq!(remaining_recording_duration_secs(&recording, 1_000), Some(60)); + assert_eq!(remaining_recording_duration_secs(&recording, 1_030), Some(30)); + assert_eq!(remaining_recording_duration_secs(&recording, 1_059), Some(1)); + assert_eq!(remaining_recording_duration_secs(&recording, 1_060), None); + } + + #[test] + fn classify_ffmpeg_failure_marks_transient_transport_errors_retryable() { + let result = classify_ffmpeg_failure(b"Last message\nConnection timed out\n"); + assert_eq!( + result, + RecordingExecutionResult::Retryable("Connection timed out".to_string()) + ); + } + + #[test] + fn classify_ffmpeg_failure_keeps_terminal_usage_errors_failed() { + let result = classify_ffmpeg_failure(b"Last message\nInvalid argument\n"); + assert_eq!(result, RecordingExecutionResult::Failed("Invalid argument".to_string())); + } + + #[test] + fn classify_ffmpeg_failure_marks_broader_transient_network_errors_retryable() { + let result = classify_ffmpeg_failure(b"Last message\nCould not resolve host: example.com\n"); + assert_eq!( + result, + RecordingExecutionResult::Retryable("Could not resolve host: example.com".to_string()) + ); + } + + #[test] + fn classify_ffmpeg_failure_ignores_phrases_inside_the_source_url() { + // ffmpeg echoes the input URL in its error lines. A provider path + // that happens to contain a transient-sounding phrase must not + // turn a fatal error into an endless retry loop. + let result = classify_ffmpeg_failure( + b"http://host/live/connection-refused/1.ts: Invalid data found when processing input\n", + ); + assert!( + matches!(result, RecordingExecutionResult::Failed(_)), + "url-borne phrase must not make a fatal error retryable: {result:?}" + ); + // ffmpeg's own words still classify as retryable even when the + // line also carries the URL. + let result = classify_ffmpeg_failure(b"http://host/live/1.ts: Connection refused\n"); + assert!(matches!(result, RecordingExecutionResult::Retryable(_)), "{result:?}"); + } + + #[test] + fn retryable_phrases_are_lowercase_so_the_matcher_can_find_them() { + for phrase in RETRYABLE_FFMPEG_PHRASES { + assert_eq!( + *phrase, + phrase.to_ascii_lowercase(), + "phrase must be lowercase to match the lowercased haystack" + ); + } + } + + #[test] + fn retryable_phrases_have_no_proper_subset() { + // A phrase that is a proper substring of another phrase is dead + // weight: the matcher uses `contains`, so the longer entry + // matches anything the shorter one does. Two entries with the + // same characters (case-insensitive) are also caught: order + // matters at evaluation time but the matcher must be + // deterministic. + let phrases: Vec = RETRYABLE_FFMPEG_PHRASES + .iter() + .map(|p| p.to_ascii_lowercase()) + .collect(); + for (i, outer) in phrases.iter().enumerate() { + for (j, inner) in phrases.iter().enumerate() { + if i == j { + continue; + } + assert!( + !inner.contains(outer.as_str()), + "phrase {inner:?} is a substring of {outer:?}; the longer entry already covers it" + ); + } + } + } + + #[test] + fn classify_ffmpeg_failure_marks_only_transient_tls_failures_retryable() { + let retryable = classify_ffmpeg_failure(b"Last message\ntls handshake timeout\n"); + let certificate = classify_ffmpeg_failure(b"Last message\ncertificate verify failed\n"); + let protocol = classify_ffmpeg_failure(b"Last message\nunsupported protocol version\n"); + + assert_eq!( + retryable, + RecordingExecutionResult::Retryable("tls handshake timeout".to_string()) + ); + assert_eq!( + certificate, + RecordingExecutionResult::Failed("certificate verify failed".to_string()) + ); + assert_eq!( + protocol, + RecordingExecutionResult::Failed("unsupported protocol version".to_string()) + ); + } + + fn fake_ffmpeg_script(name: &str, body: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("tuliprox_fake_ffmpeg_{name}_{nanos}")); + fs::create_dir_all(&dir).expect("create temp dir"); + let script_path = dir.join("ffmpeg"); + fs::write(&script_path, body).expect("write fake ffmpeg"); + #[cfg(unix)] + { + let mut perms = fs::metadata(&script_path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).expect("chmod"); + } + script_path + } + + #[tokio::test] + async fn recording_retry_attempts_without_partial_output_do_not_block_retry() { + let mut recording = make_recording(chrono::Utc::now().timestamp(), 30); + recording.retry_attempts = 2; + + let unsupported = recording_resume_or_retry_is_unsupported(&recording).await; + + assert!(!unsupported); + } + + #[tokio::test] + async fn run_recording_completes_with_fake_ffmpeg() { + let script = fake_ffmpeg_script( + "success", + "#!/bin/sh\nfor arg in \"$@\"; do output=\"$arg\"; done\nprintf 'recorded' > \"$output\"\nexit 0\n", + ); + let control_signal = RwLock::new(DownloadControl::None); + let control_notify = Notify::new(); + let recording = make_recording(chrono::Utc::now().timestamp(), 5); + + let result = run_recording_with_binary(&script, &recording, &control_signal, &control_notify, None, RecordingContainerFormat::default()).await; + + assert_eq!(result, RecordingExecutionResult::Completed); + assert_eq!(tokio::fs::read(&recording.file_path).await.expect("read output"), b"recorded"); + assert!(!recording_partial_path(&recording.file_path).exists()); + let _ = fs::remove_file(script); + let _ = fs::remove_file(&recording.file_path); + let _ = fs::remove_dir_all(&recording.file_dir); + } + + #[tokio::test] + async fn run_recording_returns_retryable_for_fake_transient_ffmpeg_failure() { + let script = fake_ffmpeg_script( + "retryable", + "#!/bin/sh\nprintf 'Could not resolve host: upstream.example\\n' >&2\nexit 1\n", + ); + let control_signal = RwLock::new(DownloadControl::None); + let control_notify = Notify::new(); + let recording = make_recording(chrono::Utc::now().timestamp(), 5); + + let result = run_recording_with_binary(&script, &recording, &control_signal, &control_notify, None, RecordingContainerFormat::default()).await; + + assert_eq!( + result, + RecordingExecutionResult::Retryable("Could not resolve host: upstream.example".to_string()) + ); + let _ = fs::remove_file(script); + let _ = fs::remove_dir_all(&recording.file_dir); + } + + #[tokio::test] + async fn run_recording_preempts_fake_ffmpeg_and_preserves_window_semantics() { + let script = fake_ffmpeg_script( + "preempt", + "#!/bin/sh\ntrap 'exit 0' TERM INT\nsleep 30\n", + ); + let control_signal = RwLock::new(DownloadControl::None); + let control_notify = Notify::new(); + let cancel_token = CancellationToken::new(); + let recording = make_recording(chrono::Utc::now().timestamp().saturating_sub(2), 30); + let notify_cancel = cancel_token.clone(); + + let cancel_task = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + notify_cancel.cancel(); + }); + + let result = run_recording_with_binary(&script, &recording, &control_signal, &control_notify, Some(&cancel_token), RecordingContainerFormat::default()).await; + cancel_task.await.expect("cancel task"); + + assert_eq!(result, RecordingExecutionResult::Preempted); + assert!(remaining_recording_duration_secs(&recording, chrono::Utc::now().timestamp()).is_some()); + let _ = fs::remove_file(script); + let _ = fs::remove_dir_all(&recording.file_dir); + } + + #[tokio::test] + async fn run_recording_refuses_retry_or_resume_when_partial_output_exists() { + let script = fake_ffmpeg_script( + "no-resume", + "#!/bin/sh\nprintf 'should not run' >&2\nexit 1\n", + ); + let control_signal = RwLock::new(DownloadControl::None); + let control_notify = Notify::new(); + let recording = make_recording(chrono::Utc::now().timestamp(), 5); + + fs::create_dir_all(&recording.file_dir).expect("create recording dir"); + fs::write(recording_partial_path(&recording.file_path), b"partial").expect("write partial output"); + + let result = run_recording_with_binary(&script, &recording, &control_signal, &control_notify, None, RecordingContainerFormat::default()).await; + + assert_eq!( + result, + RecordingExecutionResult::Failed( + "Recording resume is not supported" + .to_string(), + ) + ); + let _ = fs::remove_file(script); + let _ = fs::remove_file(&recording.file_path); + let _ = fs::remove_dir_all(&recording.file_dir); + } + + // --- partial path + startup recovery --- + + #[test] + fn recording_partial_path_appends_dot_partial_after_existing_extension() { + let p = Path::new("/var/recordings/pilot.ts"); + let partial = recording_partial_path(p); + assert_eq!(partial, PathBuf::from("/var/recordings/pilot.ts.partial")); + } + + #[test] + fn recording_partial_path_defaults_to_ts_partial_when_no_extension() { + let p = Path::new("/halde/temp/NL_RTL_Z_8K"); + let partial = recording_partial_path(p); + assert_eq!(partial, PathBuf::from("/halde/temp/NL_RTL_Z_8K.ts.partial")); + } + + #[test] + fn recording_partial_path_uses_last_extension_for_multi_dot_names() { + let p = Path::new("/var/recordings/show.2024.s01.ts"); + let partial = recording_partial_path(p); + assert_eq!(partial, PathBuf::from("/var/recordings/show.2024.s01.ts.partial")); + } + + #[tokio::test] + async fn recovery_decision_for_completed_when_final_file_exists() { + let dir = tempfile::tempdir().expect("tempdir"); + let final_path = dir.path().join("rec.ts"); + tokio::fs::write(&final_path, b"recorded").await.expect("write final"); + let partial = dir.path().join("rec.ts.partial"); + let decision = recovery_decision_for(&final_path, &partial).await; + assert_eq!(decision, RecoveryDecision::Completed); + } + + #[tokio::test] + async fn recovery_decision_for_failed_when_only_partial_exists() { + let dir = tempfile::tempdir().expect("tempdir"); + let final_path = dir.path().join("rec.ts"); + let partial = dir.path().join("rec.ts.partial"); + tokio::fs::write(&partial, b"partial bytes").await.expect("write partial"); + let decision = recovery_decision_for(&final_path, &partial).await; + assert_eq!(decision, RecoveryDecision::FailedPartialKept); + } + + #[tokio::test] + async fn recovery_decision_for_failed_when_no_file_exists() { + let dir = tempfile::tempdir().expect("tempdir"); + let final_path = dir.path().join("rec.ts"); + let partial = dir.path().join("rec.ts.partial"); + let decision = recovery_decision_for(&final_path, &partial).await; + assert_eq!(decision, RecoveryDecision::FailedNoFile); + } + + // Windows symlink creation needs developer mode or elevation, so the + // symlink-specific assertion is Unix-only. The behaviour it covers — + // `no_follow_existing` not following a link — is provided by + // `symlink_metadata` on both platforms. + #[cfg(unix)] + #[tokio::test] + async fn recovery_decision_for_treats_symlinked_final_as_completed() { + // The no-follow check returns None for symlinks; the helper + // therefore treats the symlink as "not present" and falls through + // to the partial check. The startup recovery in + // `recover_loaded_download` is responsible for failing closed + // when the path is a symlink to outside the root; this helper + // only inspects the file presence semantics. + let dir = tempfile::tempdir().expect("tempdir"); + let real = dir.path().join("real.ts"); + let link_path = dir.path().join("rec.ts"); + tokio::fs::write(&real, b"data").await.expect("write real"); + std::os::unix::fs::symlink(&real, &link_path).expect("symlink"); + let partial = dir.path().join("rec.ts.partial"); + let decision = recovery_decision_for(&link_path, &partial).await; + assert_eq!(decision, RecoveryDecision::FailedNoFile); + } +} diff --git a/backend/src/api/model/recording/recording_worker_runner.rs b/backend/src/api/model/recording/recording_worker_runner.rs new file mode 100644 index 000000000..f60d68f70 --- /dev/null +++ b/backend/src/api/model/recording/recording_worker_runner.rs @@ -0,0 +1,762 @@ +//! Retention and disk-pressure worker. +//! +//! The worker deletes eligible completed recordings oldest first through the +//! normal recording deletion operation. It records only aggregate counters so +//! logs and metrics do not leak private recording data. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::recording_quota::{charge_for_task, QuotaRecordingTaskView}; +use super::recording_retention::{ + compute_candidates, RetentionCandidate, RetentionConfig, RetentionReason, +}; + +/// Disk-pressure config derived from `RecordingDiskConfig`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DiskConfig { + pub high_water_percent: Option, + pub low_water_percent: Option, + pub safety_bytes: Option, +} + +/// Result of one worker pass. The fields are deliberately +/// aggregate (no per-task data) so they can be logged without +/// leaking private title / channel / filename / user id. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)] +pub struct RunStats { + /// Total candidates considered across policy + disk-pressure. + pub candidates: u64, + /// Tasks successfully deleted. + pub deleted: u64, + /// Tasks skipped because the delete callback returned a + /// failure. + pub failed: u64, + /// Tasks skipped because the policy check said "skip" (e.g. + /// already in `Deleting` state, no safe final file). + pub skipped: u64, + /// Bytes reclaimed (sum of `charge_for_task` on each deleted + /// task at the moment of deletion). + pub reclaimed_bytes: u64, + /// `true` if a disk-pressure pass deleted at least one task. + pub disk_pressure_triggered: bool, +} + +/// Outcome of one delete attempt. The worker treats every +/// non-`Ok` outcome as a `failed` increment and proceeds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteOutcome { + Ok, + /// The task was already in `Deleting` or otherwise not safe + /// to delete. Counted under `skipped`. + Skipped, + /// The deletion failed (filesystem error, persistence error, + /// etc.). Counted under `failed`. + Failed, +} + +/// A callback that performs the actual deletion. The production +/// implementation calls `RecordingService::system_retention_delete`. +/// +/// The trait object borrows the caller's data; tests can capture +/// local state (e.g. a `Vec` recording deletions) without +/// needing `'static`. The production wiring wraps the callback in +/// an `Arc>` to satisfy Tokio's `'static` requirements. +pub type DeleteFn<'a> = dyn FnMut(&str) -> DeleteOutcome + 'a; + +/// Run the policy (age + count) pass for one tick. Returns the +/// stats for this pass; the caller is expected to log them +/// without per-task data. +/// +/// `now_secs` is the wall-clock seconds used for the age check; +/// passing it in keeps the function pure and testable. +pub fn run_once( + tasks: &[V], + retention: &RetentionConfig, + now_secs: i64, + delete: &mut DeleteFn<'_>, +) -> RunStats { + let mut stats = RunStats::default(); + let candidates = compute_candidates(tasks, retention, now_secs); + stats.candidates = candidates.len() as u64; + for cand in candidates { + // The `RecordingCandidate` is built from queue-resident + // tasks; the worker re-charges by `uuid` against the + // current task snapshot. We read the charge from the + // candidate's owner/channel indirectly — the production + // path sums `charge_for_task(task_at_candidate_uuid)`. + // For the pure runner, the bytes reclaimed equal the + // candidate's policy charge: the `Completed` state's + // `measured_bytes` (from the queue at select time). + let reclaim = candidate_charge(&cand); + match delete(&cand.uuid) { + DeleteOutcome::Ok => { + stats.deleted += 1; + stats.reclaimed_bytes = stats.reclaimed_bytes.saturating_add(reclaim); + } + DeleteOutcome::Skipped => { + stats.skipped += 1; + } + DeleteOutcome::Failed => { + stats.failed += 1; + } + } + } + stats +} + +/// Estimate the charge for a candidate at delete time. Today +/// the candidate is always `Completed`, so the charge is the +/// `measured_bytes` (the final file size). We pass it through +/// `charge_for_task` once a real `FileDownload` is in scope; for +/// the pure runner, we use a conservative constant derived from +/// the candidate's reason (count or age). The production +/// integration in `recording_service.rs` will re-summarize from +/// the queue and pass the real `measured_bytes`. +fn candidate_charge(cand: &RetentionCandidate) -> u64 { + // Conservative default until the worker is wired with a real + // task snapshot. The production path will pass the file size + // explicitly via the `DeleteFn` contract. + let _ = cand.reason; + 0 +} + +/// Pure: the ordered candidate set for a disk-pressure pass, or `None` +/// when no pass is warranted. +/// +/// `None` covers three cases: the measurement is not from the +/// recording-root filesystem, the watermarks are unset or inverted, or +/// there is no pressure (`used_percent` below the high mark). +/// +/// `used_percent` is the used-space percentage of the recording-root +/// filesystem, in `0..=100`. `is_recording_root_fs` is the +/// filesystem-selection guard: a caller that measured `storage_dir` or +/// the generic download directory — potentially a different mount — must +/// pass `false` rather than let a foreign measurement authorize +/// deletions. +/// +/// Split out of [`run_disk_pressure`] so an async caller can drive the +/// same decision without needing a blocking delete callback. +pub fn disk_pressure_candidates( + tasks: &[V], + disk: &DiskConfig, + used_percent: u8, + is_recording_root_fs: bool, +) -> Option> { + if !is_recording_root_fs { + // Never reuse a measurement from another filesystem. + return None; + } + let (high, low) = (disk.high_water_percent?, disk.low_water_percent?); + if high <= low { + // Invalid config: high water must be strictly above low water. + // Treat any inversion as "no pressure pass". + return None; + } + if used_percent < high { + return None; + } + let mut candidates: Vec = Vec::new(); + for task in tasks { + let Some(meta) = task.recording() else { + continue; + }; + if !matches!( + task.state(), + crate::api::model::download::DownloadState::Completed + ) { + continue; + } + let Some(completed_at) = meta.completed_at else { + continue; + }; + let key = super::recording_retention::RetentionGroupKey { + owner: super::recording_retention::RetentionOwner::from_recording_owner(&meta.owner), + channel: super::recording_retention::ChannelKey::from_metadata( + meta.channel_id.as_deref(), + meta.channel_name.as_deref(), + ), + }; + candidates.push(RetentionCandidate { + uuid: task.uuid().to_string(), + owner: key.owner, + channel: key.channel, + completed_at, + reason: RetentionReason::Age, + }); + } + candidates.sort_by(|a, b| { + a.completed_at + .cmp(&b.completed_at) + .then_with(|| a.uuid.cmp(&b.uuid)) + }); + Some(candidates) +} + +/// Bytes charged to the task with this uuid, i.e. what deleting it would +/// reclaim. +pub fn reclaimable_bytes_for(tasks: &[V], uuid: &str) -> u64 { + charge_for_task(&cand_uuid_view(tasks, uuid)) +} + +/// Run a disk-pressure pass: keep deleting oldest eligible completed +/// recordings until the projected used% falls to or below the low water +/// mark, or the candidate set is exhausted. +/// +/// See [`disk_pressure_candidates`] for the admission conditions. +pub fn run_disk_pressure( + tasks: &[V], + disk: &DiskConfig, + used_percent: u8, + free_bytes: u64, + total_bytes: u64, + is_recording_root_fs: bool, + delete: &mut DeleteFn<'_>, +) -> RunStats { + let mut stats = RunStats::default(); + let Some(candidates) = disk_pressure_candidates(tasks, disk, used_percent, is_recording_root_fs) else { + return stats; + }; + let Some(low) = disk.low_water_percent else { + return stats; + }; + stats.disk_pressure_triggered = true; + stats.candidates = candidates.len() as u64; + for cand in &candidates { + if pressure_relieved(total_bytes, free_bytes, stats.reclaimed_bytes, low) { + break; + } + let reclaim = reclaimable_bytes_for(tasks, &cand.uuid); + match delete(&cand.uuid) { + DeleteOutcome::Ok => { + stats.deleted += 1; + stats.reclaimed_bytes = stats.reclaimed_bytes.saturating_add(reclaim); + } + DeleteOutcome::Skipped => { + stats.skipped += 1; + } + DeleteOutcome::Failed => { + stats.failed += 1; + } + } + } + stats +} + +/// Has enough been reclaimed for used% to reach the low watermark? +/// +/// `reclaimed_bytes` is what this pass has already freed. Folding it in +/// is what terminates the loop: the free-space measurement is taken once +/// per pass, so a stop condition that ignored the running total would be +/// constant for the whole pass — it would evaluate to `false` on the +/// first candidate (pressure is by definition above the high watermark, +/// which is above the low one) and stay `false`, so a single trigger +/// would delete *every* completed recording rather than just enough of +/// them. +/// +/// `true` when `total_bytes == 0`: an unmeasurable filesystem must not +/// authorize deletions. +pub fn pressure_relieved( + total_bytes: u64, + free_bytes: u64, + reclaimed_bytes: u64, + low_percent: u8, +) -> bool { + if total_bytes == 0 { + return true; + } + let projected_free = free_bytes.saturating_add(reclaimed_bytes).min(total_bytes); + let used = total_bytes.saturating_sub(projected_free); + let pct = (used.saturating_mul(100)).saturating_div(total_bytes); + pct <= u64::from(low_percent) +} + +/// Helper: look up the charge for a candidate by re-reading the +/// task list. The production path will fold this into the +/// worker; the standalone test uses a view-on-uuid adapter. +fn cand_uuid_view<'a, V: QuotaRecordingTaskView>( + tasks: &'a [V], + uuid: &'a str, +) -> UuidView<'a, V> { + UuidView { tasks, uuid } +} + +struct UuidView<'a, V: QuotaRecordingTaskView> { + tasks: &'a [V], + uuid: &'a str, +} + +impl QuotaRecordingTaskView for UuidView<'_, V> { + fn state(&self) -> &crate::api::model::download::DownloadState { + // The view always reports `Completed` because disk-pressure + // only deletes `Completed` candidates. The `charge_for_task` + // path uses `measured_bytes` for `Completed`, so the state + // is consistent for charging purposes. + const COMPLETED: crate::api::model::download::DownloadState = + crate::api::model::download::DownloadState::Completed; + &COMPLETED + } + fn recording(&self) -> Option<&shared::model::recording::RecordingMetadata> { + self.tasks + .iter() + .find(|t| t.uuid() == self.uuid) + .and_then(|t| t.recording()) + } + fn uuid(&self) -> &str { + self.uuid + } +} + +/// Cancellation-aware worker handle. Holds a `CancelToken` and +/// an `is_running` flag; the loop skips a tick if a previous +/// pass is still in progress (passes never overlap). The flag is +/// plain `AtomicBool` for the standalone test; the production +/// path uses the same primitive. +pub struct Worker { + is_running: Arc, +} + +impl Worker { + pub fn new(_cancel: tokio_util::sync::CancellationToken) -> Self { + Self { + is_running: Arc::new(AtomicBool::new(false)), + } + } + + /// `true` if a pass is currently in flight. + pub fn is_running(&self) -> bool { + self.is_running.load(Ordering::Acquire) + } + + /// Try to claim the worker for one pass. Returns `true` if + /// the pass may run; `false` if a previous pass is still + /// in progress. + pub fn try_claim(&self) -> bool { + self.is_running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + /// Release the worker after one pass. + pub fn release(&self) { + self.is_running.store(false, Ordering::Release); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::model::download::DownloadState; + use shared::model::recording::{ + RecordingMetadata, RecordingOwner, RecordingSource, RecordingVisibility, + }; + use shared::model::UserId; + + fn make_meta( + owner: RecordingOwner, + channel_id: Option<&str>, + channel_name: Option<&str>, + completed_at: i64, + measured: u64, + ) -> RecordingMetadata { + RecordingMetadata { + owner, + visibility: RecordingVisibility::Private, + source: Some(RecordingSource::new("t1", "v1", "in1")), + program_start: None, + program_end: None, + scheduled_start: None, + scheduled_end: None, + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: channel_id.map(str::to_string), + channel_name: channel_name.map(str::to_string), + program_title: None, + epg: None, + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: None, + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: measured, + completed_at: Some(completed_at), + notification_markers: Vec::new(), + deleting_previous_state: None, + } + } + + struct T { + uuid: String, + state: DownloadState, + recording: Option, + } + impl QuotaRecordingTaskView for T { + fn state(&self) -> &DownloadState { + &self.state + } + fn recording(&self) -> Option<&RecordingMetadata> { + self.recording.as_ref() + } + fn uuid(&self) -> &str { + &self.uuid + } + } + + fn completed(uuid: &str, channel_id: &str, completed_at: i64, measured: u64) -> T { + T { + uuid: uuid.to_string(), + state: DownloadState::Completed, + recording: Some(make_meta( + RecordingOwner::User(UserId::from("web:alice")), + Some(channel_id), + Some("Alpha"), + completed_at, + measured, + )), + } + } + + fn generic_download(uuid: &str) -> T { + T { + uuid: uuid.to_string(), + state: DownloadState::Completed, + recording: None, + } + } + + fn count_delete( + deleted: std::rc::Rc>>, + failures: std::rc::Rc>, + ) -> impl FnMut(&str) -> DeleteOutcome { + move |uuid: &str| { + if failures.contains(&uuid) { + DeleteOutcome::Failed + } else { + deleted.borrow_mut().push(uuid.to_string()); + DeleteOutcome::Ok + } + } + } + + #[test] + fn run_once_deletes_oldest_count_candidates() { + let tasks = vec![ + completed("a", "c1", 1_000, 100), + completed("b", "c1", 2_000, 200), + completed("c", "c1", 3_000, 300), + ]; + let config = RetentionConfig { + keep_last_per_channel: Some(1), + delete_after_days: None, + }; + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec![])); + let stats = run_once(&tasks, &config, 0, &mut delete); + assert_eq!(stats.candidates, 2); + assert_eq!(stats.deleted, 2); + assert_eq!(stats.failed, 0); + assert_eq!(stats.skipped, 0); + assert_eq!(stats.reclaimed_bytes, 0); // pure runner; production path supplies the bytes + assert_eq!(deleted.borrow().clone(), vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn run_once_continues_after_individual_failure() { + let tasks = vec![ + completed("a", "c1", 1_000, 100), + completed("b", "c1", 2_000, 200), + completed("c", "c1", 3_000, 300), + ]; + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: None, + }; + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec!["b"])); + let stats = run_once(&tasks, &config, 0, &mut delete); + assert_eq!(stats.candidates, 3); + assert_eq!(stats.deleted, 2); + assert_eq!(stats.failed, 1); + // a and c were deleted; b was reported as failure + assert_eq!(deleted.borrow().clone(), vec!["a".to_string(), "c".to_string()]); + } + + #[test] + fn run_once_skips_generic_downloads() { + // A `Completed` task with no recording metadata is a + // generic download. The retention candidate set must + // exclude it, so the policy pass produces zero candidates. + let tasks = vec![generic_download("g1")]; + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: Some(365), + }; + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec![])); + let stats = run_once(&tasks, &config, 1_000_000_000, &mut delete); + assert_eq!(stats.candidates, 0); + assert!(deleted.borrow().is_empty()); + } + + #[test] + fn disk_pressure_runs_when_above_high_water() { + let tasks = vec![ + completed("a", "c1", 1_000, 100), + completed("b", "c1", 2_000, 200), + completed("c", "c1", 3_000, 300), + ]; + let disk = DiskConfig { + high_water_percent: Some(80), + low_water_percent: Some(50), + safety_bytes: None, + }; + // `used_percent` (90) is above the high watermark, so a pass is + // warranted — but the measured free space already satisfies the + // low watermark, so the pass must delete nothing. + let total = 1_000u64; + let free = 500u64; // 50% used + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec![])); + let stats = run_disk_pressure( + &tasks, + &disk, + 90, + free, + total, + true, + &mut delete, + ); + // 90% > 80% triggers. With free=50% (= low), the first + // iteration sees `used_percent ≤ 50%` and breaks without + // deleting anything. + assert!(stats.disk_pressure_triggered); + assert_eq!(stats.deleted, 0); + } + + #[test] + fn disk_pressure_keeps_deleting_until_low_water() { + let tasks = vec![ + completed("a", "c1", 1_000, 100), + completed("b", "c1", 2_000, 200), + completed("c", "c1", 3_000, 300), + ]; + let disk = DiskConfig { + high_water_percent: Some(80), + low_water_percent: Some(50), + safety_bytes: None, + }; + let total = 1_000u64; + // 95% used with only 600 bytes of reclaimable recordings: even + // deleting all three leaves the projected free space (650) short + // of nothing — it reaches 65% used, still above the 50% low + // watermark — so every candidate is consumed. + let free = 50u64; // 95% used + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec![])); + let stats = run_disk_pressure( + &tasks, + &disk, + 95, + free, + total, + true, + &mut delete, + ); + assert_eq!(stats.deleted, 3); + assert_eq!(deleted.borrow().len(), 3); + assert_eq!(stats.reclaimed_bytes, 600); + } + + #[test] + fn disk_pressure_stops_as_soon_as_the_low_water_mark_is_reached() { + // Regression guard: the stop condition ignored the bytes already + // reclaimed by the pass, so it was constant for the whole loop and + // a single trigger deleted the entire recording library instead of + // just enough of it. + let tasks = vec![ + completed("a", "c1", 1_000, 300), + completed("b", "c1", 2_000, 300), + completed("c", "c1", 3_000, 300), + ]; + let disk = DiskConfig { + high_water_percent: Some(80), + low_water_percent: Some(50), + safety_bytes: None, + }; + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec![])); + // 90% used of 1000 bytes. Deleting the two oldest reclaims 600, + // taking projected free to 700 (30% used) — past the 50% low + // watermark — so the third must survive. + let stats = run_disk_pressure(&tasks, &disk, 90, 100, 1_000, true, &mut delete); + assert!(stats.disk_pressure_triggered); + assert_eq!(stats.deleted, 2, "pass must stop at the low watermark"); + assert_eq!(*deleted.borrow(), vec!["a".to_string(), "b".to_string()]); + } + + #[test] + fn pressure_relieved_folds_in_what_the_pass_already_freed() { + // 90% used: not relieved yet. + assert!(!pressure_relieved(1_000, 100, 0, 50)); + // Reclaiming 400 more takes free to 500 → exactly at the mark. + assert!(pressure_relieved(1_000, 100, 400, 50)); + // Reclaiming more than the disk holds cannot report a negative use. + assert!(pressure_relieved(1_000, 100, u64::MAX, 50)); + // An unmeasurable filesystem never authorizes deletions. + assert!(pressure_relieved(0, 0, 0, 50)); + } + + #[test] + fn disk_pressure_skips_when_below_high_water() { + let tasks = vec![completed("a", "c1", 1_000, 100)]; + let disk = DiskConfig { + high_water_percent: Some(80), + low_water_percent: Some(50), + safety_bytes: None, + }; + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec![])); + let stats = run_disk_pressure( + &tasks, + &disk, + 60, + 400, + 1_000, + true, + &mut delete, + ); + assert!(!stats.disk_pressure_triggered); + assert_eq!(stats.deleted, 0); + } + + #[test] + fn disk_pressure_skips_when_wrong_filesystem() { + // Never reuse a measurement from another filesystem. If + // the caller cannot prove the measurement is on the + // recording-root FS, the pass is a no-op. + let tasks = vec![completed("a", "c1", 1_000, 100)]; + let disk = DiskConfig { + high_water_percent: Some(80), + low_water_percent: Some(50), + safety_bytes: None, + }; + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec![])); + let stats = run_disk_pressure( + &tasks, + &disk, + 95, + 50, + 1_000, + false, + &mut delete, + ); + assert!(!stats.disk_pressure_triggered); + assert_eq!(stats.deleted, 0); + } + + #[test] + fn disk_pressure_skips_when_inverted_thresholds() { + // high <= low is invalid; the worker treats it as + // "no pressure pass". + let tasks = vec![completed("a", "c1", 1_000, 100)]; + let disk = DiskConfig { + high_water_percent: Some(50), + low_water_percent: Some(80), + safety_bytes: None, + }; + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec![])); + let stats = run_disk_pressure( + &tasks, + &disk, + 95, + 50, + 1_000, + true, + &mut delete, + ); + assert!(!stats.disk_pressure_triggered); + assert_eq!(stats.deleted, 0); + } + + #[test] + fn disk_pressure_continues_after_individual_failure() { + let tasks = vec![ + completed("a", "c1", 1_000, 100), + completed("b", "c1", 2_000, 200), + ]; + let disk = DiskConfig { + high_water_percent: Some(80), + low_water_percent: Some(50), + safety_bytes: None, + }; + let deleted = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let mut delete = count_delete(deleted.clone(), std::rc::Rc::new(vec!["a"])); + let stats = run_disk_pressure( + &tasks, + &disk, + 95, + 50, + 1_000, + true, + &mut delete, + ); + assert_eq!(stats.deleted, 1); + assert_eq!(stats.failed, 1); + } + + #[test] + fn filesystem_selection_uses_recording_root() { + // `free_bytes_for` is a syscall on the supplied path. We + // exercise it against the actual filesystem that + // contains `/tmp` (the temp dir is on the same FS as + // the process CWD on Linux CI). The measurement must be + // on the recording-root FS, not on `storage_dir`. The path + // is recorded in the test for + // traceability. + let recording_root = std::path::Path::new("/tmp"); + let free = super::super::recording_disk::free_bytes_for(recording_root); + if let Some(f) = free { + assert!(f > 0); + } + } + + #[test] + fn worker_no_overlap_via_try_claim() { + // The `Worker` exposes a re-entry guard. Two concurrent + // claims cannot both succeed. + let cancel = tokio_util::sync::CancellationToken::new(); + let w = Worker::new(cancel); + assert!(w.try_claim(), "first claim must succeed"); + assert!(!w.try_claim(), "second claim must fail while running"); + w.release(); + assert!(w.try_claim(), "claim after release must succeed"); + } + + #[test] + fn run_once_stats_have_no_per_task_data() { + // The privacy contract: the aggregate `RunStats` JSON + // must not contain the uuid, channel id, or user id of + // any task. Aggregate field NAMES (candidates, deleted, + // ...) are allowed — only per-task VALUES are private. + let tasks = vec![completed( + "uuid-alpha-bravo-charlie", + "channel-delta-echo-foxtrot", + 1_000, + 100, + )]; + let config = RetentionConfig { + keep_last_per_channel: Some(0), + delete_after_days: None, + }; + let mut delete = count_delete( + std::rc::Rc::new(std::cell::RefCell::new(Vec::new())), + std::rc::Rc::new(vec![]), + ); + let stats = run_once(&tasks, &config, 0, &mut delete); + let json = serde_json::to_value(&stats).unwrap(); + let s = serde_json::to_string(&json).unwrap(); + assert!(!s.contains("uuid-alpha-bravo-charlie")); + assert!(!s.contains("channel-delta-echo-foxtrot")); + } +} diff --git a/backend/src/api/model/recording/recording_ws.rs b/backend/src/api/model/recording/recording_ws.rs new file mode 100644 index 000000000..e48c5283e --- /dev/null +++ b/backend/src/api/model/recording/recording_ws.rs @@ -0,0 +1,359 @@ +//! Recording-scoped WebSocket snapshot/delta filter. +//! +//! Filtering is performed for each authenticated session. Private deltas are +//! delivered only to the owner, shared deltas to sessions with +//! `recording.read`, and legacy administrator recordings only to +//! administrators. + +use std::collections::HashSet; +use std::sync::atomic::Ordering; + +use shared::model::permission::Permission; +use shared::model::recording::{RecordingMetadata, RecordingOwner, RecordingVisibility}; +use shared::model::{Claims, FileDownloadDto, UserId, CURRENT_PERMISSION_SCHEMA_VERSION}; + +use crate::api::model::download::DownloadQueue; +use shared::model::QueueRevision; + +/// Why a session may not see recordings. +/// +/// The two reasons need different handling and used to be collapsed into +/// one `false`: the socket returned an empty list either way, so a user +/// whose token predated a permission-schema bump saw "no recordings" +/// forever with nothing to act on, while the REST routes were correctly +/// answering `recording_token_refresh_required`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordingViewDenial { + /// The token predates the current permission schema, so its + /// permission bits cannot be trusted. The client must refresh and + /// reconnect. Actionable, and therefore reported to the client. + TokenRefreshRequired, + /// The principal simply has no `recording.read`. Not actionable and + /// not an error — an empty list is the honest answer. + NotPermitted, +} + +impl RecordingViewDenial { + /// Stable wire code, matching the REST error codes so the frontend + /// maps both surfaces through one table. + pub fn code(self) -> &'static str { + match self { + Self::TokenRefreshRequired => "recording_token_refresh_required", + Self::NotPermitted => "recording_forbidden", + } + } +} + +/// `None` when the session may see recordings. +/// +/// Staleness is checked *before* the permission bit on purpose: a token +/// from an older schema cannot be trusted to describe permissions at all, +/// so "refresh" is the truthful answer even when the stale token also +/// happens to lack the bit. +pub fn recording_view_denial(claims: &Claims) -> Option { + if claims.permission_schema_version < CURRENT_PERMISSION_SCHEMA_VERSION { + return Some(RecordingViewDenial::TokenRefreshRequired); + } + if !claims.permissions.contains(Permission::RecordingRead) { + return Some(RecordingViewDenial::NotPermitted); + } + None +} + +pub fn can_view_recording(claims: &Claims) -> bool { + recording_view_denial(claims).is_none() +} + +pub fn task_visible_to( + task_meta: Option<&RecordingMetadata>, + claims: &Claims, + subject_id: &UserId, +) -> bool { + let Some(meta) = task_meta else { + return false; + }; + if claims.subject_id.is_none() { + return false; + } + if !can_view_recording(claims) { + return false; + } + match &meta.owner { + RecordingOwner::User(owner) => match meta.visibility { + RecordingVisibility::Private => owner == subject_id, + RecordingVisibility::Shared => true, + }, + RecordingOwner::LegacyAdmin => { + claims.roles.iter().any(|r| r == shared::model::ROLE_ADMIN) + } + } +} + +pub async fn recording_snapshot( + queue: &DownloadQueue, + claims: &Claims, +) -> (QueueRevision, Vec) { + if !can_view_recording(claims) { + return (current_revision(queue), Vec::new()); + } + let Some(subject) = claims.subject_id.clone() else { + return (current_revision(queue), Vec::new()); + }; + let (revision, tasks) = queue.committed_snapshot().await; + let tasks = tasks + .iter() + .filter(|task| task_visible_to(task.recording.as_ref(), claims, &subject)) + .map(FileDownloadDto::from) + .collect(); + (revision, tasks) +} + +pub async fn recording_delta( + queue: &DownloadQueue, + claims: &Claims, + delta: &[FileDownloadDto], +) -> (QueueRevision, Vec) { + if !can_view_recording(claims) { + return (current_revision(queue), Vec::new()); + } + let Some(subject) = claims.subject_id.clone() else { + return (current_revision(queue), Vec::new()); + }; + let (revision, visible) = collect_visible_set(queue, claims, &subject).await; + let tasks = filter_delta_by_visible_ids(delta, &visible); + (revision, tasks) +} + +fn current_revision(queue: &DownloadQueue) -> QueueRevision { + QueueRevision(queue.revision.load(Ordering::SeqCst)) +} + +/// The ids the session may see, plus the revision they belong to. +/// +/// This used to `try_lock` / `try_read` all four queue guards and skip +/// whichever one was contended — under mutation load the visible set +/// came back partial or empty, so `filter_delta_by_visible_ids` dropped +/// legitimate tasks and the client watched its recordings disappear +/// until the next full snapshot. It now waits for the same committed +/// boundary `recording_snapshot` uses, so the set is consistent and +/// complete, and the revision it is valid for is returned with it. +async fn collect_visible_set( + queue: &DownloadQueue, + claims: &Claims, + subject: &UserId, +) -> (QueueRevision, HashSet) { + if !can_view_recording(claims) { + return (current_revision(queue), HashSet::new()); + } + let (revision, tasks) = queue.committed_snapshot().await; + let ids = tasks + .iter() + .filter(|task| task_visible_to(task.recording.as_ref(), claims, subject)) + .map(|task| task.uuid.clone()) + .collect(); + (revision, ids) +} + +fn filter_delta_by_visible_ids(delta: &[FileDownloadDto], visible: &HashSet) -> Vec { + delta.iter().filter(|task| visible.contains(&task.id)).cloned().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::recording::{RecordingMetadata, RecordingOwner, RecordingVisibility}; + use shared::model::{Claims, CURRENT_PERMISSION_SCHEMA_VERSION, UserId}; + + fn admin_claims() -> Claims { + let mut c = Claims { + username: "admin".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: vec!["ADMIN".to_string()], + permissions: Permission::RecordingRead.into(), + pwd_version: 0, + subject_id: Some(UserId::builtin_admin()), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + }; + let _ = &mut c; + c + } + + fn alice_claims() -> Claims { + Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: Vec::new(), + permissions: Permission::RecordingRead.into(), + pwd_version: 0, + subject_id: Some(UserId::from("web:alice")), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + } + } + + fn no_read_claims() -> Claims { + let mut c = alice_claims(); + c.permissions = Permission::ConfigRead.into(); + c + } + + fn no_subject_claims() -> Claims { + let mut c = alice_claims(); + c.subject_id = None; + c + } + + #[test] + fn delta_filter_uses_task_ids_not_titles() { + let visible = std::collections::HashSet::from(["visible".to_string()]); + let delta = vec![FileDownloadDto { + id: "hidden".to_string(), + title: "same-title.ts".to_string(), + kind: shared::model::TaskKindDto::Recording, + priority: shared::model::TaskPriorityDto::Normal, + status: shared::model::TransferStatusDto::Scheduled, + retry_attempts: 0, + downloaded_bytes: 0, + total_bytes: None, + next_retry_at: None, + scheduled_start_at: None, + duration_secs: None, + error: None, + recording: None, + }]; + + assert!(filter_delta_by_visible_ids(&delta, &visible).is_empty()); + } + + fn stale_claims() -> Claims { + let mut c = alice_claims(); + c.permission_schema_version = 0; + c + } + + fn meta_private_alice() -> RecordingMetadata { + let mut m = RecordingMetadata::for_legacy_admin(0, 60); + m.owner = RecordingOwner::User(UserId::from("web:alice")); + m.visibility = RecordingVisibility::Private; + m + } + + fn meta_shared_bob() -> RecordingMetadata { + let mut m = RecordingMetadata::for_legacy_admin(0, 60); + m.owner = RecordingOwner::User(UserId::from("web:bob")); + m.visibility = RecordingVisibility::Shared; + m + } + + fn meta_legacy_admin() -> RecordingMetadata { + let mut m = RecordingMetadata::for_legacy_admin(0, 60); + m.owner = RecordingOwner::LegacyAdmin; + m.visibility = RecordingVisibility::Private; + m + } + + #[test] + fn can_view_recording_requires_perm_and_schema() { + assert!(can_view_recording(&alice_claims())); + assert!(!can_view_recording(&no_read_claims())); + assert!(!can_view_recording(&stale_claims())); + } + + #[test] + fn denial_distinguishes_a_stale_token_from_a_missing_permission() { + assert_eq!(recording_view_denial(&alice_claims()), None); + assert_eq!( + recording_view_denial(&no_read_claims()), + Some(RecordingViewDenial::NotPermitted) + ); + assert_eq!( + recording_view_denial(&stale_claims()), + Some(RecordingViewDenial::TokenRefreshRequired) + ); + } + + #[test] + fn a_stale_token_reports_refresh_even_without_the_permission_bit() { + // The bits of a pre-schema-bump token mean nothing; telling the + // user "forbidden" would send them to an administrator when all + // they need is a new token. + let mut claims = no_read_claims(); + claims.permission_schema_version = 0; + assert_eq!( + recording_view_denial(&claims), + Some(RecordingViewDenial::TokenRefreshRequired) + ); + } + + #[test] + fn denial_codes_match_the_rest_error_codes() { + assert_eq!( + RecordingViewDenial::TokenRefreshRequired.code(), + "recording_token_refresh_required" + ); + assert_eq!(RecordingViewDenial::NotPermitted.code(), "recording_forbidden"); + } + + #[test] + fn task_visible_to_rejects_non_recording() { + let claims = alice_claims(); + assert!(!task_visible_to(None, &claims, &UserId::from("web:alice"))); + } + + #[test] + fn task_visible_to_rejects_no_subject() { + let claims = no_subject_claims(); + assert!(!task_visible_to(Some(&meta_private_alice()), &claims, &UserId::from("web:alice"))); + } + + #[test] + fn private_recording_visible_to_owner_only() { + let alice = alice_claims(); + let bob = Claims { + username: "bob".to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles: Vec::new(), + permissions: Permission::RecordingRead.into(), + pwd_version: 0, + subject_id: Some(UserId::from("web:bob")), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + }; + assert!(task_visible_to(Some(&meta_private_alice()), &alice, &UserId::from("web:alice"))); + assert!(!task_visible_to(Some(&meta_private_alice()), &bob, &UserId::from("web:bob"))); + } + + #[test] + fn shared_recording_visible_to_any_with_read_perm() { + let alice = alice_claims(); + assert!(task_visible_to(Some(&meta_shared_bob()), &alice, &UserId::from("web:alice"))); + } + + #[test] + fn legacy_admin_recording_visible_to_admins_only() { + let admin = admin_claims(); + let non_admin = alice_claims(); + assert!(task_visible_to(Some(&meta_legacy_admin()), &admin, &UserId::builtin_admin())); + assert!(!task_visible_to(Some(&meta_legacy_admin()), &non_admin, &UserId::from("web:alice"))); + } + + #[tokio::test] + async fn recording_snapshot_yields_no_subject_id() { + let queue = DownloadQueue::new(); + let (rev, tasks) = recording_snapshot(&queue, &no_subject_claims()).await; + assert_eq!(rev.0, 0); + assert!(tasks.is_empty()); + } + + #[tokio::test] + async fn recording_snapshot_yields_no_recording_read_perm() { + let queue = DownloadQueue::new(); + let (rev, tasks) = recording_snapshot(&queue, &no_read_claims()).await; + assert_eq!(rev.0, 0); + assert!(tasks.is_empty()); + } +} diff --git a/backend/src/api/model/recording_worker.rs b/backend/src/api/model/recording_worker.rs deleted file mode 100644 index d639addbe..000000000 --- a/backend/src/api/model/recording_worker.rs +++ /dev/null @@ -1,451 +0,0 @@ -use crate::api::model::{DownloadControl, FileDownload}; -use std::path::Path; -use tokio::sync::{Notify, RwLock}; -use tokio_util::sync::CancellationToken; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RecordingExecutionResult { - Completed, - Paused, - Cancelled, - Preempted, - Retryable(String), - Failed(String), -} - -fn is_generic_ffmpeg_stderr_line(line: &str) -> bool { - let trimmed = line.trim(); - trimmed.is_empty() - || trimmed.eq_ignore_ascii_case("conversion failed!") - || trimmed.eq_ignore_ascii_case("exiting normally, received signal 15.") -} - -fn stderr_summary(stderr: &[u8]) -> String { - let stderr = String::from_utf8_lossy(stderr); - stderr - .lines() - .rev() - .find(|line| !is_generic_ffmpeg_stderr_line(line)) - .map_or_else(|| "ffmpeg failed".to_string(), |line| line.trim().to_string()) -} - -fn is_retryable_ffmpeg_failure_message(message: &str) -> bool { - let msg = message.to_ascii_lowercase(); - msg.contains("connection timed out") - || msg.contains("timed out") - || msg.contains("temporarily unavailable") - || msg.contains("temporary failure") - || msg.contains("resource temporarily unavailable") - || msg.contains("connection reset") - || msg.contains("connection refused") - || msg.contains("connection closed") - || msg.contains("broken pipe") - || msg.contains("unexpected eof") - || msg.contains("end of file") - || msg.contains("network is unreachable") - || msg.contains("no route to host") - || msg.contains("name or service not known") - || msg.contains("temporary failure in name resolution") - || msg.contains("could not resolve host") - || msg.contains("could not resolve") - || msg.contains("failed to resolve hostname") - || msg.contains("server returned 5") - || msg.contains("http error 5") - || msg.contains("http error 429") - || msg.contains("429 too many requests") - || msg.contains("503 service unavailable") - || msg.contains("502 bad gateway") - || msg.contains("504 gateway timeout") - || msg.contains("500 internal server error") - || msg.contains("tls handshake") - || msg.contains("tls timeout") - || msg.contains("tls: handshake") - || msg.contains("i/o error") -} - -fn classify_ffmpeg_failure(stderr: &[u8]) -> RecordingExecutionResult { - let summary = stderr_summary(stderr); - if is_retryable_ffmpeg_failure_message(&summary) { - RecordingExecutionResult::Retryable(summary) - } else { - RecordingExecutionResult::Failed(summary) - } -} - -pub fn remaining_recording_duration_secs(download: &FileDownload, now_ts: i64) -> Option { - match (download.start_at, download.duration_secs) { - (_, None) => None, - (None, Some(duration_secs)) => Some(duration_secs), - (Some(start_at), Some(duration_secs)) => { - let duration_i64 = i64::try_from(duration_secs).unwrap_or(i64::MAX); - let end_at = start_at.saturating_add(duration_i64); - if now_ts >= end_at { - None - } else if now_ts <= start_at { - Some(duration_secs) - } else { - u64::try_from(end_at.saturating_sub(now_ts)).ok() - } - } - } -} - -pub fn build_recording_args(download: &FileDownload, effective_duration_secs: u64) -> Vec { - vec![ - "-nostdin".to_string(), - "-hide_banner".to_string(), - "-loglevel".to_string(), - "warning".to_string(), - "-i".to_string(), - download.url.to_string(), - "-map".to_string(), - "0".to_string(), - "-t".to_string(), - effective_duration_secs.to_string(), - "-c".to_string(), - "copy".to_string(), - download.file_path.to_string_lossy().to_string(), - ] -} - -async fn recording_resume_or_retry_is_unsupported(download: &FileDownload) -> bool { - tokio::fs::metadata(&download.file_path) - .await - .is_ok_and(|metadata| metadata.len() > 0) -} - -pub fn recording_start_missed_window(download: &FileDownload, now_ts: i64) -> bool { - download - .start_at - .zip(download.duration_secs) - .is_some_and(|(start_at, duration_secs)| now_ts >= start_at.saturating_add(i64::try_from(duration_secs).unwrap_or(i64::MAX))) -} - -async fn run_recording_with_binary( - ffmpeg_binary: &Path, - download: &FileDownload, - control_signal: &RwLock, - control_notify: &Notify, - cancel_token: Option<&CancellationToken>, -) -> RecordingExecutionResult { - let now_ts = chrono::Utc::now().timestamp(); - if recording_start_missed_window(download, now_ts) { - return RecordingExecutionResult::Failed("Recording window already expired".to_string()); - } - let Some(effective_duration_secs) = remaining_recording_duration_secs(download, now_ts) else { - return RecordingExecutionResult::Failed("Recording window already expired".to_string()); - }; - - if let Err(err) = tokio::fs::create_dir_all(&download.file_dir).await { - return RecordingExecutionResult::Failed(format!("Error while creating recording directory: {err}")); - } - - if recording_resume_or_retry_is_unsupported(download).await { - return RecordingExecutionResult::Failed( - "Recording resume is not supported".to_string(), - // yet because ffmpeg segment stitching is not implemented - ); - } - - let mut command = tokio::process::Command::new(ffmpeg_binary); - command - .args(build_recording_args(download, effective_duration_secs)) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - let child = match command.spawn() { - Ok(child) => child, - Err(err) => return RecordingExecutionResult::Failed(format!("Failed to spawn ffmpeg: {err}")), - }; - - let mut wait_future = Box::pin(child.wait_with_output()); - - loop { - tokio::select! { - biased; - () = async { - if let Some(token) = cancel_token { - token.cancelled().await; - } else { - std::future::pending::<()>().await; - } - } => return RecordingExecutionResult::Preempted, - () = control_notify.notified() => { - match *control_signal.read().await { - DownloadControl::Pause => return RecordingExecutionResult::Paused, - DownloadControl::Cancel => return RecordingExecutionResult::Cancelled, - DownloadControl::Restart => return RecordingExecutionResult::Preempted, - DownloadControl::None => {} - } - } - output = &mut wait_future => { - match output { - Ok(output) if output.status.success() => return RecordingExecutionResult::Completed, - Ok(output) => return classify_ffmpeg_failure(&output.stderr), - Err(err) => return RecordingExecutionResult::Failed(format!("Failed to wait for ffmpeg: {err}")), - } - } - } - } -} - -pub async fn run_recording( - download: &FileDownload, - control_signal: &RwLock, - control_notify: &Notify, - cancel_token: Option<&CancellationToken>, -) -> RecordingExecutionResult { - run_recording_with_binary(Path::new("ffmpeg"), download, control_signal, control_notify, cancel_token).await -} - -#[cfg(test)] -mod tests { - use super::{ - RecordingExecutionResult, build_recording_args, classify_ffmpeg_failure, recording_resume_or_retry_is_unsupported, - recording_start_missed_window, remaining_recording_duration_secs, run_recording_with_binary, - }; - use crate::api::model::{DownloadControl, DownloadKind, DownloadState, FileDownload}; - use std::{fs, path::PathBuf, time::{SystemTime, UNIX_EPOCH}}; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use tokio::sync::{Notify, RwLock}; - use tokio_util::sync::CancellationToken; - - fn unique_recording_output() -> (PathBuf, PathBuf, String) { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time") - .as_nanos(); - let file_dir = std::env::temp_dir().join(format!("tuliprox_recording_test_{nanos}")); - let filename = format!("recording_{nanos}.ts"); - let file_path = file_dir.join(&filename); - (file_dir, file_path, filename) - } - - fn make_recording(start_at: i64, duration_secs: u64) -> FileDownload { - let (file_dir, file_path, filename) = unique_recording_output(); - FileDownload { - uuid: "id".to_string(), - file_dir, - file_path, - filename, - url: reqwest::Url::parse("https://example.com/live/1").expect("valid url"), - finished: false, - size: 0, - total_size: None, - paused: false, - error: None, - state: DownloadState::Scheduled, - start_at: Some(start_at), - duration_secs: Some(duration_secs), - kind: DownloadKind::Recording, - input_name: None, - priority: 0, - retry_attempts: 0, - next_retry_at: None, - } - } - - #[test] - fn build_recording_args_maps_duration_and_output_path() { - let recording = make_recording(1_000, 5400); - let args = build_recording_args(&recording, 5400); - - assert!(!args.iter().any(|arg| arg == "-y")); - assert!(args.windows(2).any(|pair| pair == ["-t", "5400"])); - assert!(args.windows(2).any(|pair| pair == ["-i", "https://example.com/live/1"])); - assert_eq!(args.last(), Some(&recording.file_path.to_string_lossy().to_string())); - } - - #[test] - fn classify_ffmpeg_failure_skips_generic_trailer_lines() { - let result = classify_ffmpeg_failure(b"Connection timed out\nConversion failed!\n"); - - assert_eq!( - result, - RecordingExecutionResult::Retryable("Connection timed out".to_string()) - ); - } - - #[test] - fn recording_start_missed_window_rejects_overdue_recording() { - let recording = make_recording(1_000, 60); - assert!(!recording_start_missed_window(&recording, 1_059)); - assert!(recording_start_missed_window(&recording, 1_060)); - } - - #[test] - fn remaining_recording_duration_tracks_remaining_window() { - let recording = make_recording(1_000, 60); - assert_eq!(remaining_recording_duration_secs(&recording, 900), Some(60)); - assert_eq!(remaining_recording_duration_secs(&recording, 1_000), Some(60)); - assert_eq!(remaining_recording_duration_secs(&recording, 1_030), Some(30)); - assert_eq!(remaining_recording_duration_secs(&recording, 1_059), Some(1)); - assert_eq!(remaining_recording_duration_secs(&recording, 1_060), None); - } - - #[test] - fn classify_ffmpeg_failure_marks_transient_transport_errors_retryable() { - let result = classify_ffmpeg_failure(b"Last message\nConnection timed out\n"); - assert_eq!( - result, - RecordingExecutionResult::Retryable("Connection timed out".to_string()) - ); - } - - #[test] - fn classify_ffmpeg_failure_keeps_terminal_usage_errors_failed() { - let result = classify_ffmpeg_failure(b"Last message\nInvalid argument\n"); - assert_eq!(result, RecordingExecutionResult::Failed("Invalid argument".to_string())); - } - - #[test] - fn classify_ffmpeg_failure_marks_broader_transient_network_errors_retryable() { - let result = classify_ffmpeg_failure(b"Last message\nCould not resolve host: example.com\n"); - assert_eq!( - result, - RecordingExecutionResult::Retryable("Could not resolve host: example.com".to_string()) - ); - } - - #[test] - fn classify_ffmpeg_failure_marks_only_transient_tls_failures_retryable() { - let retryable = classify_ffmpeg_failure(b"Last message\ntls handshake timeout\n"); - let certificate = classify_ffmpeg_failure(b"Last message\ncertificate verify failed\n"); - let protocol = classify_ffmpeg_failure(b"Last message\nunsupported protocol version\n"); - - assert_eq!( - retryable, - RecordingExecutionResult::Retryable("tls handshake timeout".to_string()) - ); - assert_eq!( - certificate, - RecordingExecutionResult::Failed("certificate verify failed".to_string()) - ); - assert_eq!( - protocol, - RecordingExecutionResult::Failed("unsupported protocol version".to_string()) - ); - } - - fn fake_ffmpeg_script(name: &str, body: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time") - .as_nanos(); - let dir = std::env::temp_dir().join(format!("tuliprox_fake_ffmpeg_{name}_{nanos}")); - fs::create_dir_all(&dir).expect("create temp dir"); - let script_path = dir.join("ffmpeg"); - fs::write(&script_path, body).expect("write fake ffmpeg"); - #[cfg(unix)] - { - let mut perms = fs::metadata(&script_path).expect("metadata").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script_path, perms).expect("chmod"); - } - script_path - } - - #[tokio::test] - async fn recording_retry_attempts_without_partial_output_do_not_block_retry() { - let mut recording = make_recording(chrono::Utc::now().timestamp(), 30); - recording.retry_attempts = 2; - - let unsupported = recording_resume_or_retry_is_unsupported(&recording).await; - - assert!(!unsupported); - } - - #[tokio::test] - async fn run_recording_completes_with_fake_ffmpeg() { - let script = fake_ffmpeg_script( - "success", - "#!/bin/sh\nfor arg in \"$@\"; do output=\"$arg\"; done\nprintf 'recorded' > \"$output\"\nexit 0\n", - ); - let control_signal = RwLock::new(DownloadControl::None); - let control_notify = Notify::new(); - let recording = make_recording(chrono::Utc::now().timestamp(), 5); - - let result = run_recording_with_binary(&script, &recording, &control_signal, &control_notify, None).await; - - assert_eq!(result, RecordingExecutionResult::Completed); - assert_eq!(tokio::fs::read(&recording.file_path).await.expect("read output"), b"recorded"); - let _ = fs::remove_file(script); - let _ = fs::remove_file(&recording.file_path); - let _ = fs::remove_dir_all(&recording.file_dir); - } - - #[tokio::test] - async fn run_recording_returns_retryable_for_fake_transient_ffmpeg_failure() { - let script = fake_ffmpeg_script( - "retryable", - "#!/bin/sh\nprintf 'Could not resolve host: upstream.example\\n' >&2\nexit 1\n", - ); - let control_signal = RwLock::new(DownloadControl::None); - let control_notify = Notify::new(); - let recording = make_recording(chrono::Utc::now().timestamp(), 5); - - let result = run_recording_with_binary(&script, &recording, &control_signal, &control_notify, None).await; - - assert_eq!( - result, - RecordingExecutionResult::Retryable("Could not resolve host: upstream.example".to_string()) - ); - let _ = fs::remove_file(script); - let _ = fs::remove_dir_all(&recording.file_dir); - } - - #[tokio::test] - async fn run_recording_preempts_fake_ffmpeg_and_preserves_window_semantics() { - let script = fake_ffmpeg_script( - "preempt", - "#!/bin/sh\ntrap 'exit 0' TERM INT\nsleep 30\n", - ); - let control_signal = RwLock::new(DownloadControl::None); - let control_notify = Notify::new(); - let cancel_token = CancellationToken::new(); - let recording = make_recording(chrono::Utc::now().timestamp().saturating_sub(2), 30); - let notify_cancel = cancel_token.clone(); - - let cancel_task = tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - notify_cancel.cancel(); - }); - - let result = run_recording_with_binary(&script, &recording, &control_signal, &control_notify, Some(&cancel_token)).await; - cancel_task.await.expect("cancel task"); - - assert_eq!(result, RecordingExecutionResult::Preempted); - assert!(remaining_recording_duration_secs(&recording, chrono::Utc::now().timestamp()).is_some()); - let _ = fs::remove_file(script); - let _ = fs::remove_dir_all(&recording.file_dir); - } - - #[tokio::test] - async fn run_recording_refuses_retry_or_resume_when_partial_output_exists() { - let script = fake_ffmpeg_script( - "no-resume", - "#!/bin/sh\nprintf 'should not run' >&2\nexit 1\n", - ); - let control_signal = RwLock::new(DownloadControl::None); - let control_notify = Notify::new(); - let recording = make_recording(chrono::Utc::now().timestamp(), 5); - - fs::create_dir_all(&recording.file_dir).expect("create recording dir"); - fs::write(&recording.file_path, b"partial").expect("write partial output"); - - let result = run_recording_with_binary(&script, &recording, &control_signal, &control_notify, None).await; - - assert_eq!( - result, - RecordingExecutionResult::Failed( - "Recording resume is not supported" - .to_string(), - ) - ); - let _ = fs::remove_file(script); - let _ = fs::remove_file(&recording.file_path); - let _ = fs::remove_dir_all(&recording.file_dir); - } -} diff --git a/backend/src/api/model/streams/active_client_stream.rs b/backend/src/api/model/streams/active_client_stream.rs index e9adf03f5..e24caddb6 100644 --- a/backend/src/api/model/streams/active_client_stream.rs +++ b/backend/src/api/model/streams/active_client_stream.rs @@ -214,6 +214,9 @@ struct ActiveClientStreamState { timed_stream_context: Option, preempt_cancelled: Option>>, grace_task_handle: Option>, + /// Cancels a panel-api provisioning probe spawned by the grace task; aborting the + /// grace task alone would leave the probe running to its own timeout. + provisioning_stop_signal: Option, provisionable: bool, custom_video: CustomVideoBuffers, meter: Option>, @@ -352,6 +355,9 @@ impl ActiveClientStreamState { if let Some(task) = self.grace_task_handle.take() { task.abort(); } + if let Some(token) = self.provisioning_stop_signal.take() { + token.cancel(); + } } fn clear_finished_grace_task(&mut self) { @@ -885,6 +891,7 @@ pub(crate) async fn create_active_client_stream(request: ActiveClientStreamParam let provisioning_info = resolve_grace_period_provisioning(app_state, &stream_details); let has_provisioning = provisioning_info.is_some(); + let provisioning_stop_signal = provisioning_info.as_ref().map(|info| info.stop_signal.clone()); let hold_stream = stream_details.grace_period.hold_stream; let capacity_notify = app_state.connection_manager.capacity_notified(); let pending_provider_version = if hold_stream { @@ -990,6 +997,7 @@ pub(crate) async fn create_active_client_stream(request: ActiveClientStreamParam timed_stream_context, preempt_cancelled, grace_task_handle, + provisioning_stop_signal, provider_handle: provider_handle_preserved, send_custom_stream_flag, provisionable: has_provisioning, @@ -1876,6 +1884,7 @@ mod tests { timed_stream_context: None, preempt_cancelled: None, grace_task_handle: None, + provisioning_stop_signal: None, provisionable, custom_video: CustomVideoBuffers { user_exhausted: None, @@ -2377,6 +2386,7 @@ mod tests { timed_stream_context: Some(TimedStreamContext { app_state, duration_secs: 1, virtual_id: 1 }), preempt_cancelled: None, grace_task_handle: None, + provisioning_stop_signal: None, provisionable: false, custom_video: CustomVideoBuffers { user_exhausted: None, @@ -2534,6 +2544,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![AdmissionStrategy::GraceHoldStream]), }); @@ -2951,7 +2962,7 @@ mod tests { let (tracked_provider, tracker) = track_provider_stream(gated_provider); let producer_cancel = CancellationToken::new(); let buffered_provider = - BufferedStream::new(tracked_provider, 1, producer_cancel.clone(), "controlled-test-stream").boxed(); + BufferedStream::new(tracked_provider, 1, 0, producer_cancel.clone(), "controlled-test-stream").boxed(); let direct = create_test_active_direct_stream(TestDirectStreamParams { app_state: &app_state, username: "series-closed-consumer-user", diff --git a/backend/src/api/model/streams/buffered_stream.rs b/backend/src/api/model/streams/buffered_stream.rs index 28809197b..15d9e2ba8 100644 --- a/backend/src/api/model/streams/buffered_stream.rs +++ b/backend/src/api/model/streams/buffered_stream.rs @@ -26,29 +26,34 @@ pub(in crate::api::model) struct BufferedStream { stream: ReceiverStream>, close_cancelled: Pin>, semaphore: Arc, + max_buffer_bytes: usize, } impl BufferedStream { pub fn new( stream: BoxedProviderStream, buffer_size: usize, + max_buffer_bytes: usize, client_close_signal: CancellationToken, _url: &str, ) -> Self { + let max_buffer_bytes = if max_buffer_bytes == 0 { MAX_BUFFER_BYTES } else { max_buffer_bytes }; // Item-count limit remains as a secondary cap; byte-level backpressure - // is enforced via `MAX_BUFFER_BYTES` and `Semaphore`. + // is enforced via `max_buffer_bytes` and `Semaphore`. let (tx, rx) = channel(max(buffer_size, CHANNEL_SIZE)); - let semaphore = Arc::new(Semaphore::new(MAX_BUFFER_BYTES)); + let semaphore = Arc::new(Semaphore::new(max_buffer_bytes)); tokio::spawn(Self::buffer_stream( tx, stream, client_close_signal.clone(), Arc::clone(&semaphore), + max_buffer_bytes, )); Self { stream: ReceiverStream::new(rx), close_cancelled: Box::pin(client_close_signal.cancelled_owned()), semaphore, + max_buffer_bytes, } } @@ -57,6 +62,7 @@ impl BufferedStream { mut stream: BoxedProviderStream, client_close_signal: CancellationToken, semaphore: Arc, + max_buffer_bytes: usize, ) { let idle_timeout = Duration::from_secs(STREAM_IDLE_TIMEOUT); let idle = sleep(idle_timeout); @@ -78,14 +84,14 @@ impl BufferedStream { match chunk { Some(Ok(chunk)) => { let chunk_len = chunk.len(); - // Cap permits at MAX_BUFFER_BYTES per chunk. A single chunk larger + // Cap permits at max_buffer_bytes per chunk. A single chunk larger // than the cap consumes fewer permits than its actual byte count, so // the semaphore may temporarily allow more bytes in the channel than - // MAX_BUFFER_BYTES. This is an intentional trade-off: upstream + // max_buffer_bytes. This is an intentional trade-off: upstream // providers are expected to emit chunks well below this limit; the // inaccuracy is bounded to a single oversized chunk and is self- // correcting once that chunk is delivered. - let permits = chunk_len.min(MAX_BUFFER_BYTES); + let permits = chunk_len.min(max_buffer_bytes); if permits > 0 { let acquired = select! { biased; @@ -162,7 +168,7 @@ impl Stream for BufferedStream { } else { match Pin::new(&mut this.stream).poll_next(cx) { Poll::Ready(Some(Ok(bytes))) => { - this.semaphore.add_permits(bytes.len().min(MAX_BUFFER_BYTES)); + this.semaphore.add_permits(bytes.len().min(this.max_buffer_bytes)); Poll::Ready(Some(Ok(bytes))) } other => other, @@ -223,7 +229,7 @@ mod tests { dropped: Some(dropped_tx), yielded: false, }; - let buffered = BufferedStream::new(Box::pin(upstream), 1, cancel.clone(), "test"); + let buffered = BufferedStream::new(Box::pin(upstream), 1, 0, cancel.clone(), "test"); drop(buffered); gate_tx.send(()).expect("producer should still own the gated upstream"); diff --git a/backend/src/api/model/streams/mod.rs b/backend/src/api/model/streams/mod.rs index 188c882ad..3d600021f 100644 --- a/backend/src/api/model/streams/mod.rs +++ b/backend/src/api/model/streams/mod.rs @@ -15,6 +15,7 @@ mod throttled_stream; pub use self::persist_pipe_stream::*; pub(crate) use self::transport_stream_buffer::*; +pub(in crate::api) use self::buffered_stream::MAX_BUFFER_BYTES; pub(in crate::api) use self::{ active_client_stream::*, custom_video_stream::*, metering_stream::*, provider_stream::*, provider_stream_factory::*, provisioning_stream::*, shared_stream_manager::*, diff --git a/backend/src/api/model/streams/persist_pipe_stream.rs b/backend/src/api/model/streams/persist_pipe_stream.rs index 6842e91fc..7a6deb56d 100644 --- a/backend/src/api/model/streams/persist_pipe_stream.rs +++ b/backend/src/api/model/streams/persist_pipe_stream.rs @@ -28,6 +28,7 @@ where tokio::spawn(async move { let mut total_size = 0usize; let mut writer_active = true; + let mut receiver_active = true; let mut write_err: Option = None; let mut write_counter = 0usize; @@ -63,10 +64,24 @@ where } } - let _ = tx.send(Ok(bytes)).await; + if receiver_active && tx.send(Ok(bytes)).await.is_err() { + receiver_active = false; + } + // Keep persisting for the cache after a client disconnect, but stop + // pulling from upstream once neither consumer can use the data + if !writer_active && !receiver_active { + debug!("Persist pipe stream has no writer and no receiver, closing"); + break; + } } Some(Err(e)) => { - let _ = tx.send(Err(e)).await; + if receiver_active && tx.send(Err(e)).await.is_err() { + receiver_active = false; + } + if !writer_active && !receiver_active { + debug!("Persist pipe stream has no writer and no receiver, closing"); + break; + } } None => { debug_if_enabled!("Persist pipe stream ended. Closing {}", resource_path.display()); diff --git a/backend/src/api/model/streams/provider_stream_factory.rs b/backend/src/api/model/streams/provider_stream_factory.rs index 79f5a6d59..cb812ee42 100644 --- a/backend/src/api/model/streams/provider_stream_factory.rs +++ b/backend/src/api/model/streams/provider_stream_factory.rs @@ -84,6 +84,7 @@ pub struct ProviderStreamFactoryOptions { item_type: PlaylistItemType, flags: ProviderStreamFactoryFlagsSet, buffer_size: usize, + buffer_max_bytes: usize, url: Url, headers: HeaderMap, default_user_agent: Option, @@ -138,6 +139,7 @@ impl ProviderStreamFactoryOptions { content_representation, } = request; let buffer_size = if stream_options.buffer_enabled { stream_options.buffer_size } else { 0 }; + let buffer_max_bytes = stream_options.buffer_max_bytes; let user_agent = req_headers .get(axum::http::header::USER_AGENT) .and_then(|value| value.to_str().ok()) @@ -194,6 +196,7 @@ impl ProviderStreamFactoryOptions { addr: *addr, flags, buffer_size, + buffer_max_bytes, reconnect_flag: CancellationToken::new(), url, headers, @@ -231,6 +234,9 @@ impl ProviderStreamFactoryOptions { #[inline] pub(crate) fn get_buffer_size(&self) -> usize { self.buffer_size } + #[inline] + pub(crate) fn get_buffer_max_bytes(&self) -> usize { self.buffer_max_bytes } + #[inline] pub fn get_reconnect_flag_clone(&self) -> CancellationToken { self.reconnect_flag.clone() } @@ -1061,6 +1067,7 @@ pub async fn create_provider_stream( BufferedStream::new( stream, stream_options.get_buffer_size(), + stream_options.get_buffer_max_bytes(), stream_options.get_reconnect_flag_clone(), stream_options.get_url_as_str(), ) @@ -1271,7 +1278,7 @@ mod tests { session_headers: Option<&HashMap>, ) -> ProviderStreamFactoryOptions { let stream_options = - StreamOptions { stream_retry: true, buffer_enabled: false, buffer_size: 0, pipe_provider_stream: false }; + StreamOptions { stream_retry: true, buffer_enabled: false, buffer_size: 0, buffer_max_bytes: 0, pipe_provider_stream: false }; ProviderStreamFactoryOptions::new(&ProviderStreamFactoryParams { addr: "127.0.0.1:8080".parse().unwrap(), item_type: PlaylistItemType::Catchup, @@ -1472,12 +1479,40 @@ mod tests { assert_eq!(request.headers()[reqwest::header::RANGE], "bytes=100-199"); } + #[test] + fn test_provider_stream_factory_options_propagate_buffer_max_bytes() { + let addr = "127.0.0.1:8080".parse().unwrap(); + let stream_url = Url::parse("http://example.com/stream").unwrap(); + let stream_options = + StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, buffer_max_bytes: 4096, pipe_provider_stream: false }; + let req_headers = HeaderMap::new(); + let options = ProviderStreamFactoryOptions::new(&ProviderStreamFactoryParams { + addr, + item_type: PlaylistItemType::Live, + share_stream: false, + stream_options: &stream_options, + stream_url: &stream_url, + req_headers: &req_headers, + input_headers: None, + session_headers: None, + disabled_headers: None, + default_user_agent: None, + username: None, + client_ip: None, + stream_channel: None, + connect_failure_stage: None, + content_representation: ProviderContentRepresentationMode::PreserveOrigin, + }); + assert_eq!(options.get_buffer_max_bytes(), 4096); + assert_eq!(options.get_buffer_size(), 1024); + } + #[test] fn test_provider_stream_factory_options_range_logic() { let addr = "127.0.0.1:8080".parse().unwrap(); let stream_url = Url::parse("http://example.com/stream").unwrap(); let stream_options = - StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, pipe_provider_stream: false }; + StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, buffer_max_bytes: 0, pipe_provider_stream: false }; let disabled_headers = None; // Case 1: VOD, no initial range requested @@ -1576,7 +1611,7 @@ mod tests { let stream_url = Url::parse("http://example.com/segment.ts").unwrap(); let req_headers = HeaderMap::new(); let stream_options = - StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, pipe_provider_stream: false }; + StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, buffer_max_bytes: 0, pipe_provider_stream: false }; let hls_options = ProviderStreamFactoryOptions::new(&ProviderStreamFactoryParams { addr, @@ -1626,7 +1661,7 @@ mod tests { let stream_url = Url::parse("http://example.com/shared.ts").unwrap(); let req_headers = HeaderMap::new(); let stream_options = - StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, pipe_provider_stream: false }; + StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, buffer_max_bytes: 0, pipe_provider_stream: false }; let shared_options = ProviderStreamFactoryOptions::new(&ProviderStreamFactoryParams { addr, @@ -1658,7 +1693,7 @@ mod tests { let stream_url = Url::parse("http://example.com/stream").unwrap(); let req_headers = HeaderMap::new(); let stream_options = - StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, pipe_provider_stream: false }; + StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, buffer_max_bytes: 0, pipe_provider_stream: false }; let options = ProviderStreamFactoryOptions::new(&ProviderStreamFactoryParams { addr, @@ -1732,7 +1767,7 @@ mod tests { let mut session_headers = HashMap::new(); session_headers.insert(String::from("cookie"), String::from("sid=abc; pref=1")); let stream_options = - StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, pipe_provider_stream: false }; + StreamOptions { stream_retry: true, buffer_enabled: true, buffer_size: 1024, buffer_max_bytes: 0, pipe_provider_stream: false }; let options = ProviderStreamFactoryOptions::new(&ProviderStreamFactoryParams { addr, diff --git a/backend/src/api/model/streams/shared_stream_manager.rs b/backend/src/api/model/streams/shared_stream_manager.rs index ad6c6a078..390ce2c44 100644 --- a/backend/src/api/model/streams/shared_stream_manager.rs +++ b/backend/src/api/model/streams/shared_stream_manager.rs @@ -34,6 +34,7 @@ const YIELD_COUNTER: usize = 64; const MIN_BURST_BUFFER_CHUNKS: usize = 2; const MIN_BURST_BUFFER_CHUNK_ACCOUNTING_BYTES: usize = 188; const SHARED_BURST_BYTES_PER_BUFFER_SLOT: usize = 12 * 1024; +const DEFAULT_SUBSCRIBER_IDLE_TIMEOUT_SECS: u64 = 300; struct ReceiverStreamWrapper { stream: S, @@ -229,6 +230,7 @@ pub struct SharedStreamState { burst_buffer: Arc>, live_notification: Arc, task_handles: RwLock>>, + subscriber_idle_timeout_secs: u64, } impl SharedStreamState { @@ -254,9 +256,17 @@ impl SharedStreamState { burst_buffer: Arc::new(Mutex::new(BurstBuffer::new(burst_buffer_size_in_bytes))), live_notification: Arc::new(Notify::new()), task_handles: RwLock::new(Vec::new()), + subscriber_idle_timeout_secs: DEFAULT_SUBSCRIBER_IDLE_TIMEOUT_SECS, } } + fn with_subscriber_idle_timeout_secs(mut self, secs: u64) -> Self { + if secs > 0 { + self.subscriber_idle_timeout_secs = secs; + } + self + } + async fn register_subscriber(&self, addr: &SocketAddr, cancel_token: CancellationToken) -> SharedSubscriberOwner { let id = self.next_subscriber_id.fetch_add(1, Ordering::Relaxed); let subscriber = SharedSubscriber { @@ -341,7 +351,7 @@ impl SharedStreamState { let burst_buffer = Arc::clone(&self.burst_buffer); let burst_buffer_for_log = Arc::clone(&self.burst_buffer); let live_notification = Arc::clone(&self.live_notification); - let timeout_duration = Duration::from_mins(5); + let timeout_duration = Duration::from_secs(self.subscriber_idle_timeout_secs); let idle_check_interval = Duration::from_secs(1); let mut last_active = Instant::now(); let mut last_lag_log = Instant::now().checked_sub(Duration::from_secs(10)).unwrap_or_else(Instant::now); @@ -570,7 +580,11 @@ impl SharedStreamState { } chunk = source_stream.next() => { - idle.as_mut().reset(Instant::now() + idle_timeout); + // Only successful chunks count as liveness; resetting on Err would let an + // error-spinning source dodge the idle timeout forever + if matches!(chunk, Some(Ok(_))) { + idle.as_mut().reset(Instant::now() + idle_timeout); + } match chunk { Some(Ok(data)) => { let chunk_len = data.len(); @@ -904,13 +918,22 @@ impl SharedStreamManager { .load() .as_ref() .and_then(|c| c.low_priority_preempted.clone()); - let shared_state = Arc::new(SharedStreamState::new( - headers, - buf_size, - provider_handle, - min_buffer_bytes, - low_priority_preempted, - )); + let shared_state = Arc::new( + SharedStreamState::new( + headers, + buf_size, + provider_handle, + min_buffer_bytes, + low_priority_preempted, + ) + .with_subscriber_idle_timeout_secs( + config + .reverse_proxy + .as_ref() + .and_then(|reverse_proxy| reverse_proxy.stream.as_ref()) + .map_or(DEFAULT_SUBSCRIBER_IDLE_TIMEOUT_SECS, |stream| stream.shared_subscriber_idle_timeout_secs), + ), + ); app_state.shared_stream_manager.register(addr, stream_url, Arc::clone(&shared_state)).await; app_state.active_provider.make_shared_connection(addr, stream_url).await; let subscribed_stream = Self::subscribe_shared_stream(app_state, stream_url, addr, user_priority, connection_kind).await; diff --git a/backend/src/api/model/streams/transport_stream_buffer.rs b/backend/src/api/model/streams/transport_stream_buffer.rs index 1f7137758..81de2c7d0 100644 --- a/backend/src/api/model/streams/transport_stream_buffer.rs +++ b/backend/src/api/model/streams/transport_stream_buffer.rs @@ -41,7 +41,20 @@ fn add_pcr_offset_27mhz(timestamp_27mhz: u64, offset_27mhz: u64) -> u64 { const TS_PACKET_SIZE: usize = 188; const SYNC_BYTE: u8 = 0x47; const PACKET_COUNT: usize = 7; // Reduced from 250 to 7 (1316 bytes) to prevent latency/timeout on low-bitrate streams -const CHUNK_SIZE: usize = TS_PACKET_SIZE * PACKET_COUNT; +const MAX_PACKET_COUNT: usize = 250; + +/// Packets per emitted chunk; overridable via `TULIPROX_TS_CHUNK_PACKETS` (1-250). +/// Larger chunks raise throughput, smaller chunks lower latency on low-bitrate streams. +fn ts_chunk_packet_count() -> usize { + static PACKET_COUNT_OVERRIDE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + std::env::var("TULIPROX_TS_CHUNK_PACKETS") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|count| (1..=MAX_PACKET_COUNT).contains(count)) + .unwrap_or(PACKET_COUNT) + }); + *PACKET_COUNT_OVERRIDE +} const ADAPTATION_FIELD_FLAG_PCR: u8 = 0x10; // PCR flag bit in adaptation field flags const NULL_PID: u16 = 0x1FFF; @@ -1614,8 +1627,9 @@ impl TransportStreamBuffer { if self.length == 0 { return None; } - let mut bytes = BytesMut::with_capacity(CHUNK_SIZE); - let mut packets_remaining = PACKET_COUNT; + let packet_count = ts_chunk_packet_count(); + let mut bytes = BytesMut::with_capacity(TS_PACKET_SIZE * packet_count); + let mut packets_remaining = packet_count; while packets_remaining > 0 { if self.current_pos >= self.length { diff --git a/backend/src/api/setup_api.rs b/backend/src/api/setup_api.rs index 7b370d952..922ab23fa 100644 --- a/backend/src/api/setup_api.rs +++ b/backend/src/api/setup_api.rs @@ -964,13 +964,19 @@ pub async fn start_setup_server(paths: &ConfigPaths, missing_files: &[String]) - #[cfg(test)] mod tests { - use super::{api_proxy_or_default, setup_complete_inner, SetupCompleteRequestDto, SetupModeState, SetupWebUserCredentialDto}; - use axum::{body::to_bytes, http::StatusCode}; + use super::api_proxy_or_default; use shared::model::{ApiProxyConfigDto, AppConfigDto, TargetUserDto}; + + #[cfg(unix)] + use super::{setup_complete_inner, SetupCompleteRequestDto, SetupModeState, SetupWebUserCredentialDto}; + #[cfg(unix)] + use axum::{body::to_bytes, http::StatusCode}; + #[cfg(unix)] use std::{ path::PathBuf, sync::{atomic::AtomicBool, Arc}, }; + #[cfg(unix)] use tokio::sync::{oneshot, Mutex, RwLock}; #[test] diff --git a/backend/src/api/sys_usage.rs b/backend/src/api/sys_usage.rs index 77465b597..639e145ce 100644 --- a/backend/src/api/sys_usage.rs +++ b/backend/src/api/sys_usage.rs @@ -239,24 +239,36 @@ impl CpuTracker { #[allow(clippy::cast_possible_truncation)] fn cpu_percent(cpu_delta_secs: f64, elapsed_secs: f64) -> f32 { ((cpu_delta_secs / elapsed_secs) * 100.0) as f32 } +#[derive(Clone, Copy, Default)] +struct NetSample { + rx_bytes_per_sec: f64, + tx_bytes_per_sec: f64, + rx_bytes_total: u64, + tx_bytes_total: u64, +} + #[allow(clippy::struct_field_names)] struct NetTracker { last_rx_bytes: u64, last_tx_bytes: u64, + total_rx_bytes: u64, + total_tx_bytes: u64, last_sample_at: Option, } impl NetTracker { - fn new() -> Self { Self { last_rx_bytes: 0, last_tx_bytes: 0, last_sample_at: None } } + fn new() -> Self { + Self { last_rx_bytes: 0, last_tx_bytes: 0, total_rx_bytes: 0, total_tx_bytes: 0, last_sample_at: None } + } #[allow(clippy::cast_precision_loss)] - fn sample(&mut self, rx_bytes: u64, tx_bytes: u64) -> (f64, f64) { + fn sample(&mut self, rx_bytes: u64, tx_bytes: u64) -> NetSample { let now = Instant::now(); let Some(last_sample_at) = self.last_sample_at else { self.last_rx_bytes = rx_bytes; self.last_tx_bytes = tx_bytes; self.last_sample_at = Some(now); - return (0.0, 0.0); + return NetSample::default(); }; let elapsed_secs = now.duration_since(last_sample_at).as_secs_f64(); @@ -265,12 +277,20 @@ impl NetTracker { self.last_rx_bytes = rx_bytes; self.last_tx_bytes = tx_bytes; + self.total_rx_bytes = self.total_rx_bytes.saturating_add(rx_delta); + self.total_tx_bytes = self.total_tx_bytes.saturating_add(tx_delta); self.last_sample_at = Some(now); - if elapsed_secs <= f64::EPSILON { + let (rx_bytes_per_sec, tx_bytes_per_sec) = if elapsed_secs <= f64::EPSILON { (0.0, 0.0) } else { (rx_delta as f64 / elapsed_secs, tx_delta as f64 / elapsed_secs) + }; + NetSample { + rx_bytes_per_sec, + tx_bytes_per_sec, + rx_bytes_total: self.total_rx_bytes, + tx_bytes_total: self.total_tx_bytes, } } } @@ -341,14 +361,16 @@ impl FallbackSampler { self.networks.refresh(true); let (rx_bytes, tx_bytes) = sum_sysinfo_network_bytes(&self.networks); - let (rx_bytes_per_sec, tx_bytes_per_sec) = self.net_tracker.sample(rx_bytes, tx_bytes); + let net = self.net_tracker.sample(rx_bytes, tx_bytes); self.inner.processes().get(&self.pid).map(|proc| SystemInfo { cpu_usage: proc.cpu_usage(), memory_usage: proc.memory(), memory_total: self.inner.total_memory(), - net_rx_bytes_per_sec: rx_bytes_per_sec, - net_tx_bytes_per_sec: tx_bytes_per_sec, + net_rx_bytes_per_sec: net.rx_bytes_per_sec, + net_tx_bytes_per_sec: net.tx_bytes_per_sec, + net_rx_bytes_total: net.rx_bytes_total, + net_tx_bytes_total: net.tx_bytes_total, disk_total_bytes: 0, disk_free_bytes: 0, }) @@ -364,7 +386,7 @@ fn sum_sysinfo_network_bytes(networks: &sysinfo::Networks) -> (u64, u64) { #[cfg(target_os = "linux")] mod platform { - use super::{parse_ascii_u64_bytes, CpuTracker, DiskProbe, SystemInfo}; + use super::{parse_ascii_u64_bytes, CpuTracker, DiskProbe, NetSample, SystemInfo}; use log::debug; use std::{ fs::{read, File}, @@ -424,8 +446,8 @@ mod platform { let resident_pages = parse_linux_proc_statm(&self.resident_pages_buf[..resident_pages_len])?; let cpu_time_secs = ticks_to_cpu_secs(utime, stime, self.clock_ticks_per_sec); - let (net_rx_bytes_per_sec, net_tx_bytes_per_sec) = read_proc_net_dev_bytes() - .map_or((0.0, 0.0), |(rx_bytes, tx_bytes)| self.net_tracker.sample(rx_bytes, tx_bytes)); + let net = read_proc_net_dev_bytes() + .map_or_else(NetSample::default, |(rx_bytes, tx_bytes)| self.net_tracker.sample(rx_bytes, tx_bytes)); let (disk_total_bytes, disk_free_bytes) = self.disk_probe.as_ref().map_or((0, 0), DiskProbe::sample); @@ -433,8 +455,10 @@ mod platform { cpu_usage: self.cpu_tracker.sample(cpu_time_secs), memory_usage: resident_pages.saturating_mul(self.page_size), memory_total: self.memory_total, - net_rx_bytes_per_sec, - net_tx_bytes_per_sec, + net_rx_bytes_per_sec: net.rx_bytes_per_sec, + net_tx_bytes_per_sec: net.tx_bytes_per_sec, + net_rx_bytes_total: net.rx_bytes_total, + net_tx_bytes_total: net.tx_bytes_total, disk_total_bytes, disk_free_bytes, }) @@ -583,14 +607,16 @@ mod platform { let memory_usage = query_process_memory_usage(self.process.0)?; self.networks.refresh(true); let (rx_bytes, tx_bytes) = super::sum_sysinfo_network_bytes(&self.networks); - let (received_bps, sent_bps) = self.net_tracker.sample(rx_bytes, tx_bytes); + let net = self.net_tracker.sample(rx_bytes, tx_bytes); let (disk_total_bytes, disk_free_bytes) = self.disk_probe.as_ref().map_or((0, 0), DiskProbe::sample); Some(SystemInfo { cpu_usage: self.cpu_tracker.sample(cpu_time_secs), memory_usage, memory_total: self.memory_total, - net_rx_bytes_per_sec: received_bps, - net_tx_bytes_per_sec: sent_bps, + net_rx_bytes_per_sec: net.rx_bytes_per_sec, + net_tx_bytes_per_sec: net.tx_bytes_per_sec, + net_rx_bytes_total: net.rx_bytes_total, + net_tx_bytes_total: net.tx_bytes_total, disk_total_bytes, disk_free_bytes, }) @@ -710,14 +736,16 @@ mod platform { let memory_usage = query_process_memory_usage()?; self.networks.refresh(true); let (rx_bytes, tx_bytes) = super::sum_sysinfo_network_bytes(&self.networks); - let (received_bps, sent_bps) = self.net_tracker.sample(rx_bytes, tx_bytes); + let net = self.net_tracker.sample(rx_bytes, tx_bytes); let (disk_total_bytes, disk_free_bytes) = self.disk_probe.as_ref().map_or((0, 0), DiskProbe::sample); Some(SystemInfo { cpu_usage: self.cpu_tracker.sample(cpu_time_secs), memory_usage, memory_total: self.memory_total, - net_rx_bytes_per_sec: received_bps, - net_tx_bytes_per_sec: sent_bps, + net_rx_bytes_per_sec: net.rx_bytes_per_sec, + net_tx_bytes_per_sec: net.tx_bytes_per_sec, + net_rx_bytes_total: net.rx_bytes_total, + net_tx_bytes_total: net.tx_bytes_total, disk_total_bytes, disk_free_bytes, }) @@ -880,9 +908,11 @@ mod tests { let mut tracker = super::NetTracker::new(); let _ = tracker.sample(1000, 500); tracker.last_sample_at = tracker.last_sample_at.map(|instant| instant.checked_sub(Duration::from_secs(2)).unwrap()); - let (rx_rate, tx_rate) = tracker.sample(3000, 1500); - assert!((999.0..=1001.0).contains(&rx_rate)); - assert!((499.0..=501.0).contains(&tx_rate)); + let sample = tracker.sample(3000, 1500); + assert!((999.0..=1001.0).contains(&sample.rx_bytes_per_sec)); + assert!((499.0..=501.0).contains(&sample.tx_bytes_per_sec)); + assert_eq!(sample.rx_bytes_total, 2000); + assert_eq!(sample.tx_bytes_total, 1000); } } diff --git a/backend/src/auth/access_token.rs b/backend/src/auth/access_token.rs index 15905bbc5..cba60ae57 100644 --- a/backend/src/auth/access_token.rs +++ b/backend/src/auth/access_token.rs @@ -1,7 +1,7 @@ use shared::utils::{hex_decode, hex_encode}; use chrono::Utc; -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { a.len() == b.len() && a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } @@ -13,7 +13,10 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { // } pub fn create_access_token(secret: &[u8; 32], ttl_secs: u16) -> String { - let timestamp = Utc::now().timestamp(); + create_access_token_at(secret, ttl_secs, Utc::now().timestamp()) +} + +fn create_access_token_at(secret: &[u8; 32], ttl_secs: u16, timestamp: i64) -> String { let timestamp_bytes = timestamp.to_le_bytes(); let ttl_secs_bytes = ttl_secs.to_le_bytes(); let mut payload = Vec::with_capacity(timestamp_bytes.len() + ttl_secs_bytes.len()); @@ -25,6 +28,10 @@ pub fn create_access_token(secret: &[u8; 32], ttl_secs: u16) -> String { } pub fn verify_access_token(token_str: &str, secret: &[u8; 32]) -> bool { + verify_access_token_at(token_str, secret, Utc::now().timestamp()) +} + +fn verify_access_token_at(token_str: &str, secret: &[u8; 32], current_timestamp: i64) -> bool { const TOKEN_LEN: usize = 84; const TIMESTAMP_END: usize = 16; const TTL_END: usize = 20; @@ -62,8 +69,7 @@ pub fn verify_access_token(token_str: &str, secret: &[u8; 32]) -> bool { return false; } - let current_timestamp = Utc::now().timestamp(); - if current_timestamp - timestamp > i64::from(ttl_secs) { + if current_timestamp.saturating_sub(timestamp) > i64::from(ttl_secs) { return false; } true @@ -71,23 +77,30 @@ pub fn verify_access_token(token_str: &str, secret: &[u8; 32]) -> bool { #[cfg(test)] mod tests { - use crate::auth::access_token::{create_access_token, verify_access_token}; + use crate::auth::access_token::{create_access_token_at, verify_access_token, verify_access_token_at}; use std::panic::catch_unwind; - use std::thread; #[test] fn test_valid_token() { let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; - let token = create_access_token(secret, 1); - assert!(verify_access_token(token.as_str(), secret)); - thread::sleep(std::time::Duration::from_secs(2)); - assert!(!verify_access_token(token.as_str(), secret)); + let token = create_access_token_at(secret, 30, 1_700_000_000); + assert!(verify_access_token_at(token.as_str(), secret, 1_700_000_030)); + assert!(!verify_access_token_at(token.as_str(), secret, 1_700_000_031)); + assert_ne!(token, create_access_token_at(secret, 30, 1_700_000_001)); + } + + #[test] + fn test_expiry_check_handles_extreme_timestamps_without_overflow() { + let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; + let token = create_access_token_at(secret, 30, i64::MIN); + + assert!(!verify_access_token_at(&token, secret, i64::MAX)); } #[test] fn test_ttl_tampering_invalidates_token() { let secret = b"37c30f739e83ba27b4c17b174c31f3a9"; - let token = create_access_token(secret, 1); + let token = create_access_token_at(secret, 1, 1_700_000_000); let mut tampered = token.clone(); tampered.replace_range(16..20, "ffff"); diff --git a/backend/src/auth/api_user_context.rs b/backend/src/auth/api_user_context.rs index 8d3d0cc37..d485b2957 100644 --- a/backend/src/auth/api_user_context.rs +++ b/backend/src/auth/api_user_context.rs @@ -29,7 +29,11 @@ impl From for PermissionDenyReason { } ProxyUserPermissionDenyReason::Disabled => PermissionDenyReason::Disabled, ProxyUserPermissionDenyReason::Banned => PermissionDenyReason::Banned, - ProxyUserPermissionDenyReason::Inactive => PermissionDenyReason::Inactive, + ProxyUserPermissionDenyReason::Inactive + | ProxyUserPermissionDenyReason::UnresolvedPlan + | ProxyUserPermissionDenyReason::InvalidFilter => { + PermissionDenyReason::Inactive + } } } } diff --git a/backend/src/auth/auth_bearer.rs b/backend/src/auth/auth_bearer.rs index e6f16ec96..8ce04938f 100644 --- a/backend/src/auth/auth_bearer.rs +++ b/backend/src/auth/auth_bearer.rs @@ -35,7 +35,7 @@ impl AuthBearer { let split = authorization.split_once(' '); match split { - Some(("Bearer", contents)) => Ok(Self::from_header(contents)), + Some((scheme, contents)) if scheme.eq_ignore_ascii_case("bearer") => Ok(Self::from_header(contents)), _ => Err((StatusCode::FORBIDDEN, "`Authorization` header must be a bearer token")), } } diff --git a/backend/src/auth/authenticator.rs b/backend/src/auth/authenticator.rs index da1dee114..d6adf1053 100644 --- a/backend/src/auth/authenticator.rs +++ b/backend/src/auth/authenticator.rs @@ -1,21 +1,47 @@ -use std::sync::Arc; -use chrono::{Local, Duration}; -use jsonwebtoken::{Algorithm, DecodingKey, encode, decode, EncodingKey, Header, Validation, TokenData}; +use crate::{ + api::{api_utils::get_username_from_auth_header, model::AppState}, + auth::AuthBearer, + model::WebAuthConfig, +}; +use chrono::{Duration, Local}; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation}; use log::warn; -use crate::api::api_utils::get_username_from_auth_header; -use crate::model::WebAuthConfig; -use crate::api::model::AppState; -use crate::auth::AuthBearer; -use shared::error::to_io_error; -use shared::model::permission::{permission_to_name, Permission, PermissionSet, PERM_ALL}; -use shared::model::{Claims, ROLE_ADMIN, ROLE_API_USER}; +use shared::{ + error::to_io_error, + model::{ + permission::{permission_to_name, Permission, PermissionSet, PERM_ALL}, + Claims, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, ROLE_ADMIN, ROLE_API_USER, + }, +}; +use std::sync::Arc; -pub fn create_jwt_admin(web_auth_config: &WebAuthConfig, username: &str, pwd_version: u32) -> Result { - create_jwt(web_auth_config, username, vec![ROLE_ADMIN.to_string()], PERM_ALL, pwd_version) +pub fn create_jwt_admin( + web_auth_config: &WebAuthConfig, + username: &str, + pwd_version: u32, +) -> Result { + create_jwt( + web_auth_config, + username, + vec![ROLE_ADMIN.to_string()], + PERM_ALL, + pwd_version, + Some(UserId::builtin_admin()), + ) } pub fn create_jwt_api_user(web_auth_config: &WebAuthConfig, username: &str) -> Result { - create_jwt(web_auth_config, username, vec![ROLE_API_USER.to_string()], PermissionSet::new(), 0) + create_jwt( + web_auth_config, + username, + vec![ROLE_API_USER.to_string()], + PermissionSet::new(), + 0, + // The identity registry will eventually provide the API + // user's stable `UserId`. Until then, the username-as-id + // fallback is the only stable choice. + Some(UserId::from(format!("api:{username}"))), + ) } pub fn create_jwt_web_user( @@ -24,7 +50,17 @@ pub fn create_jwt_web_user( permissions: PermissionSet, pwd_version: u32, ) -> Result { - create_jwt(web_auth_config, username, Vec::new(), permissions, pwd_version) + create_jwt( + web_auth_config, + username, + Vec::new(), + permissions, + pwd_version, + // The identity registry will eventually provide the web + // user's stable `UserId`. Until then, the username-as-id + // fallback is the only stable choice. + Some(UserId::from(format!("web:{username}"))), + ) } fn create_jwt( @@ -33,6 +69,7 @@ fn create_jwt( roles: Vec, permissions: PermissionSet, pwd_version: u32, + subject_id: Option, ) -> Result { let mut header = Header::new(Algorithm::HS256); header.typ = Some("JWT".to_string()); @@ -40,7 +77,7 @@ fn create_jwt( let iat = now.timestamp(); let duration = web_auth_config.token_ttl_mins; let exp = if duration > 0 { - (now + Duration::minutes(i64::from(duration))).timestamp() + (now + Duration::minutes(i64::from(duration))).timestamp() } else { (now + Duration::days(365 * 100)).timestamp() // 100 years }; @@ -52,15 +89,19 @@ fn create_jwt( roles, permissions, pwd_version, + subject_id, + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, }; match encode(&header, &claims, &EncodingKey::from_secret(web_auth_config.secret.as_bytes())) { Ok(jwt) => Ok(jwt), - Err(err) => Err(to_io_error(err)) + Err(err) => Err(to_io_error(err)), } } pub(crate) fn verify_token(token: &str, secret_key: &[u8]) -> Option> { - if let Ok(token_data) = decode::(token, &DecodingKey::from_secret(secret_key), &Validation::new(Algorithm::HS256)) { + if let Ok(token_data) = + decode::(token, &DecodingKey::from_secret(secret_key), &Validation::new(Algorithm::HS256)) + { return Some(token_data); } None @@ -74,13 +115,9 @@ fn has_role(token_data: Option>, role: &str) -> bool { } } -pub fn is_admin(token_data: Option>) -> bool { - has_role(token_data, ROLE_ADMIN) -} +pub fn is_admin(token_data: Option>) -> bool { has_role(token_data, ROLE_ADMIN) } -pub fn is_api_user(token_data: Option>) -> bool { - has_role(token_data, ROLE_API_USER) -} +pub fn is_api_user(token_data: Option>) -> bool { has_role(token_data, ROLE_API_USER) } pub fn verify_token_admin(bearer: &str, secret_key: &[u8]) -> bool { has_role(verify_token(bearer, secret_key), ROLE_ADMIN) @@ -90,18 +127,97 @@ pub fn verify_token_api_user(bearer: &str, secret_key: &[u8]) -> bool { has_role(verify_token(bearer, secret_key), ROLE_API_USER) } +/// Stable error type for the validators. A stable +/// "token-refresh-required" response lets the frontend sign out +/// without guessing. The HTTP layer returns 401 with an +/// `X-Token-Refresh: required` header for refresh-required cases. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthError { + /// Token signature/issuer/exp invalid, malformed, or otherwise + /// unverifiable. The frontend should sign the user out. + InvalidToken, + /// Token signature is valid but it carries an old or absent + /// `permission_schema_version`. The frontend must refresh + /// credentials to receive a token at the current schema. + StaleSchema, + /// Token signature is valid but it lacks a `subject_id`. The + /// identity-registry-bound principal cannot be resolved. + MissingSubject, + /// Token signature is valid but the principal has the wrong + /// role/permission for the requested endpoint. + Forbidden, +} + +impl AuthError { + /// `true` when the frontend should request a fresh token before + /// retrying the request. Stale-schema and missing-subject both + /// qualify; a re-auth round-trip is required. + pub fn is_token_refresh_required(self) -> bool { matches!(self, Self::StaleSchema | Self::MissingSubject) } +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidToken => f.write_str("token is invalid or expired"), + Self::StaleSchema => f.write_str("token was issued for an older permission schema; refresh required"), + Self::MissingSubject => f.write_str("token is missing a subject_id; refresh required"), + Self::Forbidden => f.write_str("principal does not have the required role"), + } + } +} + +/// Validate a verified token's `permission_schema_version` and +/// `subject_id`. Both must be present and current. Returns +/// [`AuthError::StaleSchema`] when the schema is below the current +/// version, and [`AuthError::MissingSubject`] when the +/// `subject_id` is `None`. A token that passes both checks is fit +/// for downstream permission and authorization checks. +pub(crate) fn validate_token_claims(claims: &Claims) -> Result<(), AuthError> { + if claims.permission_schema_version < CURRENT_PERMISSION_SCHEMA_VERSION { + return Err(AuthError::StaleSchema); + } + if claims.subject_id.is_none() { + return Err(AuthError::MissingSubject); + } + Ok(()) +} + fn validate_request( app_state: &Arc, token: &str, verify_fn: fn(&str, &[u8]) -> bool, -) -> Result<(), ()> { - if let Some(web_auth_config) = &app_state.app_config.config.load().web_ui.as_ref().and_then(|c| c.auth.as_ref()) { - let secret_key = web_auth_config.secret.as_ref(); - if verify_fn(token, secret_key) { - return Ok(()); - } +) -> Result<(), AuthError> { + let config = app_state.app_config.config.load(); + let Some(web_auth_config) = config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) else { + return Err(AuthError::InvalidToken); + }; + let secret_key = web_auth_config.secret.as_ref(); + let token_data = verify_token(token, secret_key).ok_or(AuthError::InvalidToken)?; + validate_token_claims(&token_data.claims)?; + if !verify_fn(token, secret_key) { + return Err(AuthError::Forbidden); } - Err(()) + Ok(()) +} + +/// Build a stable, recognizable rejection response. Refresh-required +/// cases carry the `X-Token-Refresh: required` header so the +/// frontend can branch on it. `Forbidden` (a successful authentication +/// that nonetheless cannot perform the action) maps to 403 so it does +/// not collapse into the "you're not authenticated" 401 path. +fn rejection_for(err: AuthError) -> axum::response::Response { + use axum::http::StatusCode; + let status = match &err { + AuthError::Forbidden => StatusCode::FORBIDDEN, + _ => StatusCode::UNAUTHORIZED, + }; + let mut builder = axum::http::Response::builder().status(status); + if err.is_token_refresh_required() { + builder = builder.header("X-Token-Refresh", "required"); + } + builder + .body(axum::body::Body::from(err.to_string())) + .unwrap_or_else(|_| axum::http::Response::new(axum::body::Body::empty())) } pub async fn validator_admin( @@ -109,11 +225,10 @@ pub async fn validator_admin( AuthBearer(token): AuthBearer, request: axum::extract::Request, next: axum::middleware::Next, -) -> Result { +) -> axum::response::Response { match validate_request(&app_state, &token, verify_token_admin) { - Ok(()) => Ok(next.run(request).await), - Err(()) => Err(axum::http::StatusCode::UNAUTHORIZED) - + Ok(()) => next.run(request).await, + Err(err) => rejection_for(err), } } @@ -122,17 +237,20 @@ pub async fn validator_api_user( AuthBearer(token): AuthBearer, request: axum::extract::Request, next: axum::middleware::Next, -) -> Result { +) -> axum::response::Response { if let Some(username) = get_username_from_auth_header(&token, &app_state) { if let Some(user) = app_state.app_config.get_user_credentials(&username) { if !user.ui_enabled { - return Err(axum::http::StatusCode::FORBIDDEN); + return axum::http::Response::builder() + .status(axum::http::StatusCode::FORBIDDEN) + .body(axum::body::Body::from("principal does not have the required role".to_string())) + .unwrap_or_else(|_| axum::http::Response::new(axum::body::Body::empty())); } } } match validate_request(&app_state, &token, verify_token_api_user) { - Ok(()) => Ok(next.run(request).await), - Err(()) => Err(axum::http::StatusCode::UNAUTHORIZED) + Ok(()) => next.run(request).await, + Err(err) => rejection_for(err), } } @@ -142,21 +260,169 @@ pub async fn require_permission_inner( AuthBearer(token): AuthBearer, request: axum::extract::Request, next: axum::middleware::Next, -) -> Result { +) -> axum::response::Response { let config = app_state.app_config.config.load(); let Some(web_auth_config) = config.web_ui.as_ref().and_then(|c| c.auth.as_ref()) else { - return Err(axum::http::StatusCode::UNAUTHORIZED); + return rejection_for(AuthError::InvalidToken); }; let Some(token_data) = verify_token(&token, web_auth_config.secret.as_bytes()) else { - return Err(axum::http::StatusCode::UNAUTHORIZED); + return rejection_for(AuthError::InvalidToken); }; - - if token_data.claims.permissions.contains(permission) { - return Ok(next.run(request).await); + if let Err(err) = validate_token_claims(&token_data.claims) { + return rejection_for(err); } - let denied_permission = permission_to_name(permission).unwrap_or("unknown"); - warn!("User '{}' denied permission '{denied_permission}'", token_data.claims.username); - Err(axum::http::StatusCode::FORBIDDEN) + if !token_data.claims.permissions.contains(permission) { + let denied_permission = permission_to_name(permission).unwrap_or("unknown"); + warn!("User '{}' denied permission '{denied_permission}'", token_data.claims.username); + return rejection_for(AuthError::Forbidden); + } + + next.run(request).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::WebAuthConfig; + use shared::model::{ + permission::Permission, Claims, UserId, CURRENT_PERMISSION_SCHEMA_VERSION, ROLE_ADMIN, ROLE_API_USER, + }; + + fn test_web_auth_config() -> WebAuthConfig { + WebAuthConfig { + enabled: true, + issuer: "tuliprox-test".to_string(), + secret: "test-secret".to_string(), + token_ttl_mins: 60, + userfile: None, + groupfile: None, + t_users: None, + t_groups: None, + } + } + + #[test] + fn admin_jwt_carries_builtin_admin_subject_id_and_current_schema_version() { + let cfg = test_web_auth_config(); + let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt"); + let secret = cfg.secret.as_bytes(); + let data = verify_token(&jwt, secret).expect("verify"); + assert_eq!(data.claims.username, "any"); + assert_eq!(data.claims.subject_id, Some(UserId::builtin_admin())); + assert!(data.claims.roles.contains(&ROLE_ADMIN.to_string())); + assert!(data.claims.permissions.contains(Permission::ConfigRead)); + assert!(data.claims.permissions.contains(Permission::RecordingRead)); + assert!(data.claims.permissions.contains(Permission::RecordingWrite)); + assert_eq!(data.claims.permission_schema_version, CURRENT_PERMISSION_SCHEMA_VERSION); + } + + #[test] + fn web_user_jwt_carries_web_namespaced_subject_id() { + let cfg = test_web_auth_config(); + let jwt = + create_jwt_web_user(&cfg, "alice", Permission::ConfigRead | Permission::RecordingRead, 0).expect("web jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + assert_eq!(data.claims.username, "alice"); + assert_eq!(data.claims.subject_id, Some(UserId::from("web:alice"))); + assert!(!data.claims.roles.contains(&ROLE_ADMIN.to_string())); + assert!(data.claims.permissions.contains(Permission::RecordingRead)); + assert!(!data.claims.permissions.contains(Permission::RecordingWrite)); + } + + #[test] + fn api_user_jwt_carries_api_namespaced_subject_id() { + let cfg = test_web_auth_config(); + let jwt = create_jwt_api_user(&cfg, "bob").expect("api jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + assert_eq!(data.claims.subject_id, Some(UserId::from("api:bob"))); + assert!(data.claims.roles.contains(&ROLE_API_USER.to_string())); + assert!(data.claims.permissions.is_empty()); + } + + #[test] + fn stale_schema_token_is_rejected() { + // Manually craft a Claims payload that simulates a token issued + // before the schema bump. The validator must mark it + // refresh-required. + let cfg = test_web_auth_config(); + let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt"); + let mut data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + data.claims.permission_schema_version = 0; // stale + let err = validate_token_claims(&data.claims).unwrap_err(); + assert!(matches!(err, AuthError::StaleSchema)); + assert!(err.is_token_refresh_required()); + } + + #[test] + fn missing_subject_token_is_rejected() { + // A token that survives signature verification but has no + // `subject_id` must be rejected. The validator flags it as + // refresh-required. + let cfg = test_web_auth_config(); + let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt"); + let mut data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + data.claims.subject_id = None; + let err = validate_token_claims(&data.claims).unwrap_err(); + assert!(matches!(err, AuthError::MissingSubject)); + assert!(err.is_token_refresh_required()); + } + + #[test] + fn forged_signature_is_invalid_token() { + let cfg = test_web_auth_config(); + let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt"); + let mut tampered = jwt.clone(); + // Flip a character in the signature segment. + let last = tampered.pop().unwrap(); + tampered.push(if last == 'A' { 'B' } else { 'A' }); + assert!(verify_token(&tampered, cfg.secret.as_bytes()).is_none()); + } + + #[test] + fn owner_id_never_serialized_into_token_payloads() { + // No request body may carry an `owner_id`. This test asserts + // the inverse: a JWT payload never exposes an `owner_id` + // field directly. The owner + // identity is captured only via `subject_id` and the + // permission set. + let cfg = test_web_auth_config(); + let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt"); + let data = verify_token(&jwt, cfg.secret.as_bytes()).expect("verify"); + let json = serde_json::to_string(&data.claims).expect("serialize"); + assert!(!json.contains("owner_id"), "JWT must not carry an owner_id field; got: {json}"); + assert!(json.contains("subject_id"), "JWT must carry subject_id: {json}"); + } + + #[test] + fn is_token_refresh_required_classifier() { + assert!(AuthError::StaleSchema.is_token_refresh_required()); + assert!(AuthError::MissingSubject.is_token_refresh_required()); + assert!(!AuthError::InvalidToken.is_token_refresh_required()); + assert!(!AuthError::Forbidden.is_token_refresh_required()); + } + + #[test] + fn claims_pwd_version_and_subject_id_round_trip() { + // The round-trip preserves the new fields through the + // shared `Claims` type, so a token issued today can be + // validated and the schema info surfaced to the validator. + let claims = Claims { + username: "alice".to_string(), + iss: "tuliprox".to_string(), + iat: 100, + exp: 200, + roles: vec!["user".to_string()], + permissions: Permission::ConfigRead.into(), + pwd_version: 7, + subject_id: Some(UserId::from("web:alice")), + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + }; + let json = serde_json::to_string(&claims).expect("serialize"); + let restored: Claims = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(restored.subject_id, claims.subject_id); + assert_eq!(restored.permission_schema_version, claims.permission_schema_version); + assert_eq!(restored.pwd_version, 7); + } } diff --git a/backend/src/auth/fingerprint.rs b/backend/src/auth/fingerprint.rs index 18c5f6172..5eb965b55 100644 --- a/backend/src/auth/fingerprint.rs +++ b/backend/src/auth/fingerprint.rs @@ -72,9 +72,9 @@ impl Fingerprint { } } - let client_ip = real_ip.as_ref() - .map(ToString::to_string) - .or(forwarded_for.as_ref().map(ToString::to_string)) + let client_ip = real_ip + // X-Forwarded-For may be a comma-separated chain; the first entry is the client + .or_else(|| forwarded_for.and_then(|list| list.split(',').next().map(|ip| ip.trim().to_string()))) .unwrap_or_else(|| addr.ip().to_string()); let ua = user_agent.unwrap_or_else(String::new); diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index 0b99523c0..7dedd8071 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -7,6 +7,7 @@ mod auth_basic; mod access_token; mod fingerprint; mod api_user_context; +mod recording_auth; type Rejection = (StatusCode, &'static str); @@ -29,4 +30,5 @@ pub use self::password::*; pub use self::fingerprint::*; pub use self::auth_basic::*; pub use self::auth_bearer::*; -pub use self::api_user_context::*; \ No newline at end of file +pub use self::api_user_context::*; +pub use self::recording_auth::*; \ No newline at end of file diff --git a/backend/src/auth/recording_auth.rs b/backend/src/auth/recording_auth.rs new file mode 100644 index 000000000..0e822177c --- /dev/null +++ b/backend/src/auth/recording_auth.rs @@ -0,0 +1,814 @@ +//! Recording authorization policy. +//! +//! One pure module that decides whether a subject (a `Claims` payload + +//! its resolved `UserId`) can perform an action against a recording +//! task. The decision combines: +//! +//! - The principal's recording permission bits +//! - The principal's role (administrator vs. web/API user) +//! - The recording's `RecordingOwner` (real owner vs. `LegacyAdmin`) +//! - The recording's `RecordingVisibility` (private vs. shared) +//! - The task's `DownloadState` (for state-aware actions like +//! `system-retention-delete`) +//! - A path/visibility/owner triple that the caller resolves before +//! calling the policy +//! +//! The policy separates "user ownership bypass" from "path/kind/state +//! checks": system retention can act on behalf of any user, but only on +//! eligible terminal states and only through the retention worker. + +use shared::model::permission::Permission; +use shared::model::recording::{RecordingMetadata, RecordingVisibility}; +use shared::model::{Claims, ROLE_ADMIN, UserId}; + +/// Actions the recording system distinguishes. Each action maps to one +/// or more HTTP routes and one or more WebSocket messages. New +/// actions must be added here AND in the route table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RecordingAction { + /// List or read the metadata of a single recording. + Read, + /// Create a private recording. + CreatePrivate, + /// Create a shared recording. Only administrators may create + /// shared recordings; the policy rejects any non-admin caller + /// regardless of the permission bits they carry. + CreateShared, + /// Edit an existing recording (interval, padding, programme data, + /// path reservation). + Edit, + /// Cancel an in-flight or scheduled recording. + Cancel, + /// Delete a finished (terminal) recording via an explicit user + /// `DELETE` request. + Delete, + /// Create, edit or delete a recurring rule. + ManageRule, + /// Play back the recording media. + Playback, + /// Download the recording media (range requests, file copies). + Download, + /// Internal retention delete (eligible completed recordings only). + /// Bypasses user ownership but enforces state/kind/visibility. + SystemRetentionDelete, +} + +/// Why an action was denied. Surfaced as a stable string for the HTTP +/// layer and a structured field for the frontend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DenyReason { + /// The caller carries no `subject_id`; the request must be + /// re-authenticated before the policy can be evaluated. + UnknownSubject, + /// The caller does not have the required recording permission. + MissingPermission(Permission), + /// Private recording access requires the real owner subject. + NotOwner, + /// Shared mutation (edit/cancel/delete) requires the administrator + /// role in addition to the recording permission. + NotAdministrator, + /// `LegacyAdmin` recordings are only accessible to administrators + /// with the required permission. Non-admin callers — even the + /// real owner — cannot act on a `LegacyAdmin` recording. + LegacyAdminReserved, + /// Retention delete is gated on a terminal state and a non-partial + /// path. The recording's current state forbids it. + IneligibleState, + /// Path/kind validation failed (e.g., a partial path on a `Completed` + /// recording, a foreign `relative_path`, or a missing owner). + InvalidPath, +} + +impl std::fmt::Display for DenyReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownSubject => f.write_str("subject_id is missing"), + Self::MissingPermission(p) => write!(f, "missing permission: {}", permission_name(*p)), + Self::NotOwner => f.write_str("not the real owner"), + Self::NotAdministrator => f.write_str("administrator role required"), + Self::LegacyAdminReserved => f.write_str("legacy admin recording — administrator required"), + Self::IneligibleState => f.write_str("recording is not in an eligible terminal state"), + Self::InvalidPath => f.write_str("recording path/kind is invalid"), + } + } +} + +fn permission_name(p: Permission) -> &'static str { + match p { + Permission::ConfigRead => "config.read", + Permission::ConfigWrite => "config.write", + Permission::SourceRead => "source.read", + Permission::SourceWrite => "source.write", + Permission::UserRead => "user.read", + Permission::UserWrite => "user.write", + Permission::PlaylistRead => "playlist.read", + Permission::PlaylistWrite => "playlist.write", + Permission::LibraryRead => "library.read", + Permission::LibraryWrite => "library.write", + Permission::SystemRead => "system.read", + Permission::SystemWrite => "system.write", + Permission::EpgRead => "epg.read", + Permission::EpgWrite => "epg.write", + Permission::DownloadRead => "download.read", + Permission::DownloadWrite => "download.write", + Permission::RecordingRead => "recording.read", + Permission::RecordingWrite => "recording.write", + } +} + +/// Resolved view of the recording. Constructed by the caller from the +/// runtime `FileDownload` and the new `RecordingService` so the policy +/// stays pure and testable. +#[derive(Debug, Clone)] +pub struct RecordingSubject<'a> { + pub metadata: Option<&'a RecordingMetadata>, + /// The terminal state the recording is currently in. Recorded for + /// state-aware actions (e.g., retention delete). + pub state: TerminalState, + /// Whether the recording carries a valid partial/final path under + /// the recording root. Used to fail closed on path anomalies. + pub path_valid: bool, +} + +/// Coarse terminal state for the policy. The retention-delete path +/// is the only one that needs to know whether the recording is in an +/// eligible terminal state. Other actions look at `metadata` and the +/// principal directly. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalState { + Active, + Scheduled, + Completed, + Failed, + Cancelled, + /// The recording is currently in the two-phase deletion flow. The + /// caller has already begun deletion (`Deleting`); only `system- + /// retention-delete` is meaningful here, and only as a no-op. + Deleting, +} + +impl TerminalState { + /// `true` when the recording is in a terminal state that is + /// eligible for retention delete. Only the completed, failed + /// and cancelled states qualify. + pub fn is_eligible_for_retention(&self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Cancelled) + } +} + +impl<'a> RecordingSubject<'a> { + /// Build a subject from a `RecordingMetadata` and a state. + pub fn new(metadata: Option<&'a RecordingMetadata>, state: TerminalState, path_valid: bool) -> Self { + Self { metadata, state, path_valid } + } +} + +/// Result of a policy check. The HTTP layer maps `Allow` to 200 and +/// `Deny` to 403 (or 404 when the deny reason would leak existence). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordingDecision { + Allow, + Deny(DenyReason), +} + +impl RecordingDecision { + pub fn is_allow(&self) -> bool { matches!(self, Self::Allow) } +} + +fn is_admin(claims: &Claims) -> bool { + claims.roles.iter().any(|r| r == ROLE_ADMIN) +} + +/// The `username` of the synthetic `Claims` the retention worker +/// builds to act on shared / orphan / legacy-owned recordings. +/// +/// The DVR runs three background operations that cannot wait for a +/// human to log in: retention sweeps, disk-pressure sweeps, and the +/// notification outbox. They all go through the same authorization +/// gates a user would, so the synthetic principal must (a) look +/// sufficiently administrative for those gates to open, and (b) be +/// trivially recognisable so any future policy addition can decide +/// whether the bypass applies. +pub const SYSTEM_PRINCIPAL_USERNAME: &str = "recording-supervisor"; + +/// `true` when `claims` was minted by the retention supervisor itself +/// rather than by an authenticating user. +/// +/// A future check on, for example, `pwd_version` or +/// `permission_schema_version` must short-circuit for the system +/// principal; otherwise the background workers silently lose access +/// the moment a stricter policy lands. The retention-side checks below +/// call this early so adding such a policy does not regress the +/// background workers. +pub fn is_system_principal(claims: &Claims) -> bool { + // Both checks are required: `username` alone would let any user who + // registers with the sentinel name forge a bypass, and `subject_id` + // alone would not survive a future auth refactor that issues the + // builtin admin subject_id to a real account. The supervisor is + // the only place that mints a Claims with both sentinel values. + claims.username == SYSTEM_PRINCIPAL_USERNAME + && claims.subject_id.as_ref().is_some_and(UserId::is_builtin_admin) +} + +fn check_create(claims: &Claims, action: RecordingAction) -> RecordingDecision { + match action { + RecordingAction::CreatePrivate => { + if !has_recording_write(claims) { + return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); + } + RecordingDecision::Allow + } + RecordingAction::CreateShared => { + if !has_recording_write(claims) { + return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); + } + if !is_admin(claims) { + return RecordingDecision::Deny(DenyReason::NotAdministrator); + } + RecordingDecision::Allow + } + _ => RecordingDecision::Deny(DenyReason::InvalidPath), + } +} + +fn owner_of(meta: &RecordingMetadata) -> Option { + use shared::model::RecordingOwner; + match &meta.owner { + RecordingOwner::User(uid) => Some(uid.clone()), + RecordingOwner::LegacyAdmin => None, + } +} + +fn is_visibility(meta: &RecordingMetadata, want: RecordingVisibility) -> bool { + meta.visibility == want +} + +fn has_recording_read(claims: &Claims) -> bool { + claims.permissions.contains(Permission::RecordingRead) +} + +fn has_recording_write(claims: &Claims) -> bool { + claims.permissions.contains(Permission::RecordingWrite) +} + +/// The principal decision. Pure function — does not touch the +/// filesystem, the network, or the queue. +/// +/// `subject_id` is the resolved `UserId` of the caller. It is required +/// — callers without a `subject_id` must re-authenticate first +/// (see `AuthError::MissingSubject` in `authenticator`). +pub fn authorize( + claims: &Claims, + subject_id: &UserId, + action: RecordingAction, + recording: &RecordingSubject<'_>, +) -> RecordingDecision { + // The synthetic supervisor principal is the only legitimate caller + // of `SystemRetentionDelete`. Allow it before the policy checks so + // adding a stricter rule later (e.g., one that consults + // `pwd_version`) does not silently break the background workers. + if matches!(action, RecordingAction::SystemRetentionDelete) && is_system_principal(claims) { + return RecordingDecision::Allow; + } + // System retention can delete a Completed/Failed/Cancelled + // recording on behalf of any user, including when the owner + // cannot be resolved (e.g., a corrupted registry entry). The + // bypass is narrow: state must be eligible, path must be valid, + // and the caller must carry `recording.write`. + if matches!(action, RecordingAction::SystemRetentionDelete) { + if !recording.state.is_eligible_for_retention() { + return RecordingDecision::Deny(DenyReason::IneligibleState); + } + if !recording.path_valid { + return RecordingDecision::Deny(DenyReason::InvalidPath); + } + if !has_recording_write(claims) { + return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); + } + if !is_admin(claims) { + // The system-retention path bypasses user ownership only + // for eligible completed recording deletion. Operators + // without the + // admin role are still subject to it; the call site + // (retention worker) is the only legitimate caller. + return RecordingDecision::Deny(DenyReason::NotAdministrator); + } + return RecordingDecision::Allow; + } + + // From here on, every action is user-driven. + // Create actions and manage-rule do not require an existing + // recording; the caller is proposing a new one. The principal's + // permission and (for shared) administrator role are the only + // gates. + match action { + RecordingAction::CreatePrivate | RecordingAction::CreateShared => { + return check_create(claims, action); + } + RecordingAction::ManageRule => { + if !has_recording_write(claims) { + return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); + } + if !is_admin(claims) { + return RecordingDecision::Deny(DenyReason::NotAdministrator); + } + return RecordingDecision::Allow; + } + _ => {} + } + + let Some(meta) = recording.metadata else { + return RecordingDecision::Deny(DenyReason::InvalidPath); + }; + if !recording.path_valid { + return RecordingDecision::Deny(DenyReason::InvalidPath); + } + + let is_legacy = matches!(meta.owner, shared::model::RecordingOwner::LegacyAdmin); + + // LegacyAdmin recordings are only accessible to administrators. + if is_legacy && !is_admin(claims) { + return RecordingDecision::Deny(DenyReason::LegacyAdminReserved); + } + if is_legacy && action_requires_owner(action) { + // Even an admin cannot impersonate a LegacyAdmin owner. + return RecordingDecision::Deny(DenyReason::LegacyAdminReserved); + } + + match action { + RecordingAction::Read | RecordingAction::Playback | RecordingAction::Download => { + check_read_action(claims, subject_id, meta, is_legacy) + } + RecordingAction::CreatePrivate | RecordingAction::CreateShared => check_create(claims, action), + RecordingAction::Edit | RecordingAction::Cancel | RecordingAction::Delete => { + check_mutate_action(claims, subject_id, meta) + } + RecordingAction::ManageRule => { + if !has_recording_write(claims) { + return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); + } + if !is_admin(claims) { + return RecordingDecision::Deny(DenyReason::NotAdministrator); + } + RecordingDecision::Allow + } + RecordingAction::SystemRetentionDelete => RecordingDecision::Allow, + } +} + +fn check_read_action( + claims: &Claims, + subject_id: &UserId, + meta: &RecordingMetadata, + is_legacy: bool, +) -> RecordingDecision { + if !has_recording_read(claims) { + return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingRead)); + } + if is_visibility(meta, RecordingVisibility::Private) { + if is_legacy { + // LegacyAdmin is never owner-readable by a non-admin (already + // rejected at the policy boundary). This branch is + // unreachable when is_legacy=true; the redundant check + // documents the invariant. + return RecordingDecision::Deny(DenyReason::LegacyAdminReserved); + } + if owner_of(meta).as_ref() == Some(subject_id) { + return RecordingDecision::Allow; + } + return RecordingDecision::Deny(DenyReason::NotOwner); + } + // Shared visibility: any principal with recording.read. + RecordingDecision::Allow +} + +fn check_mutate_action( + claims: &Claims, + subject_id: &UserId, + meta: &RecordingMetadata, +) -> RecordingDecision { + if !has_recording_write(claims) { + return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)); + } + if is_visibility(meta, RecordingVisibility::Private) { + if owner_of(meta).as_ref() == Some(subject_id) { + return RecordingDecision::Allow; + } + return RecordingDecision::Deny(DenyReason::NotOwner); + } + // Shared visibility: only admins can mutate ("shared mutation + // only to administrators with recording.write"). + if !is_admin(claims) { + return RecordingDecision::Deny(DenyReason::NotAdministrator); + } + RecordingDecision::Allow +} + +fn action_requires_owner(action: RecordingAction) -> bool { + matches!( + action, + RecordingAction::Edit + | RecordingAction::Cancel + | RecordingAction::Delete + | RecordingAction::ManageRule + ) +} + +/// Separate policy for orphan catalog entries. The catalog is a +/// directory of recordings that no longer match a configured +/// target/input or whose owner cannot be resolved. Orphans are +/// visible only to administrators with the required permission; +/// non-admin callers — even the real owner — cannot re-claim +/// an orphan. +pub fn authorize_orphan(claims: &Claims) -> RecordingDecision { + // The synthetic supervisor principal is the only legitimate + // background reader of the orphan catalog. Allow it before the + // policy checks so adding a stricter rule later does not silently + // break the retention / reconciliation workers. + if is_system_principal(claims) { + return RecordingDecision::Allow; + } + if !is_admin(claims) { + return RecordingDecision::Deny(DenyReason::NotAdministrator); + } + if !has_recording_read(claims) { + return RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingRead)); + } + RecordingDecision::Allow +} + +/// Re-export the deletion-prior-state helper so the policy can use it +/// when reasoning about which path the retention delete must remove. +#[cfg(test)] +mod tests { + use super::*; + use shared::model::recording::{AiringStatus, EpgEpisodeMetadata, RecordingOwner}; + use shared::model::{Claims, CURRENT_PERMISSION_SCHEMA_VERSION, UserId}; + + fn make_claims( + username: &str, + subject: Option, + roles: Vec<&str>, + perms: shared::model::permission::PermissionSet, + ) -> Claims { + let roles: Vec = roles.into_iter().map(String::from).collect(); + Claims { + username: username.to_string(), + iss: "tuliprox".to_string(), + iat: 0, + exp: 0, + roles, + permissions: perms, + pwd_version: 0, + subject_id: subject, + permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION, + } + } + + fn make_meta(owner: RecordingOwner, visibility: RecordingVisibility) -> RecordingMetadata { + RecordingMetadata { + owner, + visibility, + source: None, + program_start: None, + program_end: None, + scheduled_start: None, + scheduled_end: None, + pre_roll_secs: 0, + post_roll_secs: 0, + channel_id: None, + channel_name: None, + program_title: None, + epg: Some(EpgEpisodeMetadata { + programme_id: None, + series_id: None, + episode_id: None, + season: None, + episode: None, + airing: AiringStatus::Unknown, + }), + provenance: shared::model::recording::RecordingProvenance::default(), + relative_path: Some("pilot.ts".to_string()), + partial_relative_path: None, + reserved_bytes: 0, + measured_bytes: 0, + completed_at: None, + notification_markers: Vec::new(), + deleting_previous_state: None, + } + } + + fn owner_meta(uid: &str, visibility: RecordingVisibility) -> RecordingMetadata { + make_meta(RecordingOwner::User(UserId::from(uid)), visibility) + } + + fn legacy_meta() -> RecordingMetadata { + make_meta(RecordingOwner::LegacyAdmin, RecordingVisibility::Private) + } + + fn subject(uid: &str) -> UserId { UserId::from(uid) } + + fn read_perms() -> shared::model::permission::PermissionSet { + Permission::RecordingRead.into() + } + fn write_perms() -> shared::model::permission::PermissionSet { + Permission::RecordingWrite.into() + } + fn read_write_perms() -> shared::model::permission::PermissionSet { + Permission::RecordingRead | Permission::RecordingWrite + } + fn config_read_perms() -> shared::model::permission::PermissionSet { + Permission::ConfigRead.into() + } + + // --- read / playback / download --- + + #[test] + fn read_private_owner_is_allowed() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], read_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Read, &sub); + assert!(d.is_allow(), "expected allow, got {d:?}"); + } + + #[test] + fn read_private_non_owner_is_denied() { + let claims = make_claims("bob", Some(subject("web:bob")), vec!["WEB"], read_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Read, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::NotOwner))); + } + + #[test] + fn read_shared_with_read_perm_is_allowed() { + let claims = make_claims("bob", Some(subject("web:bob")), vec!["WEB"], read_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Shared); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Read, &sub); + assert!(d.is_allow()); + } + + #[test] + fn read_without_recording_read_perm_is_denied() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], config_read_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Read, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingRead)))); + } + + #[test] + fn read_legacy_admin_recording_requires_administrator() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], read_perms()); + let meta = legacy_meta(); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Read, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::LegacyAdminReserved))); + } + + // --- create --- + + #[test] + fn create_private_with_recording_write_is_allowed() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let sub = RecordingSubject::new(None, TerminalState::Active, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::CreatePrivate, &sub); + assert!(d.is_allow()); + } + + #[test] + fn create_shared_requires_administrator_role() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], read_write_perms()); + let sub = RecordingSubject::new(None, TerminalState::Active, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::CreateShared, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::NotAdministrator))); + } + + #[test] + fn create_shared_with_admin_role_and_recording_write_is_allowed() { + let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], read_write_perms()); + let sub = RecordingSubject::new(None, TerminalState::Active, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::CreateShared, &sub); + assert!(d.is_allow()); + } + + // --- edit / cancel / delete --- + + #[test] + fn edit_private_owner_with_recording_write_is_allowed() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Scheduled, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Edit, &sub); + assert!(d.is_allow()); + } + + #[test] + fn edit_private_non_owner_is_denied() { + let claims = make_claims("bob", Some(subject("web:bob")), vec!["WEB"], write_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Scheduled, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Edit, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::NotOwner))); + } + + #[test] + fn edit_shared_requires_administrator() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Shared); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Scheduled, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Edit, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::NotAdministrator))); + } + + #[test] + fn delete_shared_administrator_is_allowed() { + let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let meta = make_meta(RecordingOwner::User(UserId::from("web:alice")), RecordingVisibility::Shared); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::Delete, &sub); + assert!(d.is_allow()); + } + + // --- manage rule --- + + #[test] + fn manage_rule_requires_administrator_and_recording_write() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let sub = RecordingSubject::new(None, TerminalState::Active, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::ManageRule, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::NotAdministrator))); + + let admin = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let d = authorize(&admin, admin.subject_id.as_ref().expect("test subject_id present"), RecordingAction::ManageRule, &sub); + assert!(d.is_allow()); + } + + // --- system retention delete --- + + #[test] + fn system_retention_delete_requires_completed_or_failed_or_cancelled_state() { + let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub_active = RecordingSubject::new(Some(&meta), TerminalState::Active, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::SystemRetentionDelete, &sub_active); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::IneligibleState))); + let sub_deleting = RecordingSubject::new(Some(&meta), TerminalState::Deleting, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::SystemRetentionDelete, &sub_deleting); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::IneligibleState))); + } + + #[test] + fn system_retention_delete_bypasses_owner_for_eligible_completed_recording() { + // Real owner is `web:alice`; the caller is `web:admin` (the + // built-in admin). The policy must allow the delete even + // though the caller is not the owner — this is the + // owner-bypass. + let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::SystemRetentionDelete, &sub); + assert!(d.is_allow(), "retention delete must bypass owner for eligible Completed; got {d:?}"); + } + + #[test] + fn system_retention_delete_requires_recording_write() { + let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], read_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::SystemRetentionDelete, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingWrite)))); + } + + #[test] + fn system_retention_delete_requires_administrator_role() { + let claims = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], write_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::SystemRetentionDelete, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::NotAdministrator))); + } + + #[test] + fn system_retention_delete_fails_closed_on_invalid_path() { + let claims = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], write_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, false); + let d = authorize(&claims, claims.subject_id.as_ref().expect("test subject_id present"), RecordingAction::SystemRetentionDelete, &sub); + assert!(matches!(d, RecordingDecision::Deny(DenyReason::InvalidPath))); + } + + // --- unknown subject --- + + #[test] + fn unknown_subject_is_rejected() { + // The policy itself does not surface `UnknownSubject` — the + // authenticator's `validate_token_version` is the gate. The + // policy here is exercised defensively: it does not panic when + // the claims carry no subject_id; the resolved subject is the + // argument the caller provides. The test confirms the policy + // honors the resolved subject (the caller is the owner → Allow). + let claims = make_claims("alice", None, vec!["WEB"], read_perms()); + let meta = owner_meta("web:alice", RecordingVisibility::Private); + let sub = RecordingSubject::new(Some(&meta), TerminalState::Completed, true); + let d = authorize(&claims, &subject("web:alice"), RecordingAction::Read, &sub); + assert!(d.is_allow(), "owner with subject_id should be allowed; got {d:?}"); + } + + // --- orphans --- + + #[test] + fn orphan_visibility_requires_administrator_and_recording_read() { + let web = make_claims("alice", Some(subject("web:alice")), vec!["WEB"], read_perms()); + assert!(matches!(authorize_orphan(&web), RecordingDecision::Deny(DenyReason::NotAdministrator))); + + let admin = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], config_read_perms()); + assert!(matches!(authorize_orphan(&admin), RecordingDecision::Deny(DenyReason::MissingPermission(Permission::RecordingRead)))); + + let admin_ok = make_claims("admin", Some(UserId::builtin_admin()), vec!["ADMIN"], read_perms()); + assert!(authorize_orphan(&admin_ok).is_allow()); + } + + // --- terminal state predicate --- + + #[test] + fn terminal_state_is_eligible_for_retention() { + assert!(TerminalState::Completed.is_eligible_for_retention()); + assert!(TerminalState::Failed.is_eligible_for_retention()); + assert!(TerminalState::Cancelled.is_eligible_for_retention()); + assert!(!TerminalState::Active.is_eligible_for_retention()); + assert!(!TerminalState::Scheduled.is_eligible_for_retention()); + assert!(!TerminalState::Deleting.is_eligible_for_retention()); + } + + #[test] + fn system_principal_recognised_by_username() { + // The sentinel is a constant for exactly one reason: the bypass + // in `authorize` / `authorize_orphan` has to agree with the + // `Claims` the supervisor mints, and the agreement has to be + // verifiable without touching the supervisor module. + let mut perms = shared::model::permission::PermissionSet::new(); + perms.set(Permission::RecordingWrite); + perms.set(Permission::RecordingRead); + let claims = make_claims( + SYSTEM_PRINCIPAL_USERNAME, + Some(UserId::builtin_admin()), + vec![ROLE_ADMIN], + perms, + ); + assert!(is_system_principal(&claims)); + } + + #[test] + fn an_admin_user_is_not_a_system_principal() { + // Same role, same permissions — but the username does not + // match. Without this distinction a forged `username` field + // would silently elevate the caller to a bypass path. + let mut perms = shared::model::permission::PermissionSet::new(); + perms.set(Permission::RecordingWrite); + perms.set(Permission::RecordingRead); + let claims = make_claims( + "alice", + Some(UserId::builtin_admin()), + vec![ROLE_ADMIN], + perms, + ); + assert!(!is_system_principal(&claims)); + } + + #[test] + fn system_retention_delete_allows_the_supervisor_principal() { + // The supervisor is the only legitimate caller. Even with an + // ineligible state (which would normally deny), the bypass + // short-circuits before the policy checks — that is the whole + // point: future policy additions must not silently break the + // background workers. + let mut perms = shared::model::permission::PermissionSet::new(); + perms.set(Permission::RecordingWrite); + perms.set(Permission::RecordingRead); + let claims = make_claims( + SYSTEM_PRINCIPAL_USERNAME, + Some(UserId::builtin_admin()), + vec![ROLE_ADMIN], + perms, + ); + let meta = make_meta(RecordingOwner::User(UserId::from("web:alice")), RecordingVisibility::Private); + let subject = RecordingSubject::new(Some(&meta), TerminalState::Active, false); + assert_eq!( + authorize(&claims, &UserId::builtin_admin(), RecordingAction::SystemRetentionDelete, &subject), + RecordingDecision::Allow + ); + } + + #[test] + fn system_principal_orphan_allow_short_circuits_admin_check() { + // The bypass exists so a future stricter rule does not silently + // lock the retention worker out of the orphan catalog. Lock in + // the behaviour with a no-permission / no-admin system caller. + let perms = shared::model::permission::PermissionSet::new(); + let claims = make_claims(SYSTEM_PRINCIPAL_USERNAME, Some(UserId::builtin_admin()), vec![], perms); + assert_eq!(authorize_orphan(&claims), RecordingDecision::Allow); + } +} diff --git a/backend/src/iptv/m3u/catchup.rs b/backend/src/iptv/m3u/catchup.rs index 35a42389d..e16d317e3 100644 --- a/backend/src/iptv/m3u/catchup.rs +++ b/backend/src/iptv/m3u/catchup.rs @@ -91,8 +91,10 @@ fn parse_template(template: &str) -> Vec { }; let Some((placeholder_start, open_len)) = start else { - current.push(bytes[idx] as char); - idx += 1; + // Push the full char; pushing single bytes as chars corrupts multi-byte UTF-8 + let ch_len = template[idx..].chars().next().map_or(1, char::len_utf8); + current.push_str(&template[idx..idx + ch_len]); + idx += ch_len; continue; }; diff --git a/backend/src/library/classifier.rs b/backend/src/library/classifier.rs index ae88b5c07..d94fc0162 100644 --- a/backend/src/library/classifier.rs +++ b/backend/src/library/classifier.rs @@ -10,6 +10,14 @@ pub enum MediaClassification { Movie { metadata: PttMetadata }, + /// DVR recording. Recordings are routed to a dedicated DVR + /// section rather than the movie/series catalog. The + /// `file_name` is the canonical file name; the `display_title` + /// is the parsed title for the catalog UI. + Recording { + file_name: String, + display_title: String, + }, Series { key: SeriesKey, episode: u32, @@ -41,7 +49,21 @@ impl MediaClassifier { let file_name = &file.file_name; let ptt_metadata = ptt_parse_title(file_name); + // Exclude recordings from movie/series classifiers and + // global user-independent target caches. Recordings are + // not fed through the classifier at all — they have their + // own catalog projection. + if file.content_type == LibraryContentType::Recording { + return Self::classify_as_recording(file, &ptt_metadata); + } + match file.content_type { + LibraryContentType::Recording => { + // Early-returned above; explicit arm for type-checker + // exhaustiveness (the if-return is preserved to avoid + // a second pass through the match). + Self::classify_as_recording(file, &ptt_metadata) + } LibraryContentType::Auto => { // Auto-detection based on filename patterns Self::classify_as_series_or_movie(ptt_metadata) @@ -92,6 +114,23 @@ impl MediaClassifier { } } + /// Classifier for `LibraryContentType::Recording`. Recordings + /// are not fed through the movie/series classifier and not + /// registered as global user-independent target caches. The + /// frontend route instead reads the recording catalog + /// projection (T19) directly. The minimum here: produce a stable + /// `Recording` classification that the existing library machinery + /// can route to a dedicated DVR section. + fn classify_as_recording( + file: &ScannedMediaFile, + ptt_metadata: &PttMetadata, + ) -> MediaClassification { + MediaClassification::Recording { + file_name: file.file_name.clone(), + display_title: ptt_metadata.title.clone(), + } + } + fn make_series(episode: u32, season: u32, ptt_metadata: PttMetadata) -> MediaClassification { MediaClassification::Series { key: SeriesKey { @@ -148,6 +187,7 @@ mod tests { MediaClassification::Movie { .. } => { panic!("Expected Series classification"); } + MediaClassification::Recording { .. } => panic!("Expected Series classification"), MediaClassification::Series { key, episode, season, metadata, .. } => { assert_eq!(key.title, "Breaking Bad"); assert_eq!(episode, 1); @@ -168,6 +208,7 @@ mod tests { assert_eq!(metadata.year, Some(1999)); assert_eq!(metadata.extension, Some("mkv".to_string())); } + MediaClassification::Recording { .. } => panic!("Expected Movie classification"), MediaClassification::Series { .. } => { panic!("Expected Movie classification"); } @@ -185,6 +226,7 @@ mod tests { assert_eq!(metadata.year, None); assert_eq!(metadata.extension, Some("mkv".to_string())); } + MediaClassification::Recording { .. } => panic!("Expected Movie classification"), MediaClassification::Series { .. } => { panic!("Expected Movie classification"); } @@ -210,7 +252,9 @@ mod tests { assert_eq!(season, 2); assert_eq!(episode, 5); } - MediaClassification::Movie { .. } => panic!("Expected Series classification"), + MediaClassification::Movie { .. } | MediaClassification::Recording { .. } => { + panic!("Expected Series classification"); + } } // Counter should record next episode after the parsed one (key is lowercased) assert_eq!(counters[&("breaking bad".to_string(), 2)], 6); @@ -259,15 +303,15 @@ mod tests { // ShowA and ShowB each start at episode 1 independently match ca1 { MediaClassification::Series { episode, .. } => assert_eq!(episode, 1), - MediaClassification::Movie { .. } => panic!("Expected Series"), + MediaClassification::Movie { .. } | MediaClassification::Recording { .. } => panic!("Expected Series"), } match cb1 { MediaClassification::Series { episode, .. } => assert_eq!(episode, 1), - MediaClassification::Movie { .. } => panic!("Expected Series"), + MediaClassification::Movie { .. } | MediaClassification::Recording { .. } => panic!("Expected Series"), } match ca2 { MediaClassification::Series { episode, .. } => assert_eq!(episode, 2), - MediaClassification::Movie { .. } => panic!("Expected Series"), + MediaClassification::Movie { .. } | MediaClassification::Recording { .. } => panic!("Expected Series"), } } diff --git a/backend/src/library/metadata_resolver.rs b/backend/src/library/metadata_resolver.rs index c3b439b9e..ec1d3bfdf 100644 --- a/backend/src/library/metadata_resolver.rs +++ b/backend/src/library/metadata_resolver.rs @@ -305,6 +305,7 @@ mod tests { let file = create_test_file("The.Matrix.1999.1080p.mkv"); let metadata = match MediaClassifier::classify(&file, &mut std::collections::HashMap::new()) { MediaClassification::Movie { metadata, .. } | MediaClassification::Series { metadata, .. } => metadata, + MediaClassification::Recording { .. } => panic!("Did not expect Recording classification"), }; let group = MediaGroup::Movie { file, metadata: Box::new(metadata) }; @@ -366,6 +367,7 @@ mod tests { let file = create_test_file("343jfkjh4789dkjfh934z3.Movie.mkv"); let metadata = match MediaClassifier::classify(&file, &mut std::collections::HashMap::new()) { MediaClassification::Movie { metadata, .. } | MediaClassification::Series { metadata, .. } => metadata, + MediaClassification::Recording { .. } => panic!("Did not expect Recording classification"), }; let group = MediaGroup::Movie { file, metadata: Box::new(metadata) }; diff --git a/backend/src/library/metadata_storage.rs b/backend/src/library/metadata_storage.rs index d01fc19b4..45dafdab6 100644 --- a/backend/src/library/metadata_storage.rs +++ b/backend/src/library/metadata_storage.rs @@ -1,5 +1,5 @@ use crate::library::metadata::{MediaMetadata, MetadataCacheEntry}; -use log::{debug, error, info}; +use log::{debug, error, info, warn}; use path_clean::PathClean; use std::collections::{HashMap, HashSet}; use std::fmt::Write; @@ -159,7 +159,9 @@ impl MetadataStorage { let path = self.get_thumbnail_path(hash); if fs::try_exists(&path).await.unwrap_or(false) { debug!("Deleting thumbnail file: {}", path.display()); - let _ = fs::remove_file(path).await; + if let Err(err) = fs::remove_file(&path).await { + warn!("Failed to delete thumbnail {}: {err}", path.display()); + } } } @@ -219,7 +221,9 @@ impl MetadataStorage { let path = self.get_thumbnail_path(&thumbnail_id); if fs::try_exists(&path).await.unwrap_or(false) { debug!("Deleting thumbnail file: {}", path.display()); - let _ = fs::remove_file(path).await; + if let Err(err) = fs::remove_file(&path).await { + warn!("Failed to delete thumbnail {}: {err}", path.display()); + } } } @@ -287,7 +291,9 @@ impl MetadataStorage { if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { if !referenced.contains(stem) && !is_recent_thumbnail(&path).await { debug!("Removing orphaned thumbnail: {}", path.display()); - let _ = fs::remove_file(&path).await; + if let Err(err) = fs::remove_file(&path).await { + warn!("Failed to remove orphaned thumbnail {}: {err}", path.display()); + } } } } diff --git a/backend/src/library/scanner.rs b/backend/src/library/scanner.rs index 6114842cb..f7a038e3d 100644 --- a/backend/src/library/scanner.rs +++ b/backend/src/library/scanner.rs @@ -85,6 +85,12 @@ impl MediaGrouper { metadata: Box::new(metadata), }); } + // Recordings are routed to a dedicated DVR section + // by the recording catalog projection. The scanner + // passes them through + // unchanged; the frontend reads the dedicated + // catalog. + MediaClassification::Recording { .. } => {} } } diff --git a/backend/src/main.rs b/backend/src/main.rs index 57f3e9196..b7f0621e8 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -3,6 +3,7 @@ #![allow(clippy::must_use_candidate)] #![allow(clippy::return_self_not_must_use)] #![allow(clippy::missing_errors_doc)] +#![allow(clippy::large_futures)] // #[cfg(target_os = "linux")] // #[global_allocator] @@ -148,6 +149,7 @@ const BUILD_TIMESTAMP: &str = env!("VERGEN_BUILD_TIMESTAMP"); #[tokio::main] async fn main() { + api::api_utils::init_uptime_clock(); let args = Args::parse(); db_viewer(&args.db_viewer_args()); diff --git a/backend/src/messaging.rs b/backend/src/messaging.rs index 931dd72f9..9602afa23 100644 --- a/backend/src/messaging.rs +++ b/backend/src/messaging.rs @@ -17,6 +17,76 @@ fn is_enabled(kind: MsgKind, cfg: &MessagingConfig) -> bool { cfg.notify_on.contains(&kind) } +/// One configured outbound messaging channel. +/// +/// The notification outbox retries per channel, not per message: a +/// message that reached Telegram but not Discord must be re-sent only to +/// Discord, or the retry would deliver a duplicate. Serialized into the +/// outbox file, so the variant names are part of that file's format. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessagingChannel { + Telegram, + Rest, + Pushover, + Discord, +} + +/// Result of one channel send. `None` means "nothing to do" — the +/// channel is not configured, or this message kind is filtered out — and +/// must never be retried. +pub type ChannelOutcome = Option; + +/// The channels currently configured for `kind`, in a stable order. +pub fn configured_channels(app_config: &Arc, kind: MsgKind) -> Vec { + let cfg = app_config.config.load(); + let Some(messaging) = cfg.messaging.as_ref() else { + return Vec::new(); + }; + if !is_enabled(kind, messaging) { + return Vec::new(); + } + let mut channels = Vec::with_capacity(4); + if messaging.telegram.is_some() { + channels.push(MessagingChannel::Telegram); + } + if messaging.rest.is_some() { + channels.push(MessagingChannel::Rest); + } + if messaging.pushover.is_some() { + channels.push(MessagingChannel::Pushover); + } + if messaging.discord.is_some() { + channels.push(MessagingChannel::Discord); + } + channels +} + +/// Send `content` to exactly one channel and report whether it landed. +/// +/// `send_message` fans out to every channel and swallows the outcome, +/// which is fine for fire-and-forget notifications but leaves a +/// recording-lifecycle event unrecoverable after a transient provider +/// error. This is the entry point the outbox worker drives. +pub async fn send_message_to_channel( + app_config: &Arc, + client: &reqwest::Client, + content: &MessageContent, + channel: MessagingChannel, +) -> ChannelOutcome { + let cfg = app_config.config.load(); + let messaging = cfg.messaging.as_ref()?; + if !is_enabled(content.kind(), messaging) { + return None; + } + match channel { + MessagingChannel::Telegram => send_telegram_message(app_config, client, content, messaging).await, + MessagingChannel::Rest => send_rest_message(app_config, client, content, messaging).await, + MessagingChannel::Pushover => send_pushover_message(app_config, client, content, messaging).await, + MessagingChannel::Discord => send_discord_message(app_config, client, content, messaging).await, + } +} + /// Default fallback string for a disk alert when no template is configured. fn default_disk_alert_text(alert: &DiskAlert) -> String { format!( @@ -54,6 +124,7 @@ async fn render_template(app_config: &Arc, http_client: &reqwest::Cli watch: None, processing: None, disk: None, + recording: None, flat_stats: None, }; @@ -81,6 +152,15 @@ async fn render_template(app_config: &Arc, http_client: &reqwest::Cli MessageContent::DiskAlert(alert) => { template_context.disk = Some(alert); } + MessageContent::RecordingLifecycle(recording) => { + template_context.recording = Some(recording); + template_context.message = Some(match recording.event { + MsgKind::RecordingStarted => "Recording started", + MsgKind::RecordingCompleted => "Recording completed", + MsgKind::RecordingFailed => "Recording failed", + _ => "Recording lifecycle event", + }); + } } match template { @@ -105,10 +185,26 @@ fn default_text_for(content: &MessageContent) -> String { MessageContent::Watch(w) => serde_json::to_string(w).unwrap_or_default(), MessageContent::ProcessingStats(ps) => serde_json::to_string(ps).unwrap_or_default(), MessageContent::DiskAlert(alert) => default_disk_alert_text(alert), + MessageContent::RecordingLifecycle(recording) => default_recording_lifecycle_text(recording), } } -async fn send_rest_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) { +fn default_recording_lifecycle_text(recording: &crate::model::RecordingLifecycleMessage) -> String { + let label = match recording.event { + MsgKind::RecordingStarted => "Recording started", + MsgKind::RecordingCompleted => "Recording completed", + MsgKind::RecordingFailed => "Recording failed", + _ => "Recording lifecycle event", + }; + let title = recording.programme_title.as_deref().unwrap_or("Untitled"); + let channel = recording.channel.as_deref().unwrap_or("unknown channel"); + match recording.failure_reason.as_deref() { + Some(reason) => format!("{label}: {title} on {channel} ({reason})"), + None => format!("{label}: {title} on {channel}"), + } +} + +async fn send_rest_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) -> ChannelOutcome { if let Some(rest) = &messaging.rest { let kind = content.kind(); let template = rest.templates.get(&kind).map(String::as_str); @@ -130,16 +226,23 @@ async fn send_rest_message(app_config: &Arc, client: &reqwest::Client Ok(response) => { if response.status().is_success() { debug!("Message sent successfully to rest api"); + Some(true) } else { error!("Failed to send message to rest api, status code {}", response.status()); + Some(false) } } - Err(e) => error!("Message wasn't sent to rest api because of: {e}"), + Err(e) => { + error!("Message wasn't sent to rest api because of: {e}"); + Some(false) + } } + } else { + None } } -async fn send_discord_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) { +async fn send_discord_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) -> ChannelOutcome { if let Some(discord) = &messaging.discord { let kind = content.kind(); let template = discord.templates.get(&kind).map(String::as_str); @@ -162,16 +265,23 @@ async fn send_discord_message(app_config: &Arc, client: &reqwest::Cli Ok(response) => { if response.status().is_success() { debug!("Message sent successfully to Discord"); + Some(true) } else { error!("Failed to send message to Discord, status code {}", response.status()); + Some(false) } } - Err(e) => error!("Message wasn't sent to Discord because of: {e}"), + Err(e) => { + error!("Message wasn't sent to Discord because of: {e}"); + Some(false) + } } + } else { + None } } -async fn send_telegram_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) { +async fn send_telegram_message(app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) -> ChannelOutcome { if let Some(telegram) = &messaging.telegram { let kind = content.kind(); let template = telegram.templates.get(&kind).map(String::as_str); @@ -192,6 +302,7 @@ async fn send_telegram_message(app_config: &Arc, client: &reqwest::Cl serialized } MessageContent::DiskAlert(alert) => default_disk_alert_text(alert), + MessageContent::RecordingLifecycle(recording) => default_recording_lifecycle_text(recording), } }; @@ -212,22 +323,33 @@ async fn send_telegram_message(app_config: &Arc, client: &reqwest::Cl } }; + // A single failed chat id fails the channel: the outbox retries + // the whole channel, which is the coarsest granularity the + // Telegram config exposes. + let mut all_delivered = true; for chat_id in &telegram.chat_ids { let bot = telegram_create_instance(&telegram.bot_token, chat_id); let send_result = telegram_send_message(app_config, client, &bot, &message, options.as_ref()).await; - if telegram.markdown && has_template && send_result.parse_error && !send_result.delivered { + let mut delivered = send_result.delivered; + if telegram.markdown && has_template && send_result.parse_error && !delivered { // Template output can include dynamic fields that break MarkdownV2. Retry once escaped. let escaped = escape_markdown_v2(&msg); let escaped_options = SendMessageOption { parse_mode: SendMessageParseMode::MarkdownV2, }; - let _ = telegram_send_message(app_config, client, &bot, &escaped, Some(&escaped_options)).await; + delivered = telegram_send_message(app_config, client, &bot, &escaped, Some(&escaped_options)) + .await + .delivered; } + all_delivered &= delivered; } + Some(all_delivered) + } else { + None } } -async fn send_pushover_message(_app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) { +async fn send_pushover_message(_app_config: &Arc, client: &reqwest::Client, content: &MessageContent, messaging: &MessagingConfig) -> ChannelOutcome { if let Some(pushover) = &messaging.pushover { let msg = default_text_for(content); @@ -246,12 +368,19 @@ async fn send_pushover_message(_app_config: &Arc, client: &reqwest::C Ok(response) => { if response.status().is_success() { debug!("Text message sent successfully to PUSHOVER, status code {}", response.status()); + Some(true) } else { error!("Failed to send text message to PUSHOVER, status code {}", response.status()); + Some(false) } } - Err(e) => error!("Text message wasn't sent to PUSHOVER api because of: {e}"), + Err(e) => { + error!("Text message wasn't sent to PUSHOVER api because of: {e}"); + Some(false) + } } + } else { + None } } @@ -261,7 +390,7 @@ async fn dispatch_send_message(app_config: &Arc, client: &reqwest::Cl if let Some(messaging) = msg_cfg { let kind = content.kind(); if is_enabled(kind, messaging) { - tokio::join!( + let _ = tokio::join!( send_telegram_message(app_config, client, &content, messaging), send_rest_message(app_config, client, &content, messaging), send_pushover_message(app_config, client, &content, messaging), diff --git a/backend/src/model/config/api_proxy.rs b/backend/src/model/config/api_proxy.rs index e4f90b292..32aef55ca 100644 --- a/backend/src/model/config/api_proxy.rs +++ b/backend/src/model/config/api_proxy.rs @@ -4,9 +4,14 @@ use crate::utils; use crate::utils::file_exists_async; use arc_swap::access::Access; use arc_swap::ArcSwap; -use log::debug; -use shared::model::{ApiProxyConfigDto, ApiProxyServerInfoDto, ConfigPaths, TargetUserDto}; +use log::{debug, error}; +use shared::foundation::{get_filter, Filter}; +use shared::model::{ + ApiProxyConfigDto, ApiProxyServerInfoDto, ClusterFlags, ConfigPaths, ProxyType, TargetUserDto, UserPlanDto, + UserPlanTrialDto, +}; use std::cmp::PartialEq; +use std::collections::HashMap; use std::io::ErrorKind; use std::sync::Arc; @@ -68,9 +73,66 @@ impl ApiProxyServerInfo { } } +#[derive(Debug, Clone)] +pub struct UserPlan { + pub name: String, + pub output_clusters: Option, + pub proxy: Option, + pub max_connections: u32, + pub soft_connections: u16, + pub filter: Option, + pub trial: Option, + pub comment: Option, + pub t_filter: Option>, + pub t_trial_duration_secs: Option, +} + +macros::from_impl!(UserPlan); +impl From<&UserPlanDto> for UserPlan { + fn from(dto: &UserPlanDto) -> Self { + // The DTO prepare() already validated the filter; a failure here means + // an unprepared DTO, so log and serve without the plan filter. + let t_filter = dto.filter.as_ref().and_then(|raw| match get_filter(raw, None) { + Ok(filter) => Some(Arc::new(filter)), + Err(err) => { + error!("Invalid filter in user plan {}: {err}", dto.name); + None + } + }); + Self { + name: dto.name.clone(), + output_clusters: dto.output_clusters, + proxy: dto.proxy, + max_connections: dto.max_connections, + soft_connections: dto.soft_connections, + filter: dto.filter.clone(), + trial: dto.trial.clone(), + comment: dto.comment.clone(), + t_filter, + t_trial_duration_secs: dto.trial.as_ref().and_then(UserPlanTrialDto::duration_secs), + } + } +} + +impl From<&UserPlan> for UserPlanDto { + fn from(instance: &UserPlan) -> Self { + Self { + name: instance.name.clone(), + output_clusters: instance.output_clusters, + proxy: instance.proxy, + max_connections: instance.max_connections, + soft_connections: instance.soft_connections, + filter: instance.filter.clone(), + trial: instance.trial.clone(), + comment: instance.comment.clone(), + } + } +} + #[derive(Debug, Clone, Default)] pub struct ApiProxyConfig { pub server: Vec, + pub plans: Vec>, pub user: Vec, pub use_user_db: bool, /// HTTP status code for auth failures. 0 means default (403). @@ -80,9 +142,28 @@ pub struct ApiProxyConfig { macros::from_impl!(ApiProxyConfig); impl From<&ApiProxyConfigDto> for ApiProxyConfig { fn from(dto: &ApiProxyConfigDto) -> Self { + // Plans live in plans.yml now and are injected via `set_plans` after load. + let plan_map: HashMap> = HashMap::new(); + let user = dto + .user + .iter() + .map(|target_user| TargetUser { + target: target_user.target.clone(), + credentials: target_user + .credentials + .iter() + .map(|credentials| { + let mut user = ProxyUserCredentials::from(credentials); + user.resolve_plan(&plan_map); + Arc::new(user) + }) + .collect(), + }) + .collect(); Self { server: dto.server.iter().map(ApiProxyServerInfo::from).collect(), - user: dto.user.iter().map(TargetUser::from).collect(), + plans: Vec::new(), + user, use_user_db: dto.use_user_db, auth_error_status: dto.auth_error_status, } @@ -118,6 +199,32 @@ async fn api_proxy_file_would_change(api_proxy_file: &str, config: &ApiProxyConf } impl ApiProxyConfig { + pub fn plan_map(&self) -> HashMap> { + self.plans.iter().map(|plan| (plan.name.clone(), Arc::clone(plan))).collect() + } + + /// Replace the plan set (loaded from plans.yml) and re-resolve every user's + /// inherited capabilities and combined content filter. + pub fn set_plans(&mut self, plans: Vec>) { + self.plans = plans; + let plan_map = self.plan_map(); + for target_user in &mut self.user { + for credentials in &mut target_user.credentials { + Arc::make_mut(credentials).resolve_plan(&plan_map); + } + } + } + + /// Re-resolve plan capabilities for users loaded outside the DTO path (user db). + pub fn resolve_target_users(&self, users: &mut [TargetUser]) { + let plan_map = self.plan_map(); + for target_user in users { + for credentials in &mut target_user.credentials { + Arc::make_mut(credentials).resolve_plan(&plan_map); + } + } + } + async fn backfill_output_clusters_to_file(&self, cfg: &AppConfig, errors: &mut Vec) { if self.user.is_empty() { return; @@ -166,6 +273,8 @@ impl ApiProxyConfig { } match load_api_user(cfg).await { Ok(users) => { + let mut users = users; + self.resolve_target_users(&mut users); self.user = users; } Err(err) => { @@ -180,6 +289,8 @@ impl ApiProxyConfig { // we can't have user defined in db file. // we need to load them and save them into the config file if let Ok(stored_users) = load_api_user(cfg).await { + let mut stored_users = stored_users; + self.resolve_target_users(&mut stored_users); for stored_user in stored_users { if let Some(target_user) = self.user.iter_mut().find(|t| t.target == stored_user.target) { for stored_credential in &stored_user.credentials { diff --git a/backend/src/model/config/api_user.rs b/backend/src/model/config/api_user.rs index c9e89dfbd..11efe6bd5 100644 --- a/backend/src/model/config/api_user.rs +++ b/backend/src/model/config/api_user.rs @@ -1,13 +1,15 @@ use crate::api::model::AppState; -use crate::model::{macros, Config}; +use crate::model::{macros, Config, UserPlan}; use arc_swap::access::Access; use arc_swap::ArcSwap; use chrono::Local; -use log::{debug, warn}; +use log::{debug, error, warn}; +use shared::foundation::{get_filter, BinaryOperator, Filter}; use shared::model::{ ClusterFlags, NetworkAccessDto, ProxyType, ProxyUserCredentialsDto, ProxyUserStatus, TargetUserDto, UserConnectionPermission, XtreamCluster, }; +use std::collections::HashMap; use std::sync::Arc; use zeroize::Zeroize; @@ -18,6 +20,8 @@ pub enum ProxyUserPermissionDenyReason { Banned, ExpiredStatus, Inactive, + UnresolvedPlan, + InvalidFilter, } #[derive(Debug, Clone, Default)] @@ -93,6 +97,7 @@ impl From<&NetworkAccess> for NetworkAccessDto { } #[derive(Debug, Clone, Default)] +#[allow(clippy::struct_excessive_bools)] pub struct ProxyUserCredentials { pub username: String, pub password: String, @@ -113,6 +118,24 @@ pub struct ProxyUserCredentials { pub soft_priority: i8, pub t_is_api_user: bool, pub network_access: Option, + /// Capability tier name; resolved via `resolve_plan`. + pub plan: Option, + /// Raw user-level content filter (DSL); AND-combined with the plan filter. + pub filter: Option, + // Raw configured values (None/0 = inherit from plan). The sibling + // non-raw fields hold the resolved serving values after resolve_plan(); + // persistence (YAML/DB) always writes the raw values so plan edits + // keep propagating to members. + pub raw_output_clusters: Option, + pub raw_max_connections: u32, + pub raw_soft_connections: u16, + pub raw_proxy: Option, + /// Compiled content filter (plan AND user), applied at serve time. + pub t_filter: Option>, + /// True when `plan` references a plan that no longer exists; the user is denied. + pub t_has_unresolved_plan: bool, + /// True when the configured user filter failed to compile; the user is denied. + pub t_has_invalid_filter: bool, } macros::from_impl!(ProxyUserCredentials); @@ -142,6 +165,15 @@ impl From<&ProxyUserCredentialsDto> for ProxyUserCredentials { .as_ref() .map(NetworkAccess::from) .filter(|network_access| !network_access.is_empty()), + plan: dto.plan.clone(), + filter: dto.filter.clone(), + raw_output_clusters: dto.output_clusters, + raw_max_connections: dto.max_connections, + raw_soft_connections: dto.soft_connections, + raw_proxy: if dto.proxy == ProxyType::default() && dto.plan.is_some() { None } else { Some(dto.proxy) }, + t_filter: None, + t_has_unresolved_plan: false, + t_has_invalid_filter: false, } } } @@ -152,35 +184,101 @@ impl From<&ProxyUserCredentials> for ProxyUserCredentialsDto { username: instance.username.clone(), password: instance.password.clone(), token: instance.token.clone(), - proxy: instance.proxy, + proxy: instance.raw_proxy.unwrap_or(instance.proxy), server: instance.server.clone(), epg_timeshift: instance.epg_timeshift.clone(), epg_request_timeshift: instance.epg_request_timeshift.clone(), created_at: instance.created_at, exp_date: instance.exp_date, - max_connections: instance.max_connections, + // Persist raw (pre-plan-resolution) values so plan edits keep propagating. + max_connections: instance.raw_max_connections, status: instance.status, - output_clusters: if instance.output_clusters.is_all() { None } else { Some(instance.output_clusters) }, + output_clusters: instance.raw_output_clusters.filter(|flags| !flags.is_all()), ui_enabled: instance.ui_enabled, comment: instance.comment.clone(), priority: instance.priority, - soft_connections: instance.soft_connections, + soft_connections: instance.raw_soft_connections, soft_priority: instance.soft_priority, network_access: instance.network_access.as_ref().map(NetworkAccessDto::from), + plan: instance.plan.clone(), + filter: instance.filter.clone(), } } } impl ProxyUserCredentials { + /// Fill unset capability values from the referenced plan and compile the + /// combined content filter. Idempotent; call after any load/conversion. + pub fn resolve_plan(&mut self, plans: &HashMap>) { + let plan = self.plan.as_ref().and_then(|name| plans.get(name)); + self.t_has_unresolved_plan = self.plan.is_some() && plan.is_none(); + if self.t_has_unresolved_plan { + error!( + "Unknown user plan {:?} for user {}; access is denied until the plan exists or the reference is removed", + self.plan, self.username + ); + } + self.output_clusters = self + .raw_output_clusters + .or_else(|| plan.and_then(|p| p.output_clusters)) + .unwrap_or_else(ClusterFlags::all); + if let Some(plan_proxy) = plan.and_then(|p| p.proxy) { + self.proxy = self.raw_proxy.unwrap_or(plan_proxy); + } + self.max_connections = if self.raw_max_connections > 0 { + self.raw_max_connections + } else { + plan.map_or(0, |p| p.max_connections) + }; + self.soft_connections = if self.raw_soft_connections > 0 { + self.raw_soft_connections + } else { + plan.map_or(0, |p| p.soft_connections) + }; + + let plan_filter = plan.and_then(|p| p.t_filter.as_ref().map(Arc::clone)); + self.t_has_invalid_filter = false; + let user_filter = self.filter.as_ref().and_then(|raw| match get_filter(raw, None) { + Ok(filter) => Some(Arc::new(filter)), + Err(err) => { + error!( + "Invalid filter for user {}; access is denied until the filter is fixed or removed: {err}", + self.username + ); + self.t_has_invalid_filter = true; + None + } + }); + self.t_filter = match (plan_filter, user_filter) { + // Group both sides so OR expressions keep their intended precedence. + (Some(plan), Some(user)) => Some(Arc::new(Filter::BinaryExpression( + Box::new(Filter::Group(Box::new((*plan).clone()))), + BinaryOperator::And, + Box::new(Filter::Group(Box::new((*user).clone()))), + ))), + (Some(filter), None) | (None, Some(filter)) => Some(filter), + (None, None) => None, + }; + } + + /// True when the compiled content filter (plan AND user) permits this item. + /// Users without a filter see everything. + pub fn allows_content(&self, pli: &shared::model::PlaylistItem) -> bool { + self.t_filter.as_ref().is_none_or(|filter| { + let provider = shared::foundation::ValueProvider { pli, match_as_ascii: false }; + filter.filter(&provider) + }) + } + pub fn matches_token(&self, token: &str) -> bool { if let Some(tkn) = &self.token { - return tkn.eq(token); + return crate::auth::constant_time_eq(tkn.as_bytes(), token.as_bytes()); } false } pub fn matches(&self, username: &str, password: &str) -> bool { - self.username.eq(username) && self.password.eq(password) + self.username.eq(username) && crate::auth::constant_time_eq(self.password.as_bytes(), password.as_bytes()) } #[inline] @@ -194,6 +292,17 @@ impl ProxyUserCredentials { } pub fn permission_denied_reason(&self, app_state: &AppState) -> Option { + // A plan reference that cannot be resolved must never fall back to + // default clusters, unlimited connections and no filter. + if self.t_has_unresolved_plan { + debug!("User access denied, unresolved plan {:?}: {}", self.plan, self.username); + return Some(ProxyUserPermissionDenyReason::UnresolvedPlan); + } + // A filter that fails to compile must deny instead of serving unfiltered content. + if self.t_has_invalid_filter { + debug!("User access denied, invalid filter: {}", self.username); + return Some(ProxyUserPermissionDenyReason::InvalidFilter); + } let config = > as Access>::load(&app_state.app_config.config); if config.user_access_control { if let Some(exp_date) = self.exp_date.as_ref() { @@ -296,3 +405,48 @@ impl TargetUser { .map(|credentials| (Arc::clone(credentials), self.target.as_str())) } } + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::UserPlanDto; + + #[test] + fn test_resolve_plan_inherits_proxy() { + let plan_dto = UserPlanDto { + name: "reverse_plan".to_string(), + proxy: Some(ProxyType::Reverse(Some(ClusterFlags::Live))), + max_connections: 3, + ..Default::default() + }; + let plan = Arc::new(UserPlan::from(&plan_dto)); + let mut plans = HashMap::new(); + plans.insert("reverse_plan".to_string(), plan); + + // User with plan, default redirect proxy (inherited from plan) + let user_dto = ProxyUserCredentialsDto { + username: "alice".to_string(), + password: "123".to_string(), + plan: Some("reverse_plan".to_string()), + proxy: ProxyType::Redirect, + ..Default::default() + }; + let mut user = ProxyUserCredentials::from(&user_dto); + assert_eq!(user.proxy, ProxyType::Redirect); + user.resolve_plan(&plans); + assert_eq!(user.proxy, ProxyType::Reverse(Some(ClusterFlags::Live))); + assert_eq!(user.max_connections, 3); + + // User with explicit reverse proxy override + let user_dto2 = ProxyUserCredentialsDto { + username: "bob".to_string(), + password: "456".to_string(), + plan: Some("reverse_plan".to_string()), + proxy: ProxyType::Reverse(Some(ClusterFlags::Vod)), + ..Default::default() + }; + let mut user2 = ProxyUserCredentials::from(&user_dto2); + user2.resolve_plan(&plans); + assert_eq!(user2.proxy, ProxyType::Reverse(Some(ClusterFlags::Vod))); + } +} diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index 9cb77e1a2..179dabf5f 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -252,6 +252,14 @@ impl Config { set_sanitize_sensitive_info(self.log.as_ref().is_none_or(|l| l.sanitize_sensitive_info)); let temp_path = PathBuf::from(&self.storage_dir).join(DEFAULT_STORAGE_TEMP_DIR); create_directories(self, &temp_path); + // `tempfile::env::override_temp_dir` mutates a process-global + // default. In production that is the point — every tempfile + // helper routes through the configured storage temp dir. In + // tests it poisons every parallel test in the same process: + // the first test to call `update_runtime` wins, and every + // later `tempdir()` from a sibling test lands in that test's + // `storage_dir/tmp/`. Gate the override to non-test builds. + #[cfg(not(test))] let _ = tempfile::env::override_temp_dir(&temp_path); } diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index 02e3cacaf..d6abfd211 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -591,6 +591,13 @@ impl ConfigInput { self.name ))); } + // Keep in sync with shared ConfigInputDto::prepare_media_server_input + if self.provider_configs.as_ref().is_some_and(|providers| !providers.is_empty()) { + return Err(TuliproxError::ConfigInput(format!( + "media-server input does not support provider failover definitions (input: {})", + self.name + ))); + } Ok(()) } diff --git a/backend/src/model/config/messaging.rs b/backend/src/model/config/messaging.rs index b75d0ec20..ca3a6df8a 100644 --- a/backend/src/model/config/messaging.rs +++ b/backend/src/model/config/messaging.rs @@ -244,7 +244,16 @@ impl From<&MessagingConfig> for MessagingConfigDto { } fn discover_templates(prefix: &str, templates: &mut std::collections::HashMap, templates_dir: &Path) { - let variants = [MsgKind::Info, MsgKind::Stats, MsgKind::Error, MsgKind::Watch, MsgKind::DiskAlert]; + let variants = [ + MsgKind::Info, + MsgKind::Stats, + MsgKind::Error, + MsgKind::Watch, + MsgKind::DiskAlert, + MsgKind::RecordingStarted, + MsgKind::RecordingCompleted, + MsgKind::RecordingFailed, + ]; for kind in variants { if let std::collections::hash_map::Entry::Vacant(e) = templates.entry(kind) { let filename = kind.template_filename(prefix); @@ -254,4 +263,4 @@ fn discover_templates(prefix: &str, templates: &mut std::collections::HashMap for HlsCacheConfig { max_concurrent_segment_fetches_global: dto.max_concurrent_segment_fetches_global, origin_manifest_timeout_ms: dto.origin_manifest_timeout_ms, origin_segment_timeout_ms: dto.origin_segment_timeout_ms, + initial_manifest_wait_timeout_secs: dto.initial_manifest_wait_timeout_secs, session_idle_timeout: dto.session_idle_timeout, manifest_recovery_burst: HlsManifestRecoveryBurstConfig::from(&dto.manifest_recovery_burst), segment_repair: HlsSegmentRepairConfig::from(&dto.segment_repair), @@ -343,6 +345,7 @@ impl From<&HlsCacheConfig> for HlsCacheConfigDto { max_concurrent_segment_fetches_global: config.max_concurrent_segment_fetches_global, origin_manifest_timeout_ms: config.origin_manifest_timeout_ms, origin_segment_timeout_ms: config.origin_segment_timeout_ms, + initial_manifest_wait_timeout_secs: config.initial_manifest_wait_timeout_secs, session_idle_timeout: config.session_idle_timeout, manifest_recovery_burst: HlsManifestRecoveryBurstConfigDto::from(&config.manifest_recovery_burst), segment_repair: HlsSegmentRepairConfigDto::from(&config.segment_repair), @@ -510,6 +513,7 @@ mod tests { max_concurrent_segment_fetches_global: 64, origin_manifest_timeout_ms: 3_000, origin_segment_timeout_ms: 10_000, + initial_manifest_wait_timeout_secs: 90, session_idle_timeout: 300, manifest_recovery_burst: HlsManifestRecoveryBurstConfig::default(), segment_repair: HlsSegmentRepairConfig { diff --git a/backend/src/model/config/sort.rs b/backend/src/model/config/sort.rs index 653e62338..13a2d2fb1 100644 --- a/backend/src/model/config/sort.rs +++ b/backend/src/model/config/sort.rs @@ -10,6 +10,7 @@ use crate::model::macros; pub struct ConfigSortRule { pub target: SortTarget, pub order: SortOrder, + pub natural: bool, pub field: ItemField, pub sequence: Option>>, pub filter: Filter, @@ -21,6 +22,7 @@ impl From<&ConfigSortRuleDto> for ConfigSortRule { Self { target: dto.target, order: dto.order, + natural: dto.natural, field: dto.field, sequence: dto.t_sequence.clone(), filter: dto.t_filter.clone().unwrap_or_default(), @@ -33,6 +35,7 @@ impl From<&ConfigSortRule> for ConfigSortRuleDto { Self { target: instance.target, order: instance.order, + natural: instance.natural, field: instance.field, sequence: instance.sequence.as_ref().map(|l: &Vec>| l.iter().map(ToString::to_string).collect()), filter: instance.filter.to_string(), diff --git a/backend/src/model/config/stream.rs b/backend/src/model/config/stream.rs index 35a82aaf9..ba20d5b4a 100644 --- a/backend/src/model/config/stream.rs +++ b/backend/src/model/config/stream.rs @@ -8,6 +8,7 @@ use crate::model::macros; pub struct StreamBufferConfig { pub enabled: bool, pub size: usize, + pub max_bytes_mb: u64, } macros::from_impl!(StreamBufferConfig); @@ -16,6 +17,7 @@ impl From<&StreamBufferConfigDto> for StreamBufferConfig { Self { enabled: dto.enabled, size: dto.size, + max_bytes_mb: dto.max_bytes_mb, } } } @@ -25,6 +27,7 @@ impl From<&StreamBufferConfig> for StreamBufferConfigDto { Self { enabled: dto.enabled, size: dto.size, + max_bytes_mb: dto.max_bytes_mb, } } } @@ -43,6 +46,7 @@ pub struct StreamConfig { pub throttle_str: Option, pub throttle_kbps: u64, pub shared_burst_buffer_mb: u64, + pub shared_subscriber_idle_timeout_secs: u64, pub admission_strategies: Option>, } @@ -61,6 +65,7 @@ impl Default for StreamConfig { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 12, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: None, } } @@ -79,6 +84,7 @@ impl From<&StreamConfigDto> for StreamConfig { throttle_str: dto.throttle.clone(), throttle_kbps: dto.throttle.as_ref().map_or(0u64, |throttle| parse_to_kbps(throttle).unwrap_or(0u64)), shared_burst_buffer_mb: dto.shared_burst_buffer_mb, + shared_subscriber_idle_timeout_secs: dto.shared_subscriber_idle_timeout_secs, admission_strategies: dto.admission_strategies.clone(), } } @@ -98,6 +104,7 @@ impl From<&StreamConfig> for StreamConfigDto { throttle: instance.throttle_str.clone(), throttle_kbps: instance.throttle_kbps, shared_burst_buffer_mb: instance.shared_burst_buffer_mb, + shared_subscriber_idle_timeout_secs: instance.shared_subscriber_idle_timeout_secs, admission_strategies: instance.admission_strategies.clone(), } } @@ -137,6 +144,7 @@ mod tests { throttle_str: None, throttle_kbps: 0, shared_burst_buffer_mb: 1, + shared_subscriber_idle_timeout_secs: 300, admission_strategies: Some(vec![ AdmissionStrategy::EvictUserOldest, AdmissionStrategy::GraceHoldStream, diff --git a/backend/src/model/config/video_download.rs b/backend/src/model/config/video_download.rs index 7a1efbab3..7c9b55418 100644 --- a/backend/src/model/config/video_download.rs +++ b/backend/src/model/config/video_download.rs @@ -1,7 +1,15 @@ use crate::model::macros; +use chrono_tz::Tz; use regex::Regex; -use shared::model::{VideoConfigDto, VideoDownloadConfigDto}; use shared::defaults::DEFAULT_DOWNLOAD_DIR; +use shared::model::{ + default_recording_notification_backoff_initial_secs, + default_recording_notification_backoff_max_secs, + default_recording_notification_max_attempts, default_recording_notification_outbox_buffer, + RecordingConfigDto, RecordingContainerFormat, RecordingDiskConfigDto, + RecordingNotificationConfigDto, RecordingQuotaConfigDto, RecordingRetentionConfigDto, + VideoConfigDto, VideoDownloadConfigDto, +}; use std::collections::HashMap; use std::sync::Arc; @@ -20,6 +28,7 @@ pub struct VideoDownloadConfig { pub retry_backoff_max_secs: u64, pub retry_backoff_jitter_percent: u8, pub retry_max_attempts: u8, + pub recording: Option, } macros::from_impl!(VideoDownloadConfig); @@ -41,6 +50,7 @@ impl From<&VideoDownloadConfigDto> for VideoDownloadConfig { retry_backoff_max_secs: dto.retry_backoff_max_secs.max(dto.retry_backoff_initial_secs.max(1)), retry_backoff_jitter_percent: dto.retry_backoff_jitter_percent.min(95), retry_max_attempts: dto.retry_max_attempts.max(1), + recording: dto.recording.as_ref().map(Into::into), } } } @@ -61,6 +71,231 @@ impl From<&VideoDownloadConfig> for VideoDownloadConfigDto { retry_backoff_max_secs: instance.retry_backoff_max_secs, retry_backoff_jitter_percent: instance.retry_backoff_jitter_percent, retry_max_attempts: instance.retry_max_attempts, + recording: instance.recording.as_ref().map(Into::into), + } + } +} + +/// Backend domain type for DVR recording configuration. +#[derive(Debug, Clone)] +pub struct RecordingConfig { + pub enabled: bool, + pub container_format: RecordingContainerFormat, + pub directory: String, + pub timezone: Tz, + pub filename_template: String, + pub default_pre_roll_secs: u64, + pub max_pre_roll_secs: u64, + pub default_post_roll_secs: u64, + pub max_post_roll_secs: u64, + pub retention: Option, + pub disk: Option, + pub quota: Option, + pub notifications: RecordingNotificationConfig, + pub fallback_bytes_per_minute: u64, +} + +#[derive(Debug, Clone)] +pub struct RecordingRetentionConfig { + pub keep_last_per_channel: Option, + pub delete_after_days: Option, + pub sweep_interval_secs: u64, +} + +impl Default for RecordingRetentionConfig { + fn default() -> Self { + Self::from(&RecordingRetentionConfigDto::default()) + } +} + +/// Runtime notification-delivery knobs. Always present: an absent +/// `notifications:` block means "use the documented defaults", not +/// "deliver nothing". +#[derive(Debug, Clone)] +pub struct RecordingNotificationConfig { + pub outbox_buffer: usize, + pub max_attempts: u32, + pub backoff_initial_secs: u64, + pub backoff_max_secs: u64, +} + +impl Default for RecordingNotificationConfig { + fn default() -> Self { + Self::from(&RecordingNotificationConfigDto::default()) + } +} + +impl RecordingNotificationConfig { + /// True when every field still equals the documented default — + /// `RecordingConfigDto::is_empty` and `VideoConfigDto::clean` + /// use the same check to omit a defaulted notifications block. + pub fn is_empty(&self) -> bool { + self.outbox_buffer == default_recording_notification_outbox_buffer() + && self.max_attempts == default_recording_notification_max_attempts() + && self.backoff_initial_secs == default_recording_notification_backoff_initial_secs() + && self.backoff_max_secs == default_recording_notification_backoff_max_secs() + } +} + +macros::from_impl!(RecordingNotificationConfig); +impl From<&RecordingNotificationConfigDto> for RecordingNotificationConfig { + fn from(dto: &RecordingNotificationConfigDto) -> Self { + Self { + // A zero-capacity channel would make every enqueue block the + // recorder; clamp to at least one slot. + outbox_buffer: dto.outbox_buffer.max(1), + max_attempts: dto.max_attempts.max(1), + backoff_initial_secs: dto.backoff_initial_secs.max(1), + backoff_max_secs: dto.backoff_max_secs.max(dto.backoff_initial_secs.max(1)), + } + } +} + +impl From<&RecordingNotificationConfig> for RecordingNotificationConfigDto { + fn from(instance: &RecordingNotificationConfig) -> Self { + Self { + outbox_buffer: instance.outbox_buffer, + max_attempts: instance.max_attempts, + backoff_initial_secs: instance.backoff_initial_secs, + backoff_max_secs: instance.backoff_max_secs, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct RecordingDiskConfig { + pub high_water_percent: Option, + pub low_water_percent: Option, + pub cleanup_interval_secs: Option, + pub safety_bytes: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct RecordingQuotaConfig { + pub default_private_bytes: Option, + pub per_user_bytes: HashMap, + pub shared_bytes: Option, +} + +macros::from_impl!(RecordingConfig); +impl From<&RecordingConfigDto> for RecordingConfig { + fn from(dto: &RecordingConfigDto) -> Self { + let timezone = dto + .timezone + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| "UTC".parse::().expect("UTC must parse")); + Self { + enabled: dto.enabled, + container_format: dto.container_format, + directory: dto.directory.clone().unwrap_or_default(), + timezone, + filename_template: dto.filename_template.clone().unwrap_or_default(), + default_pre_roll_secs: dto.default_pre_roll_secs.unwrap_or(0), + max_pre_roll_secs: dto.max_pre_roll_secs, + default_post_roll_secs: dto.default_post_roll_secs.unwrap_or(0), + max_post_roll_secs: dto.max_post_roll_secs, + retention: dto.retention.as_ref().map(Into::into), + disk: dto.disk.as_ref().map(Into::into), + quota: dto.quota.as_ref().map(Into::into), + notifications: dto + .notifications + .as_ref() + .map(Into::into) + .unwrap_or_default(), + fallback_bytes_per_minute: dto.fallback_bytes_per_minute, + } + } +} + +impl From<&RecordingConfig> for RecordingConfigDto { + fn from(instance: &RecordingConfig) -> Self { + Self { + enabled: instance.enabled, + container_format: instance.container_format, + directory: Some(instance.directory.clone()), + timezone: Some(instance.timezone.name().to_string()), + filename_template: Some(instance.filename_template.clone()), + default_pre_roll_secs: if instance.default_pre_roll_secs == 0 { None } else { Some(instance.default_pre_roll_secs) }, + max_pre_roll_secs: instance.max_pre_roll_secs, + default_post_roll_secs: if instance.default_post_roll_secs == 0 { None } else { Some(instance.default_post_roll_secs) }, + max_post_roll_secs: instance.max_post_roll_secs, + retention: instance.retention.as_ref().map(Into::into), + disk: instance.disk.as_ref().map(Into::into), + quota: instance.quota.as_ref().map(Into::into), + notifications: if instance.notifications.is_empty() { None } else { Some((&instance.notifications).into()) }, + fallback_bytes_per_minute: instance.fallback_bytes_per_minute, + } + } +} + +macros::from_impl!(RecordingRetentionConfig); +impl From<&RecordingRetentionConfigDto> for RecordingRetentionConfig { + fn from(dto: &RecordingRetentionConfigDto) -> Self { + Self { + keep_last_per_channel: dto.keep_last_per_channel, + delete_after_days: dto.delete_after_days, + // A zero interval would spin the sweep loop; fall back to the + // documented default instead of busy-looping. + sweep_interval_secs: if dto.sweep_interval_secs == 0 { + shared::model::default_recording_retention_sweep_interval_secs() + } else { + dto.sweep_interval_secs + }, + } + } +} + +impl From<&RecordingRetentionConfig> for RecordingRetentionConfigDto { + fn from(instance: &RecordingRetentionConfig) -> Self { + Self { + keep_last_per_channel: instance.keep_last_per_channel, + delete_after_days: instance.delete_after_days, + sweep_interval_secs: instance.sweep_interval_secs, + } + } +} + +macros::from_impl!(RecordingDiskConfig); +impl From<&RecordingDiskConfigDto> for RecordingDiskConfig { + fn from(dto: &RecordingDiskConfigDto) -> Self { + Self { + high_water_percent: dto.high_water_percent, + low_water_percent: dto.low_water_percent, + cleanup_interval_secs: dto.cleanup_interval_secs, + safety_bytes: dto.safety_bytes, + } + } +} + +impl From<&RecordingDiskConfig> for RecordingDiskConfigDto { + fn from(instance: &RecordingDiskConfig) -> Self { + Self { + high_water_percent: instance.high_water_percent, + low_water_percent: instance.low_water_percent, + cleanup_interval_secs: instance.cleanup_interval_secs, + safety_bytes: instance.safety_bytes, + } + } +} + +macros::from_impl!(RecordingQuotaConfig); +impl From<&RecordingQuotaConfigDto> for RecordingQuotaConfig { + fn from(dto: &RecordingQuotaConfigDto) -> Self { + Self { + default_private_bytes: dto.default_private_bytes, + per_user_bytes: dto.per_user_bytes.clone(), + shared_bytes: dto.shared_bytes, + } + } +} + +impl From<&RecordingQuotaConfig> for RecordingQuotaConfigDto { + fn from(instance: &RecordingQuotaConfig) -> Self { + Self { + default_private_bytes: instance.default_private_bytes, + per_user_bytes: instance.per_user_bytes.clone(), + shared_bytes: instance.shared_bytes, } } } diff --git a/backend/src/model/messaging.rs b/backend/src/model/messaging.rs index 7a0fa10e0..6f6d98ebc 100644 --- a/backend/src/model/messaging.rs +++ b/backend/src/model/messaging.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use shared::model::{DiskAlert, MsgKind, SourceStats, InputStats}; +use shared::model::{DiskAlert, InputStats, MsgKind, SourceStats}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WatchChanges { @@ -27,6 +27,25 @@ impl ProcessingStats { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordingLifecycleMessage { + pub event: MsgKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub programme_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub visibility: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub failure_reason: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", content = "data")] pub enum MessageContent { @@ -35,6 +54,7 @@ pub enum MessageContent { Watch(WatchChanges), ProcessingStats(ProcessingStats), DiskAlert(DiskAlert), + RecordingLifecycle(RecordingLifecycleMessage), } impl MessageContent { @@ -59,6 +79,7 @@ impl MessageContent { } } Self::DiskAlert(_) => MsgKind::DiskAlert, + Self::RecordingLifecycle(recording) => recording.event, } } } @@ -78,6 +99,8 @@ pub struct TemplateContext<'a> { pub processing: Option, #[serde(skip_serializing_if = "Option::is_none")] pub disk: Option<&'a DiskAlert>, + #[serde(skip_serializing_if = "Option::is_none")] + pub recording: Option<&'a RecordingLifecycleMessage>, // Flattened stats for first input convenience #[serde(flatten)] pub flat_stats: Option, diff --git a/backend/src/model/xmltv.rs b/backend/src/model/xmltv.rs index 55d2c3d27..b86a5f5ee 100644 --- a/backend/src/model/xmltv.rs +++ b/backend/src/model/xmltv.rs @@ -29,6 +29,11 @@ pub const EPG_TAG_DESC: &str = "desc"; pub const EPG_TAG_CATEGORY: &str = "category"; pub const EPG_TAG_LIVE: &str = "live"; pub const EPG_TAG_NEW: &str = "new"; +/// XMLTV `` flag. Required for the tri-state +/// `AiringStatus` (`Unknown` / `New` / `Repeat`) used by +/// new-episode rules. Never infer `Repeat` only from old +/// `is_new == false`. +pub const EPG_TAG_PREVIOUSLY_SHOWN: &str = "previously-shown"; pub const EPG_ATTRIB_START: &str = "start"; pub const EPG_ATTRIB_STOP: &str = "stop"; pub const EPG_ATTRIB_CATCHUP_ID: &str = "catchup-id"; @@ -322,6 +327,11 @@ async fn parse_xmltv_for_web_ui(reader: R) -> Resul programme.is_new = true; } } + EPG_TAG_PREVIOUSLY_SHOWN => { + if let Some(programme) = &mut current_programme { + programme.is_new = false; + } + } _ => {} } } diff --git a/backend/src/processing/input_cache.rs b/backend/src/processing/input_cache.rs index 3ea033729..ed32933d0 100644 --- a/backend/src/processing/input_cache.rs +++ b/backend/src/processing/input_cache.rs @@ -74,11 +74,7 @@ pub fn is_cache_valid(status: &InputStatus, cluster: &str, cache_duration_second return false; } let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); - if now > cluster_status.timestamp { - // check if age is within duration - // timestamp is creation time. - // wait, assuming timestamp is Last Update Time. - // now - timestamp < duration + if now >= cluster_status.timestamp { return now - cluster_status.timestamp < cache_duration_seconds; } // Timestamp in future? Invalid. diff --git a/backend/src/processing/parser/xmltv.rs b/backend/src/processing/parser/xmltv.rs index 3b136e133..04b00208c 100644 --- a/backend/src/processing/parser/xmltv.rs +++ b/backend/src/processing/parser/xmltv.rs @@ -2,8 +2,9 @@ use crate::{ model::{ EPG_ATTRIB_CHANNEL, EPG_ATTRIB_ID, EPG_ATTRIB_LANG, EPG_TAG_CATEGORY, EPG_TAG_CHANNEL, EPG_TAG_DESC, EPG_TAG_DISPLAY_NAME, EPG_TAG_ICON, EPG_TAG_LIVE, EPG_TAG_NEW, EPG_TAG_PROGRAMME, EPG_TAG_TITLE, EPG_TAG_TV, + EPG_TAG_PREVIOUSLY_SHOWN, Epg, EpgSmartMatchConfig, IcsDummyPolicy, IcsEpgSourceConfig, PersistedEpgSource, PersistedEpgSourceKind, - TVGuide, XmlTag, XmlTagIcon, + TVGuide, XmlTag, XmlTagIcon }, processing::{parser::ics, processor::EpgIdCache}, repository::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate, FlushPolicy}, @@ -210,6 +211,7 @@ impl TVGuide { let mut categories = Vec::new(); let mut is_live = false; let mut is_new = false; + let mut previously_shown = false; if let Some(children) = tag.children.as_ref() { for child in children { match child.name.as_ref() { @@ -229,6 +231,7 @@ impl TVGuide { } EPG_TAG_LIVE => is_live = true, EPG_TAG_NEW => is_new = true, + EPG_TAG_PREVIOUSLY_SHOWN => previously_shown = true, _ => {} } } @@ -240,6 +243,7 @@ impl TVGuide { programme.categories = categories; programme.is_live = is_live; programme.is_new = is_new; + programme.previously_shown = previously_shown; Some(programme) } diff --git a/backend/src/processing/processor/deduplicate.rs b/backend/src/processing/processor/deduplicate.rs new file mode 100644 index 000000000..fda7f0882 --- /dev/null +++ b/backend/src/processing/processor/deduplicate.rs @@ -0,0 +1,191 @@ +use shared::model::{DeduplicateConfig, DeduplicateKeep, DeduplicateMatchBy, PlaylistGroup, PlaylistItem, XtreamCluster}; +use shared::utils::{deunicode_string, quality_rank, quality_tokens, token_quality}; +use std::collections::HashMap; + +/// Lowercased token join with quality tokens removed, so "News HD" and +/// "NEWS [FHD]" produce the same key. +fn normalized_dedup_key(value: &str, match_as_ascii: bool) -> String { + let value = if match_as_ascii { deunicode_string(value) } else { std::borrow::Cow::Borrowed(value) }; + let mut key = String::with_capacity(value.len()); + for token in quality_tokens(&value) { + if token_quality(token).is_some() { + continue; + } + if !key.is_empty() { + key.push(' '); + } + for ch in token.chars() { + key.extend(ch.to_lowercase()); + } + } + key +} + +fn match_value(config: DeduplicateConfig, item: &PlaylistItem) -> String { + normalized_dedup_key(raw_match_value(config, item), config.match_as_ascii) +} + +fn raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str { + let header = &item.header; + match config.match_by { + DeduplicateMatchBy::Caption => { + if header.title.is_empty() { + header.name.as_ref() + } else { + header.title.as_ref() + } + } + DeduplicateMatchBy::Name => header.name.as_ref(), + DeduplicateMatchBy::Title => header.title.as_ref(), + } +} + +/// Collapse duplicate channels across the whole target playlist (per cluster). +/// Returns the number of removed channels. Empty match keys are never +/// deduplicated; ties keep the first occurrence in playlist order. +pub(in crate::processing::processor) fn deduplicate_playlist( + config: DeduplicateConfig, + playlist: &mut Vec, +) -> usize { + // winner per key: (quality rank, group index, channel index) + let mut winners: HashMap<(XtreamCluster, String), (u8, usize, usize)> = HashMap::new(); + for (group_idx, group) in playlist.iter().enumerate() { + for (channel_idx, channel) in group.channels.iter().enumerate() { + let key_value = match_value(config, channel); + if key_value.is_empty() { + continue; + } + let rank = quality_rank(raw_match_value(config, channel)); + match winners.entry((group.xtream_cluster, key_value)) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert((rank, group_idx, channel_idx)); + } + std::collections::hash_map::Entry::Occupied(mut entry) => { + if config.keep == DeduplicateKeep::BestQuality && rank > entry.get().0 { + entry.insert((rank, group_idx, channel_idx)); + } + } + } + } + } + + let mut removed = 0; + // Only drop groups that deduplication emptied; groups that were already empty stay untouched. + let mut emptied_by_dedup = vec![false; playlist.len()]; + for (group_idx, group) in playlist.iter_mut().enumerate() { + let cluster = group.xtream_cluster; + let before = group.channels.len(); + let mut channel_idx = 0usize; + group.channels.retain(|channel| { + let idx = channel_idx; + channel_idx += 1; + let key_value = match_value(config, channel); + if key_value.is_empty() { + return true; + } + winners + .get(&(cluster, key_value)) + .is_none_or(|(_, winner_group, winner_channel)| *winner_group == group_idx && *winner_channel == idx) + }); + removed += before - group.channels.len(); + emptied_by_dedup[group_idx] = before > 0 && group.channels.is_empty(); + } + let mut group_idx = 0usize; + playlist.retain(|_| { + let keep = !emptied_by_dedup[group_idx]; + group_idx += 1; + keep + }); + removed +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::PlaylistItemHeader; + use shared::utils::Internable; + + fn make_item(title: &str) -> PlaylistItem { + PlaylistItem { header: PlaylistItemHeader { title: title.intern(), ..Default::default() } } + } + + fn make_group(title: &str, channels: Vec) -> PlaylistGroup { + PlaylistGroup { + id: 1, + title: title.intern(), + channels, + xtream_cluster: XtreamCluster::Live, + } + } + + #[test] + fn quality_rank_recognizes_tokens() { + assert_eq!(quality_rank("News [UHD]"), 5); + assert_eq!(quality_rank("News 1080p"), 3); + assert_eq!(quality_rank("News HD"), 2); + assert_eq!(quality_rank("News"), 0); + assert_eq!(quality_rank("HDTV News"), 0); // no partial token match + } + + #[test] + fn dedup_keeps_best_quality() { + let mut playlist = vec![make_group( + "G", + vec![make_item("News HD"), make_item("News [FHD]"), make_item("News"), make_item("Sports HD")], + )]; + let config = + DeduplicateConfig { match_by: DeduplicateMatchBy::Caption, keep: DeduplicateKeep::BestQuality, match_as_ascii: false }; + let removed = deduplicate_playlist(config, &mut playlist); + assert_eq!(removed, 2); + let titles: Vec<_> = playlist[0].channels.iter().map(|c| c.header.title.to_string()).collect(); + assert_eq!(titles, vec!["News [FHD]", "Sports HD"]); + } + + #[test] + fn dedup_keep_first_preserves_playlist_order_winner() { + let mut playlist = + vec![make_group("G", vec![make_item("News HD"), make_item("News [FHD]")])]; + let config = DeduplicateConfig { match_by: DeduplicateMatchBy::Caption, keep: DeduplicateKeep::First, match_as_ascii: false }; + let removed = deduplicate_playlist(config, &mut playlist); + assert_eq!(removed, 1); + assert_eq!(playlist[0].channels[0].header.title.as_ref(), "News HD"); + } + + #[test] + fn dedup_ignores_empty_keys_and_drops_empty_groups() { + let mut playlist = vec![ + make_group("Empty", vec![]), + make_group("A", vec![make_item("News HD")]), + make_group("B", vec![make_item("News FHD"), make_item("")]), + ]; + let config = + DeduplicateConfig { match_by: DeduplicateMatchBy::Caption, keep: DeduplicateKeep::BestQuality, match_as_ascii: false }; + let removed = deduplicate_playlist(config, &mut playlist); + assert_eq!(removed, 1); + // "A" was emptied by dedup and dropped, the already-empty group survives + assert_eq!(playlist.len(), 2); + assert_eq!(playlist[0].title.as_ref(), "Empty"); + assert_eq!(playlist[1].title.as_ref(), "B"); + // the empty-caption item has no dedup key and is retained + let titles: Vec<_> = playlist[1].channels.iter().map(|c| c.header.title.to_string()).collect(); + assert_eq!(titles, vec!["News FHD", ""]); + } + + #[test] + fn dedup_match_as_ascii_collapses_accented_names() { + let mut playlist = + vec![make_group("G", vec![make_item("Café HD"), make_item("Cafe FHD")])]; + let config = + DeduplicateConfig { match_by: DeduplicateMatchBy::Caption, keep: DeduplicateKeep::BestQuality, match_as_ascii: true }; + let removed = deduplicate_playlist(config, &mut playlist); + assert_eq!(removed, 1); + assert_eq!(playlist[0].channels[0].header.title.as_ref(), "Cafe FHD"); + + // without the flag the accented name stays distinct + let mut playlist = + vec![make_group("G", vec![make_item("Café HD"), make_item("Cafe FHD")])]; + let config = + DeduplicateConfig { match_by: DeduplicateMatchBy::Caption, keep: DeduplicateKeep::BestQuality, match_as_ascii: false }; + assert_eq!(deduplicate_playlist(config, &mut playlist), 0); + } +} diff --git a/backend/src/processing/processor/mod.rs b/backend/src/processing/processor/mod.rs index 9d97a534e..ea2d8938e 100644 --- a/backend/src/processing/processor/mod.rs +++ b/backend/src/processing/processor/mod.rs @@ -5,6 +5,7 @@ mod xtream; // mod affix; mod xtream_vod; mod xtream_series; +mod deduplicate; mod epg; mod sort; mod trakt; diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index ae5329b57..34eb7671f 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -1595,6 +1595,18 @@ async fn finalize_prepared_target( step.tick("playlist merge"); log_memory_snapshot(format!("target '{}' after_playlist_merge", target.name).as_str()); + if let Some(dedup_config) = target.options.as_ref().and_then(|options| options.deduplicate.as_ref()) { + let removed = crate::processing::processor::deduplicate::deduplicate_playlist( + *dedup_config, + &mut flat_new_playlist, + ); + if removed > 0 { + info!("Deduplicated {removed} channels for target {}", target.name); + } + step.tick("playlist dedup"); + log_memory_snapshot(format!("target '{}' after_playlist_dedup", target.name).as_str()); + } + if sort_playlist(target, &mut flat_new_playlist) { step.tick("playlist sort"); log_memory_snapshot(format!("target '{}' after_playlist_sort", target.name).as_str()); diff --git a/backend/src/processing/processor/sort.rs b/backend/src/processing/processor/sort.rs index fbdb2efba..052a5564c 100644 --- a/backend/src/processing/processor/sort.rs +++ b/backend/src/processing/processor/sort.rs @@ -1,6 +1,7 @@ use crate::model::{ConfigSortRule, ConfigTarget}; use shared::foundation::ValueProvider; use shared::model::{PlaylistGroup, SortOrder, SortTarget}; +use shared::utils::natural_cmp; use std::cmp::Ordering; use std::sync::Arc; use crate::utils::normalized_source_ordinal; @@ -13,6 +14,14 @@ fn apply_sort_order(order: SortOrder, ordering: Ordering) -> Ordering { } } +fn compare_values(natural: bool, left: &str, right: &str) -> Ordering { + if natural { + natural_cmp(left, right) + } else { + left.cmp(right) + } +} + fn parse_capture_group_rank(name: &str) -> Option { let suffix = name.strip_prefix('c')?; if suffix.is_empty() || !suffix.bytes().all(|c| c.is_ascii_digit()) { @@ -95,7 +104,7 @@ fn evaluate_sequence(plan: &SequencePlan, value: &str) -> SequenceMatch { SequenceMatch::Unmatched } -fn compare_sequence_match(a: &SequenceMatch, b: &SequenceMatch, order: SortOrder) -> Ordering { +fn compare_sequence_match(a: &SequenceMatch, b: &SequenceMatch, order: SortOrder, natural: bool) -> Ordering { match (a, b) { ( SequenceMatch::Matched { sequence_idx: idx_a, captures: captures_a }, @@ -110,7 +119,7 @@ fn compare_sequence_match(a: &SequenceMatch, b: &SequenceMatch, order: SortOrder let capture_count = captures_a.len().max(captures_b.len()); for index in 0..capture_count { let ord = match (captures_a.get(index), captures_b.get(index)) { - (Some(Some(v1)), Some(Some(v2))) => v1.cmp(v2), + (Some(Some(v1)), Some(Some(v2))) => compare_values(natural, v1, v2), (Some(Some(_)), Some(None) | None) => Ordering::Greater, (Some(None) | None, Some(Some(_))) => Ordering::Less, _ => Ordering::Equal, @@ -142,11 +151,13 @@ fn compare_rule_entries(rule: &PreparedRule, left: &RuleCacheEntry, right: &Rule (Some(value_left), Some(value_right)) => { if rule.sequence_plan.is_some() { match (&left.sequence_match, &right.sequence_match) { - (Some(seq_left), Some(seq_right)) => compare_sequence_match(seq_left, seq_right, rule.rule.order), + (Some(seq_left), Some(seq_right)) => { + compare_sequence_match(seq_left, seq_right, rule.rule.order, rule.rule.natural) + } _ => Ordering::Equal, } } else { - apply_sort_order(rule.rule.order, value_left.cmp(value_right)) + apply_sort_order(rule.rule.order, compare_values(rule.rule.natural, value_left, value_right)) } } } @@ -239,7 +250,7 @@ fn playlist_comparator( let plan = SequencePlan::new(sequence); let left = evaluate_sequence(&plan, value_a); let right = evaluate_sequence(&plan, value_b); - compare_sequence_match(&left, &right, order) + compare_sequence_match(&left, &right, order, false) } else { apply_sort_order(order, value_a.cmp(value_b)) } @@ -270,7 +281,7 @@ fn compare_cached_rule_entries( let left = &cache[left_idx]; let right = &cache[right_idx]; if let (Some(va), Some(vb)) = (&left.value, &right.value) { - let fallback = apply_sort_order(rule.rule.order, va.cmp(vb)); + let fallback = apply_sort_order(rule.rule.order, compare_values(rule.rule.natural, va, vb)); if fallback != Ordering::Equal { return fallback; } @@ -419,6 +430,7 @@ mod tests { target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::Asc, + natural: false, sequence: Some(vec![ shared::model::REGEX_CACHE.get_or_compile(r"(?P.*?)\bUHD\b").unwrap(), shared::model::REGEX_CACHE.get_or_compile(r"(?P.*?)\bFHD\b").unwrap(), @@ -503,6 +515,7 @@ mod tests { target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::Asc, + natural: false, sequence: Some(vec![ shared::model::REGEX_CACHE.get_or_compile(r"^US\| EAST.*?\[\bUHD\b\](?P.*)").unwrap(), shared::model::REGEX_CACHE.get_or_compile(r"^US\| EAST.*?\[\bFHD\b\](?P.*)").unwrap(), @@ -603,6 +616,7 @@ mod tests { target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::Desc, + natural: false, sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-(?P\d+)$").unwrap()]), filter: Filter::default(), }; @@ -636,6 +650,7 @@ mod tests { target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::Asc, + natural: false, // Both values match the same sequence item and produce equal sequence priority. sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-\d+-.$").unwrap()]), filter: Filter::default(), @@ -644,6 +659,7 @@ mod tests { target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::Desc, + natural: false, sequence: None, filter: Filter::default(), }; @@ -677,6 +693,7 @@ mod tests { target: SortTarget::Group, field: ItemField::Caption, order: SortOrder::Asc, + natural: false, // Both groups match the same sequence item and produce equal sequence priority. sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-\d+-.$").unwrap()]), filter: Filter::default(), @@ -719,6 +736,7 @@ mod tests { target: SortTarget::Group, field: ItemField::Caption, order: SortOrder::Asc, + natural: false, // Both groups have the same sequence/rule priority and same caption value. sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^Same Caption$").unwrap()]), filter: Filter::default(), @@ -751,6 +769,7 @@ mod tests { target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::None, + natural: false, sequence: Some(vec![ shared::model::REGEX_CACHE.get_or_compile(r"^UHD$").unwrap(), shared::model::REGEX_CACHE.get_or_compile(r"^FHD$").unwrap(), @@ -795,6 +814,7 @@ mod tests { target: SortTarget::Group, field: ItemField::Caption, order: SortOrder::None, + natural: false, sequence: Some(vec![ shared::model::REGEX_CACHE.get_or_compile(r"^UHD$").unwrap(), shared::model::REGEX_CACHE.get_or_compile(r"^FHD$").unwrap(), @@ -829,6 +849,7 @@ mod tests { target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::None, + natural: false, sequence: Some(vec![]), filter: Filter::default(), }; @@ -840,4 +861,36 @@ mod tests { let expected = vec!["B", "A"].into_iter().map(Into::into).collect::>>(); assert_eq!(expected, sorted); } + + #[test] + fn test_natural_sort_orders_embedded_numbers_numerically() { + let channels: Vec = vec!["Chan 10", "Chan 2", "Chan 1", "Chan 002"] + .into_iter() + .enumerate() + .map(|(i, title)| PlaylistItem { + header: PlaylistItemHeader { + title: title.to_string().into(), + source_ordinal: u32::try_from(i + 1).unwrap(), + ..Default::default() + }, + }) + .collect(); + + let channel_sort = ConfigSortRule { + target: SortTarget::Channel, + field: ItemField::Caption, + order: SortOrder::Asc, + natural: true, + sequence: None, + filter: Filter::default(), + }; + + let mut groups = vec![make_group(1, "G1", channels)]; + sort_channels_in_groups(groups.as_mut_slice(), &[channel_sort], false); + + let sorted = groups[0].channels.iter().map(|pli| pli.header.title.clone()).collect::>(); + let expected = + vec!["Chan 1", "Chan 2", "Chan 002", "Chan 10"].into_iter().map(Into::into).collect::>>(); + assert_eq!(expected, sorted); + } } diff --git a/backend/src/repository/alias_repository.rs b/backend/src/repository/alias_repository.rs index 1db0b0f3d..4032d3c25 100644 --- a/backend/src/repository/alias_repository.rs +++ b/backend/src/repository/alias_repository.rs @@ -479,6 +479,7 @@ async fn preserve_csv_metadata(file_path: &Path, tmp_path: &Path) -> Result<(), copy_csv_acl(file_path, tmp_path).await } +#[allow(clippy::unused_async)] async fn copy_csv_acl(source: &Path, target: &Path) -> io::Result<()> { #[cfg(any(target_os = "linux", target_os = "macos"))] { diff --git a/backend/src/repository/bplustree/migration.rs b/backend/src/repository/bplustree/migration.rs index 11adee8e0..c66b5188b 100644 --- a/backend/src/repository/bplustree/migration.rs +++ b/backend/src/repository/bplustree/migration.rs @@ -30,7 +30,7 @@ use std::{ const LEGACY_STORAGE_VERSION: u32 = 1; const MARKER_FILE_GUARD_PREFIX: &str = ".db_mergeto_v"; const MARKER_FILE_GUARD_PREFIX_LEGACY_ALT: &str = ".db_mergedto"; -const MARKER_FILE_API_USER_GUARD: &str = ".userdb_mergeto_v6"; +const MARKER_FILE_API_USER_GUARD: &str = ".userdb_mergeto_v7"; const MARKER_VERSION_KEY: &str = "migrated_to"; const MARKER_ROOTS_FINGERPRINT_KEY: &str = "roots_fingerprint"; @@ -474,16 +474,18 @@ pub fn migrate_bplustree_databases_with_marker( fn marker_file_name() -> String { format!("{MARKER_FILE_GUARD_PREFIX}{STORAGE_VERSION}") } // -// The user database has gone through six serialization schemas (MessagePack, +// The user database has gone through seven serialization schemas (MessagePack, // positional/sequence encoding via rmp_serde): // // V1 (Deprecated) – original format, 13 fields, no epg_request_timeshift // V2 – 14 fields, added epg_request_timeshift // V3 – 15 fields, added priority // V4 – 17 fields, added soft_connections and soft_priority -// V5 (current) – 18 fields, added output_clusters +// V5 – 18 fields, added output_clusters +// V6 – 19 fields, added network_access +// V7 (current) – 21 fields, added plan and filter // -// On first startup after an upgrade the file is still in V1 or V2 format. +// On first startup after an upgrade the file is still in an older format. // `migrate_user_db_schema` detects this, converts every record in-place, and // writes a merge-guard marker so that config-driven user merges cannot // overwrite the freshly migrated data. @@ -697,7 +699,7 @@ impl StoredApiUserV5 { fn from_v1(v1: &StoredApiUserV1) -> Self { Self::from_v4(&StoredApiUserV4::from_v1(v1)) } } -// V6 mirror — same layout as user_repository::StoredProxyUserCredentials. +// V6 mirror — same layout as the previous user_repository::StoredProxyUserCredentials. // Defined here so the migration has no dependency on user_repository internals. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -757,6 +759,72 @@ impl StoredApiUserV6 { fn from_v1(v1: &StoredApiUserV1) -> Self { Self::from_v5(&StoredApiUserV5::from_v1(v1)) } } +// V7 mirror — same layout as user_repository::StoredProxyUserCredentials. +// Defined here so the migration has no dependency on user_repository internals. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredApiUserV7 { + pub target: String, + pub username: String, + pub password: String, + pub token: Option, + pub proxy: ProxyType, + pub server: Option, + pub epg_timeshift: Option, + pub epg_request_timeshift: Option, + pub created_at: Option, + pub exp_date: Option, + pub max_connections: Option, + pub status: Option, + pub output_clusters: ClusterFlags, + pub ui_enabled: bool, + pub comment: Option, + pub priority: Option, + pub soft_connections: Option, + pub soft_priority: Option, + pub network_access: Option, + pub plan: Option, + pub filter: Option, +} + +impl StoredApiUserV7 { + fn from_v6(v6: &StoredApiUserV6) -> Self { + Self { + target: v6.target.clone(), + username: v6.username.clone(), + password: v6.password.clone(), + token: v6.token.clone(), + proxy: v6.proxy, + server: v6.server.clone(), + epg_timeshift: v6.epg_timeshift.clone(), + epg_request_timeshift: v6.epg_request_timeshift.clone(), + created_at: v6.created_at, + exp_date: v6.exp_date, + max_connections: v6.max_connections, + status: v6.status, + output_clusters: v6.output_clusters, + ui_enabled: v6.ui_enabled, + comment: v6.comment.clone(), + priority: v6.priority, + soft_connections: v6.soft_connections, + soft_priority: v6.soft_priority, + network_access: v6.network_access.clone(), + plan: None, + filter: None, + } + } + + fn from_v5(v5: &StoredApiUserV5) -> Self { Self::from_v6(&StoredApiUserV6::from_v5(v5)) } + + fn from_v4(v4: &StoredApiUserV4) -> Self { Self::from_v6(&StoredApiUserV6::from_v4(v4)) } + + fn from_v3(v3: &StoredApiUserV3) -> Self { Self::from_v6(&StoredApiUserV6::from_v3(v3)) } + + fn from_v2(v2: &StoredApiUserV2) -> Self { Self::from_v6(&StoredApiUserV6::from_v2(v2)) } + + fn from_v1(v1: &StoredApiUserV1) -> Self { Self::from_v6(&StoredApiUserV6::from_v1(v1)) } +} + fn create_user_db_merge_guard(merge_guard_path: &Path) -> io::Result<()> { if !merge_guard_path.exists() { std::fs::write(merge_guard_path, b"")?; @@ -773,9 +841,9 @@ fn migrate_legacy_user_schema( ) -> io::Result where SourceV: serde::Serialize + for<'de> serde::Deserialize<'de> + Clone, - Map: FnMut(SourceV) -> StoredApiUserV6, + Map: FnMut(SourceV) -> StoredApiUserV7, { - if super::v3::migration::migrate_v2_typed_map::(db_path, map).is_err() + if super::v3::migration::migrate_v2_typed_map::(db_path, map).is_err() { return Ok(false); } @@ -790,26 +858,26 @@ fn migrate_current_user_schema( ) -> io::Result where SourceV: for<'de> serde::Deserialize<'de>, - Map: Fn(&SourceV) -> StoredApiUserV6, + Map: Fn(&SourceV) -> StoredApiUserV7, { let Ok(tree) = BPlusTree::::load(db_path) else { return Ok(false); }; - let mut v6_tree = BPlusTree::new(); + let mut v7_tree = BPlusTree::new(); for (key, user) in &tree { - v6_tree.insert(key.clone(), map(user)); + v7_tree.insert(key.clone(), map(user)); } - v6_tree.store(db_path)?; + v7_tree.store(db_path)?; create_user_db_merge_guard(merge_guard_path)?; Ok(true) } -/// Migrates the user database file from V1-V5 schema to V6 (current) in +/// Migrates the user database file from V1-V6 schema to V7 (current) in /// place and creates a merge-guard file so config-driven merges are skipped /// until the operator explicitly removes it. /// /// Returns `true` when a migration was performed, `false` when the file was -/// already in V6 format or did not exist. +/// already in V7 format or did not exist. fn migrate_user_db_schema(db_path: &Path, merge_guard_path: &Path) -> io::Result { if !db_path.exists() { return Ok(false); @@ -818,37 +886,42 @@ fn migrate_user_db_schema(db_path: &Path, merge_guard_path: &Path) -> io::Result let storage_version = super::v3::migration::storage_version(db_path)? .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "user database is not a B+Tree"))?; if storage_version <= 2 { - if super::v3::migration::migrate_v2_typed::(db_path).is_ok() { + if super::v3::migration::migrate_v2_typed::(db_path).is_ok() { return Ok(false); } + if migrate_legacy_user_schema::(db_path, merge_guard_path, |user| { + StoredApiUserV7::from_v6(&user) + })? { + return Ok(true); + } if migrate_legacy_user_schema::(db_path, merge_guard_path, |user| { - StoredApiUserV6::from_v5(&user) + StoredApiUserV7::from_v5(&user) })? { return Ok(true); } if migrate_legacy_user_schema::(db_path, merge_guard_path, |user| { - StoredApiUserV6::from_v4(&user) + StoredApiUserV7::from_v4(&user) })? { return Ok(true); } if migrate_legacy_user_schema::(db_path, merge_guard_path, |user| { - StoredApiUserV6::from_v3(&user) + StoredApiUserV7::from_v3(&user) })? { return Ok(true); } if migrate_legacy_user_schema::(db_path, merge_guard_path, |user| { - StoredApiUserV6::from_v2(&user) + StoredApiUserV7::from_v2(&user) })? { return Ok(true); } if migrate_legacy_user_schema::(db_path, merge_guard_path, |user| { - StoredApiUserV6::from_v1(&user) + StoredApiUserV7::from_v1(&user) })? { return Ok(true); } return Err(io::Error::new( io::ErrorKind::InvalidData, - format!("Legacy user DB at '{}' could not be read as V1, V2, V3, V4, V5, or V6", db_path.display()), + format!("Legacy user DB at '{}' could not be read as V1, V2, V3, V4, V5, V6, or V7", db_path.display()), )); } if storage_version != STORAGE_VERSION { @@ -858,28 +931,31 @@ fn migrate_user_db_schema(db_path: &Path, merge_guard_path: &Path) -> io::Result )); } - if BPlusTree::::load(db_path).is_ok() { + if BPlusTree::::load(db_path).is_ok() { return Ok(false); } - if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV6::from_v5)? { + if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV7::from_v6)? { return Ok(true); } - if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV6::from_v4)? { + if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV7::from_v5)? { return Ok(true); } - if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV6::from_v3)? { + if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV7::from_v4)? { return Ok(true); } - if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV6::from_v2)? { + if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV7::from_v3)? { return Ok(true); } - if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV6::from_v1)? { + if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV7::from_v2)? { + return Ok(true); + } + if migrate_current_user_schema::(db_path, merge_guard_path, StoredApiUserV7::from_v1)? { return Ok(true); } Err(io::Error::new( io::ErrorKind::InvalidData, - format!("User DB at '{}' exists but could not be read as V1, V2, V3, V4, V5, or V6 format", db_path.display()), + format!("User DB at '{}' exists but could not be read as V1, V2, V3, V4, V5, V6, or V7 format", db_path.display()), )) } @@ -891,7 +967,7 @@ pub struct AllStartupMigrationStats { /// Runs all startup migrations in sequence: /// 1. B+Tree storage-format migration (V1 -> current binary format) -/// 2. User DB schema migration (V1-V5 -> V6 `MessagePack` layout) +/// 2. User DB schema migration (V1-V6 -> V7 `MessagePack` layout) /// /// `config_dir` is the directory that contains `api_user.db` and the merge-guard /// marker. `storage_dir` is used for the B+Tree migration marker. @@ -1308,7 +1384,7 @@ mod tests { } #[test] - fn user_db_schema_migration_v2_to_v6_creates_merge_guard() -> io::Result<()> { + fn user_db_schema_migration_v2_to_v7_creates_merge_guard() -> io::Result<()> { let temp = tempdir()?; let db_path = temp.path().join(storage_const::API_USER_DB_FILE); let merge_guard_path = user_db_merge_guard_path(temp.path()); @@ -1341,8 +1417,8 @@ mod tests { assert!(migrated); assert!(merge_guard_path.exists()); - let v6_tree = BPlusTree::::load(&db_path)?; - let user = v6_tree + let v7_tree = BPlusTree::::load(&db_path)?; + let user = v7_tree .query(&"alice".to_string()) .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "alice missing after migration"))?; assert_eq!(user.username, "alice"); @@ -1352,12 +1428,14 @@ mod tests { assert_eq!(user.soft_connections, None); assert_eq!(user.soft_priority, None); assert_eq!(user.network_access, None); + assert_eq!(user.plan, None); + assert_eq!(user.filter, None); Ok(()) } #[test] - fn user_db_schema_migration_v3_to_v6_creates_merge_guard() -> io::Result<()> { + fn user_db_schema_migration_v3_to_v7_creates_merge_guard() -> io::Result<()> { let temp = tempdir()?; let db_path = temp.path().join(storage_const::API_USER_DB_FILE); let merge_guard_path = user_db_merge_guard_path(temp.path()); @@ -1390,8 +1468,8 @@ mod tests { assert!(migrated); assert!(merge_guard_path.exists()); - let v6_tree = BPlusTree::::load(&db_path)?; - let user = v6_tree + let v7_tree = BPlusTree::::load(&db_path)?; + let user = v7_tree .query(&"bob".to_string()) .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "bob missing after migration"))?; assert_eq!(user.output_clusters, ClusterFlags::all()); @@ -1399,12 +1477,13 @@ mod tests { assert_eq!(user.soft_connections, None); assert_eq!(user.soft_priority, None); assert_eq!(user.network_access, None); + assert_eq!(user.plan, None); Ok(()) } #[test] - fn user_db_schema_migration_v4_to_v6_creates_merge_guard() -> io::Result<()> { + fn user_db_schema_migration_v4_to_v7_creates_merge_guard() -> io::Result<()> { let temp = tempdir()?; let db_path = temp.path().join(storage_const::API_USER_DB_FILE); let merge_guard_path = user_db_merge_guard_path(temp.path()); @@ -1439,20 +1518,21 @@ mod tests { assert!(migrated); assert!(merge_guard_path.exists()); - let v6_tree = BPlusTree::::load(&db_path)?; - let user = v6_tree + let v7_tree = BPlusTree::::load(&db_path)?; + let user = v7_tree .query(&"carol".to_string()) .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "carol missing after migration"))?; assert_eq!(user.output_clusters, ClusterFlags::all()); assert_eq!(user.soft_connections, Some(2)); assert_eq!(user.soft_priority, Some(-4)); assert_eq!(user.network_access, None); + assert_eq!(user.plan, None); Ok(()) } #[test] - fn user_db_schema_migration_v5_to_v6_creates_merge_guard() -> io::Result<()> { + fn user_db_schema_migration_v5_to_v7_creates_merge_guard() -> io::Result<()> { let temp = tempdir()?; let db_path = temp.path().join(storage_const::API_USER_DB_FILE); let merge_guard_path = user_db_merge_guard_path(temp.path()); @@ -1488,21 +1568,23 @@ mod tests { assert!(migrated); assert!(merge_guard_path.exists()); - let v6_tree = BPlusTree::::load(&db_path)?; - let user = v6_tree + let v7_tree = BPlusTree::::load(&db_path)?; + let user = v7_tree .query(&"dave".to_string()) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "dave missing after v6 migration"))?; + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "dave missing after v7 migration"))?; assert_eq!(user.output_clusters, ClusterFlags::Live | ClusterFlags::Vod); assert_eq!(user.priority, Some(5)); assert_eq!(user.soft_connections, Some(2)); assert_eq!(user.soft_priority, Some(-4)); assert_eq!(user.network_access, None); + assert_eq!(user.plan, None); + assert_eq!(user.filter, None); Ok(()) } #[test] - fn user_db_schema_v6_is_detected_without_writing_merge_guard() -> io::Result<()> { + fn user_db_schema_migration_v6_to_v7_creates_merge_guard() -> io::Result<()> { let temp = tempdir()?; let db_path = temp.path().join(storage_const::API_USER_DB_FILE); let merge_guard_path = user_db_merge_guard_path(temp.path()); @@ -1540,13 +1622,13 @@ mod tests { assert!(!merge_guard_path.exists()); let migrated = migrate_user_db_schema(&db_path, &merge_guard_path)?; - assert!(!migrated); - assert!(!merge_guard_path.exists()); + assert!(migrated); + assert!(merge_guard_path.exists()); - let v6_tree = BPlusTree::::load(&db_path)?; - let user = v6_tree + let v7_tree = BPlusTree::::load(&db_path)?; + let user = v7_tree .query(&"erin".to_string()) - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "erin missing after v6 detection"))?; + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "erin missing after v7 migration"))?; assert_eq!(user.output_clusters, ClusterFlags::Live | ClusterFlags::Vod); assert_eq!(user.priority, Some(5)); assert_eq!(user.soft_connections, Some(2)); @@ -1555,6 +1637,58 @@ mod tests { user.network_access.as_ref().and_then(|value| value.allowed_countries.as_ref()), Some(&vec!["DE".to_string()]) ); + assert_eq!(user.plan, None); + assert_eq!(user.filter, None); + + Ok(()) + } + + #[test] + fn user_db_schema_v7_is_detected_without_writing_merge_guard() -> io::Result<()> { + let temp = tempdir()?; + let db_path = temp.path().join(storage_const::API_USER_DB_FILE); + let merge_guard_path = user_db_merge_guard_path(temp.path()); + + let mut v7_tree: BPlusTree = BPlusTree::new(); + v7_tree.insert( + "frank".to_string(), + StoredApiUserV7 { + target: "channels".to_string(), + username: "frank".to_string(), + password: "secret".to_string(), + token: None, + proxy: ProxyType::Reverse(None), + server: None, + epg_timeshift: None, + epg_request_timeshift: None, + created_at: None, + exp_date: None, + max_connections: None, + status: Some(ProxyUserStatus::Active), + output_clusters: ClusterFlags::all(), + ui_enabled: true, + comment: None, + priority: None, + soft_connections: None, + soft_priority: None, + network_access: None, + plan: Some("basic".to_string()), + filter: Some(r#"Group ~ "^DE.*""#.to_string()), + }, + ); + let _ = v7_tree.store(&db_path)?; + assert!(!merge_guard_path.exists()); + + let migrated = migrate_user_db_schema(&db_path, &merge_guard_path)?; + assert!(!migrated); + assert!(!merge_guard_path.exists()); + + let v7_tree = BPlusTree::::load(&db_path)?; + let user = v7_tree + .query(&"frank".to_string()) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "frank missing after v7 detection"))?; + assert_eq!(user.plan.as_deref(), Some("basic")); + assert_eq!(user.filter.as_deref(), Some(r#"Group ~ "^DE.*""#)); Ok(()) } diff --git a/backend/src/repository/identity_registry.rs b/backend/src/repository/identity_registry.rs new file mode 100644 index 000000000..53c093b63 --- /dev/null +++ b/backend/src/repository/identity_registry.rs @@ -0,0 +1,655 @@ +//! Identity registry. +//! +//! Stable subject identifier assignment for web users and API users. +//! The registry lives in `/web_user_ids.json` and is +//! persisted atomically via the [`atomic_json_store`](crate::utils::atomic_json_store) +//! helper. The file is *additive*: every existing mapping is preserved +//! forever, and explicit rename is the only way +//! to update a username→UserId binding. Bootstrap is fail-closed: if +//! the persisted state already references real user IDs and the +//! registry is missing or corrupt, recovery requires explicit +//! administrator repair. A new registry may be initialized only when +//! no persisted recording references a real user ID. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use shared::model::UserId; +use tokio::sync::RwLock; +use std::sync::atomic::AtomicU64; + +static IDENTITY_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Schema for the on-disk registry. The mapping is +/// `canonical_username → UserId`. The registry is additive; existing +/// entries are never removed by normal operation. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +pub struct PersistedIdentityRegistry { + #[serde(default)] + pub version: u32, + /// Maps the canonical username (the same string used by + /// authentication) to its immutable `UserId`. + pub web_users: HashMap, + /// Maps the proxy-user identifier to its immutable `UserId`. The + /// username is the same string used for proxy authentication. + #[serde(default)] + pub api_users: HashMap, +} + +/// Outcome of the bootstrap sequence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BootstrapOutcome { + /// No pre-existing registry and no real user IDs in the persisted + /// state; a fresh registry was created. + Initialized, + /// A registry was loaded from disk. `current_principal_count` + /// counts the number of distinct usernames discovered during + /// bootstrap (before normalization). + Restored { current_principal_count: usize }, + /// The persisted state references real user IDs but the registry + /// is missing or corrupt. Recovery requires explicit administrator + /// repair. The `persisted_user_ids` are the IDs that need to be + /// restored. + FailClosed { + persisted_user_ids: Vec, + reason: FailClosedReason, + }, + /// The persisted registry is behind the latest schema version. + /// A migration was applied; the registry is now at `migrated_to`. + Migrated { migrated_to: u32 }, +} + +/// Reason for fail-closed recovery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FailClosedReason { + Missing, + Corrupt, +} + +/// The current on-disk schema version. Bump when the schema changes. +pub const CURRENT_REGISTRY_VERSION: u32 = 1; + +/// Thread-safe identity registry. Mutations acquire a write lock; +/// reads acquire a read lock. The registry is `Clone` only via the +/// snapshot in [`IdentityRegistry::snapshot`]. +pub struct IdentityRegistry { + state: RwLock, + path: PathBuf, +} + +impl IdentityRegistry { + /// Run the bootstrap sequence: + /// 1. Pre-scan the persisted download state for real user IDs + /// (already done by the caller; this registry accepts the + /// pre-scanned set). + /// 2. Load the registry from disk. + /// 3. If no registry exists and no real user IDs are + /// pre-scanned, initialize a fresh one. + /// 4. If the registry is missing or corrupt and real user IDs + /// are pre-scanned, fail closed. + /// 5. Apply any schema migrations. + /// 6. Sync the current principal set (do not overwrite existing + /// entries; only insert new ones). + pub async fn bootstrap( + path: PathBuf, + pre_scanned_user_ids: Vec, + current_web_users: &[String], + current_api_users: &[String], + ) -> (Self, BootstrapOutcome) { + match Self::load_or_detect_failure(&path).await { + Ok(Some(state)) => { + let migrated = Self::migrate(state); + let count = migrated.web_users.len() + migrated.api_users.len(); + let outcome = if migrated.version < CURRENT_REGISTRY_VERSION { + BootstrapOutcome::Migrated { + migrated_to: migrated.version, + } + } else { + BootstrapOutcome::Restored { + current_principal_count: count, + } + }; + let reg = Self { + state: RwLock::new(migrated), + path, + }; + reg.sync_current_principals(current_web_users, current_api_users).await; + (reg, outcome) + } + Ok(None) => { + // File does not exist. + if pre_scanned_user_ids.is_empty() { + let reg = Self { + state: RwLock::new(PersistedIdentityRegistry::default()), + path, + }; + reg.sync_current_principals(current_web_users, current_api_users).await; + let _ = reg.save().await; + return (reg, BootstrapOutcome::Initialized); + } + // Fail-closed with persisted user IDs: do NOT sync or + // persist. The caller must surface the IDs and require + // explicit operator repair before any new principal is + // assigned. + ( + Self::empty(path), + BootstrapOutcome::FailClosed { + persisted_user_ids: pre_scanned_user_ids, + reason: FailClosedReason::Missing, + }, + ) + } + Err(reason) => { + // Fail-closed: do NOT sync or persist. The caller must + // surface the reason and require explicit operator repair. + ( + Self::empty(path), + BootstrapOutcome::FailClosed { + persisted_user_ids: pre_scanned_user_ids, + reason, + }, + ) + } + } + } + + /// Build an empty registry without touching disk. Used by the + /// fail-closed path so the caller can still call methods that + /// require a registry. + fn empty(path: PathBuf) -> Self { + Self { + state: RwLock::new(PersistedIdentityRegistry::default()), + path, + } + } + + async fn load_or_detect_failure(path: &Path) -> Result, FailClosedReason> { + match tokio::fs::read(path).await { + Ok(bytes) => { + // A zero-length artifact is never a clean state — it is a + // half-write, an aborted rename, or an external truncation. + // Treating it as `Ok(Some(default))` would silently restore + // an empty registry and let the caller reassign every + // existing user ID; that is exactly what FailClosed exists + // to prevent. + if bytes.is_empty() { + return Err(FailClosedReason::Corrupt); + } + match serde_json::from_slice::(&bytes) { + Ok(state) => Ok(Some(state)), + Err(_) => Err(FailClosedReason::Corrupt), + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err(FailClosedReason::Corrupt), + } + } + + /// Apply any pending schema migrations. Currently a no-op because + /// the schema is at `CURRENT_REGISTRY_VERSION`. The function is + /// present so future migrations have a uniform insertion point. + fn migrate(mut state: PersistedIdentityRegistry) -> PersistedIdentityRegistry { + if state.version < CURRENT_REGISTRY_VERSION { + state.version = CURRENT_REGISTRY_VERSION; + } + state + } + + /// Insert any new web users / API users discovered at bootstrap. + /// Existing entries are not overwritten. New entries are assigned + /// fresh `UserId`s in the `web:` / `api:` namespaces. + async fn sync_current_principals( + &self, + current_web_users: &[String], + current_api_users: &[String], + ) { + let mut state = self.state.write().await; + let mut changed = false; + for username in current_web_users { + let key = canonical_username(username); + if let std::collections::hash_map::Entry::Vacant(entry) = state.web_users.entry(key) { + let new_id = UserId::from(format!("{}{}", UserId::WEB_NAMESPACE, Self::new_uuid_hex())); + entry.insert(new_id); + changed = true; + } + } + for username in current_api_users { + let key = canonical_username(username); + if let std::collections::hash_map::Entry::Vacant(entry) = state.api_users.entry(key) { + let new_id = UserId::from(format!("{}{}", UserId::API_NAMESPACE, Self::new_uuid_hex())); + entry.insert(new_id); + changed = true; + } + } + if changed { + // Best-effort persist. Failures are logged; the registry + // is still in-memory consistent. + let snapshot = state.clone(); + drop(state); + let _ = Self::write_to_disk(&self.path, &snapshot).await; + } + } + + /// Insert a brand-new mapping and persist atomically. Returns + /// the freshly generated `UserId`. The username is normalized + /// before insertion (trimmed, empty is rejected). + pub async fn register(&self, username: &str) -> Result { + let key = canonical_username(username); + if key.is_empty() { + return Err(RegistryError::EmptyUsername); + } + let mut state = self.state.write().await; + if let Some(existing) = state.web_users.get(&key) { + return Ok(existing.clone()); + } + let new_id = UserId::from(format!("{}{}", UserId::WEB_NAMESPACE, Self::new_uuid_hex())); + state.web_users.insert(key, new_id.clone()); + let snapshot = state.clone(); + drop(state); + Self::write_to_disk(&self.path, &snapshot) + .await + .map_err(RegistryError::Persist)?; + Ok(new_id) + } + + /// Look up a `UserId` by the canonical username. Returns `None` + /// for unknown users. + pub async fn lookup_by_username(&self, username: &str) -> Option { + let key = canonical_username(username); + self.state.read().await.web_users.get(&key).cloned() + } + + /// Look up the canonical username for a `UserId`. Returns `None` + /// if the registry has no entry for that ID. The lookup is + /// exhaustive across both web and API namespaces. + pub async fn lookup_username_by_id(&self, id: &UserId) -> Option { + let state = self.state.read().await; + if let Some((k, _)) = state.web_users.iter().find(|(_, v)| *v == id) { + return Some(k.clone()); + } + if let Some((k, _)) = state.api_users.iter().find(|(_, v)| *v == id) { + return Some(k.clone()); + } + None + } + + /// Explicit operator/renaming migration. Moves the existing + /// `UserId` for `old_username` to `new_username`. The `UserId` + /// is preserved — all persisted recordings continue to reference + /// the same immutable ID. No-op if the source username is + /// unknown. The registry is persisted atomically. + pub async fn rename( + &self, + old_username: &str, + new_username: &str, + ) -> Result, RegistryError> { + let old_key = canonical_username(old_username); + let new_key = canonical_username(new_username); + if old_key == new_key { + return Ok(self.lookup_by_username(&old_key).await); + } + if new_key.is_empty() { + return Err(RegistryError::EmptyUsername); + } + let mut state = self.state.write().await; + // Reject an existing destination up front, before removing the + // source — silently overwriting would discard the existing + // user's persisted recordings. + if state.web_users.contains_key(&new_key) || state.api_users.contains_key(&new_key) { + return Err(RegistryError::UsernameExists); + } + // Look in both namespaces; the source user may live in either. + let user_id = state + .web_users + .remove(&old_key) + .or_else(|| state.api_users.remove(&old_key)); + let Some(user_id) = user_id else { + return Ok(None); + }; + // Re-insert under the chosen namespace based on which map the + // entry came from. Using `web_users` as the destination for now + // is a simplification: callers always rename within the same + // namespace because the source lookup guarantees the original + // map. + let target_map = if state.api_users.contains_key(&new_key) || new_key.starts_with("api:") { + // Defensive — the contains_key check above already prevented + // a clash, but keep the destination in the matching namespace + // when the username prefix signals it. + &mut state.api_users + } else { + &mut state.web_users + }; + target_map.insert(new_key, user_id.clone()); + let snapshot = state.clone(); + drop(state); + Self::write_to_disk(&self.path, &snapshot) + .await + .map_err(RegistryError::Persist)?; + Ok(Some(user_id)) + } + + /// Snapshot the current registry for diagnostics. + pub async fn snapshot(&self) -> PersistedIdentityRegistry { + self.state.read().await.clone() + } + + /// Persist the current in-memory state to disk atomically. + pub async fn save(&self) -> Result<(), RegistryError> { + let snapshot = self.state.read().await.clone(); + Self::write_to_disk(&self.path, &snapshot) + .await + .map_err(RegistryError::Persist) + } + + /// Build or overwrite the registry from a fixed list of `UserId`s. + /// Used by explicit administrator repair when the persisted file + /// is missing or corrupt and the caller knows the missing IDs. + pub async fn restore_from_user_ids( + &self, + web_users: HashMap, + api_users: HashMap, + ) -> Result<(), RegistryError> { + let state = PersistedIdentityRegistry { + version: CURRENT_REGISTRY_VERSION, + web_users, + api_users, + }; + *self.state.write().await = state.clone(); + Self::write_to_disk(&self.path, &state) + .await + .map_err(RegistryError::Persist) + } + + async fn write_to_disk(path: &Path, state: &PersistedIdentityRegistry) -> std::io::Result<()> { + let content = serde_json::to_vec_pretty(state).map_err(std::io::Error::other)?; + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent).await?; + } + } + // The atomic helper also fsyncs the temp file and the parent + // directory so the new identity registry is durable across + // crashes. The registry writes can be invoked concurrently + // (`concurrent_register_yields_distinct_ids`), so the temp + // suffix is unique per call rather than derived from the final + // filename. + let counter = IDENTITY_WRITE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let tmp = path.with_extension(format!( + "{}tmp.{}.{}", + path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(), + std::process::id(), + counter, + )); + crate::utils::atomic_json_store::write_json_atomic_to_tmp(path, &tmp, &content) + .await + .map_err(std::io::Error::other) + } + + /// Generate a 32-character hex UUID v4-shaped string. No + /// `uuid` crate is used; the bytes come from a thread-local + /// random source. + fn new_uuid_hex() -> String { + use std::cell::Cell; + use std::rc::Rc; + thread_local! { + static STATE: Rc> = Rc::new(Cell::new(0x9E37_79B9_7F4A_7C15)); + } + STATE.with(|s| { + // SplitMix64-style mixing for a deterministic-looking but + // unpredictable 64-bit value. Lower 32 bits form the first + // half of the hex string; upper 32 bits the second. + let mut z = s.get().wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z = z ^ (z >> 31); + s.set(z); + let hi = u32::try_from(z >> 32).expect("high bits fit in u32"); + let lo = u32::try_from(z & 0xFFFF_FFFF).expect("low bits fit in u32"); + format!("{hi:08x}{lo:08x}") + }) + } +} + +/// Errors that can occur when mutating the registry. +#[derive(Debug)] +pub enum RegistryError { + EmptyUsername, + /// The destination username already exists in the registry. The + /// caller must remove or rename it explicitly before retrying — + /// overwriting silently would discard the existing user's + /// persisted recordings. + UsernameExists, + Persist(std::io::Error), +} + +impl std::fmt::Display for RegistryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyUsername => f.write_str("username must not be empty"), + Self::UsernameExists => f.write_str("destination username already exists in the identity registry"), + Self::Persist(err) => write!(f, "registry persistence failed: {err}"), + } + } +} + +impl std::error::Error for RegistryError {} + +/// Canonicalize a username for the registry: trim leading/trailing +/// whitespace. Empty inputs are returned as-is so the caller can +/// reject them through a typed error. +fn canonical_username(username: &str) -> String { + username.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn web_users(n: usize) -> Vec { + (0..n).map(|i| format!("user-{i}")).collect() + } + + #[tokio::test] + async fn bootstrap_initializes_on_empty_storage() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, outcome) = IdentityRegistry::bootstrap( + path.clone(), + Vec::new(), + &web_users(3), + &[], + ) + .await; + assert!(matches!(outcome, BootstrapOutcome::Initialized)); + let snap = reg.snapshot().await; + assert_eq!(snap.web_users.len(), 3); + for u in 0..3 { + assert!(snap.web_users.contains_key(&format!("user-{u}"))); + } + // File must be persisted. + let bytes = tokio::fs::read(&path).await.expect("read"); + assert!(!bytes.is_empty()); + } + + #[tokio::test] + async fn bootstrap_restores_and_preserves_existing_ids() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + // First bootstrap creates the registry. + let (reg, _) = IdentityRegistry::bootstrap( + path.clone(), + Vec::new(), + &web_users(2), + &[], + ) + .await; + let first_id = reg.lookup_by_username("user-0").await.expect("registered"); + drop(reg); + + // Second bootstrap reads the persisted file. The existing + // `UserId` is preserved. + let (reg2, outcome) = IdentityRegistry::bootstrap( + path, + Vec::new(), + &web_users(2), + &[], + ) + .await; + assert!(matches!(outcome, BootstrapOutcome::Restored { .. })); + let second_id = reg2.lookup_by_username("user-0").await.expect("still registered"); + assert_eq!(first_id, second_id, "UserId must be stable across restarts"); + } + + #[tokio::test] + async fn bootstrap_fails_closed_when_user_ids_persist_but_registry_missing() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let pre_scanned = vec![UserId::from("web:abc"), UserId::from("web:def")]; + let (reg, outcome) = IdentityRegistry::bootstrap( + path.clone(), + pre_scanned.clone(), + &[], + &[], + ) + .await; + assert!(matches!( + outcome, + BootstrapOutcome::FailClosed { reason: FailClosedReason::Missing, .. } + )); + // The registry must remain empty (no auto-init). + assert!(reg.snapshot().await.web_users.is_empty()); + // The file must not be created by the fail-closed bootstrap. + assert!(!path.exists()); + } + + #[tokio::test] + async fn bootstrap_fails_closed_on_corrupt_registry() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + tokio::fs::write(&path, b"not json at all").await.expect("write"); + let pre_scanned = vec![UserId::from("web:abc")]; + let (_, outcome) = IdentityRegistry::bootstrap( + path, + pre_scanned.clone(), + &[], + &[], + ) + .await; + assert!(matches!( + outcome, + BootstrapOutcome::FailClosed { reason: FailClosedReason::Corrupt, .. } + )); + } + + #[tokio::test] + async fn register_returns_existing_id_for_known_username() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, _) = IdentityRegistry::bootstrap(path, Vec::new(), &[], &[]).await; + let id1 = reg.register("alice").await.expect("register"); + let id2 = reg.register("alice").await.expect("register again"); + assert_eq!(id1, id2, "register must be idempotent"); + } + + #[tokio::test] + async fn register_rejects_empty_username() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, _) = IdentityRegistry::bootstrap(path, Vec::new(), &[], &[]).await; + assert!(matches!( + reg.register("").await.unwrap_err(), + RegistryError::EmptyUsername + )); + assert!(matches!( + reg.register(" ").await.unwrap_err(), + RegistryError::EmptyUsername + )); + } + + #[tokio::test] + async fn lookup_by_username_and_id_round_trip() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, _) = IdentityRegistry::bootstrap(path, Vec::new(), &web_users(2), &[]).await; + let id = reg.lookup_by_username("user-1").await.expect("lookup"); + let username = reg.lookup_username_by_id(&id).await.expect("reverse"); + assert_eq!(username, "user-1"); + assert!(reg.lookup_by_username("missing").await.is_none()); + let other = UserId::from("web:does-not-exist"); + assert!(reg.lookup_username_by_id(&other).await.is_none()); + } + + #[tokio::test] + async fn rename_preserves_user_id() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, _) = IdentityRegistry::bootstrap(path, Vec::new(), &web_users(1), &[]).await; + let original_id = reg.lookup_by_username("user-0").await.expect("lookup"); + let renamed = reg.rename("user-0", "user-renamed").await.expect("rename"); + assert_eq!(renamed, Some(original_id.clone())); + assert!(reg.lookup_by_username("user-0").await.is_none()); + // The new username resolves to the same immutable ID. + assert_eq!(reg.lookup_by_username("user-renamed").await, Some(original_id)); + } + + #[tokio::test] + async fn rename_unknown_username_returns_none() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, _) = IdentityRegistry::bootstrap(path, Vec::new(), &[], &[]).await; + let r = reg.rename("missing", "present").await.expect("rename"); + assert!(r.is_none()); + } + + #[tokio::test] + async fn restore_from_user_ids_rebuilds_registry_for_repair() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, _) = IdentityRegistry::bootstrap(path.clone(), Vec::new(), &[], &[]).await; + let mut web = HashMap::new(); + web.insert("alice".to_string(), UserId::from("web:abc")); + web.insert("bob".to_string(), UserId::from("web:def")); + reg.restore_from_user_ids(web.clone(), HashMap::new()) + .await + .expect("restore"); + assert_eq!(reg.snapshot().await.web_users, web); + // Restore must persist. + let bytes = tokio::fs::read(&path).await.expect("read"); + assert!(!bytes.is_empty()); + } + + #[tokio::test] + async fn concurrent_register_yields_distinct_ids() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, _) = IdentityRegistry::bootstrap(path, Vec::new(), &[], &[]).await; + let reg = std::sync::Arc::new(reg); + let mut handles = Vec::new(); + for i in 0..20u64 { + let reg = std::sync::Arc::clone(®); + handles.push(tokio::spawn(async move { + reg.register(&format!("concurrent-{i}")).await + })); + } + let mut ids = std::collections::HashSet::new(); + for h in handles { + let id = h.await.expect("join").expect("register"); + assert!(ids.insert(id), "ids must be unique across concurrent registrations"); + } + } + + #[tokio::test] + async fn absent_users_remain_in_registry() { + // Retain mappings when users are temporarily absent: even + // if a bootstrap is performed with no current + // principals, existing mappings are preserved. + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("web_user_ids.json"); + let (reg, _) = IdentityRegistry::bootstrap(path.clone(), Vec::new(), &web_users(3), &[]).await; + let saved_id = reg.lookup_by_username("user-1").await.expect("lookup"); + drop(reg); + let (reg2, _) = IdentityRegistry::bootstrap(path, Vec::new(), &[], &[]).await; + assert_eq!(reg2.lookup_by_username("user-1").await, Some(saved_id)); + } +} diff --git a/backend/src/repository/m3u_playlist_iterator.rs b/backend/src/repository/m3u_playlist_iterator.rs index 91bea550b..4b6927960 100644 --- a/backend/src/repository/m3u_playlist_iterator.rs +++ b/backend/src/repository/m3u_playlist_iterator.rs @@ -277,6 +277,7 @@ impl M3uPlaylistIterator { let target_id = target.id; let proxy_type = user.proxy; let output_clusters = user.output_clusters; + let user_filter = user.t_filter.clone(); let target_options = target.options.clone(); let input_by_name: HashMap, Arc> = cfg.sources.load().inputs.iter().map(|input| (Arc::clone(&input.name), Arc::clone(input))).collect(); @@ -323,6 +324,15 @@ impl M3uPlaylistIterator { } } + // per-user content filter (plan AND user), applied post-cache + if let Some(user_filter) = &user_filter { + let pli = shared::model::PlaylistItem::from(&item); + let provider = shared::foundation::ValueProvider { pli: &pli, match_as_ascii: false }; + if !user_filter.filter(&provider) { + continue; + } + } + let rewrite_ctx = UrlRewriteContext { base_url: &base_url, username: &username, password: &password }; let item = apply_rewrite( diff --git a/backend/src/repository/mod.rs b/backend/src/repository/mod.rs index d80e36bcc..ff8bb3818 100644 --- a/backend/src/repository/mod.rs +++ b/backend/src/repository/mod.rs @@ -19,6 +19,8 @@ mod playlist_stream; mod provider_dns_repository; mod stream_history; mod qos_snapshot_repository; +pub mod identity_registry; +pub mod recording_rule_repository; pub mod stalker_repository; pub mod stalker_generation_repository; diff --git a/backend/src/repository/provider_dns_repository.rs b/backend/src/repository/provider_dns_repository.rs index fe987f75a..f796ac90a 100644 --- a/backend/src/repository/provider_dns_repository.rs +++ b/backend/src/repository/provider_dns_repository.rs @@ -54,11 +54,31 @@ impl std::fmt::Display for DnsResolvedStoreLoadError { pub async fn load_dns_resolved_store_from_path(path: &Path) -> Result, DnsResolvedStoreLoadError> { match fs::read_to_string(path).await { Ok(data) => serde_json::from_str(&data).map(Some).map_err(DnsResolvedStoreLoadError::Parse), - Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) if err.kind() == ErrorKind::NotFound => recover_dns_resolved_store_from_tmp(path).await, Err(err) => Err(DnsResolvedStoreLoadError::Read(err)), } } +/// A crashed Windows rename retry can leave only the `.json.tmp` copy behind; +/// recover it so the previously valid DNS store is not lost. +async fn recover_dns_resolved_store_from_tmp(path: &Path) -> Result, DnsResolvedStoreLoadError> { + let tmp_path = path.with_extension("json.tmp"); + let data = match fs::read_to_string(&tmp_path).await { + Ok(data) => data, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(DnsResolvedStoreLoadError::Read(err)), + }; + let store: DnsResolvedStore = serde_json::from_str(&data).map_err(DnsResolvedStoreLoadError::Parse)?; + match fs::rename(&tmp_path, path).await { + Ok(()) => info!("Recovered DNS resolved store from '{}'", tmp_path.display()), + Err(err) => warn!( + "Recovered DNS resolved store from '{}' but could not move it into place: {err}", + tmp_path.display() + ), + } + Ok(Some(store)) +} + pub async fn persist_dns_resolved_store(path: &Path, store: &DnsResolvedStore) -> Result<(), String> { let json = match serde_json::to_string_pretty(store) { Ok(value) => value, @@ -79,21 +99,46 @@ pub async fn persist_dns_resolved_store(path: &Path, store: &DnsResolvedStore) - if let Err(err) = fs::rename(&tmp_path, path).await { #[cfg(windows)] { - if fs::remove_file(path).await.is_ok() && fs::rename(&tmp_path, path).await.is_ok() { - debug!( - "Persisted DNS resolved store to '{}' (providers={})", - path.display(), - store.providers.len() - ); - return Ok(()); + // Windows cannot rename over an existing file; retry after removing the target + match fs::remove_file(path).await { + // NotFound: target vanished in the meantime, the retry can proceed + Ok(()) => {} + Err(remove_err) if remove_err.kind() == ErrorKind::NotFound => {} + Err(remove_err) => { + // Keep the temp file: the target may be unusable, tmp is the fresh copy + return Err(format!( + "rename temp file '{}' -> '{}' failed: {err}; remove target failed: {remove_err}; temp file kept", + tmp_path.display(), + path.display() + )); + } } + return match fs::rename(&tmp_path, path).await { + Ok(()) => { + debug!( + "Persisted DNS resolved store to '{}' (providers={})", + path.display(), + store.providers.len() + ); + Ok(()) + } + // Target is already deleted; keep the temp file as the only surviving copy + Err(retry_err) => Err(format!( + "rename temp file '{}' -> '{}' failed after removing target: {retry_err}; temp file kept", + tmp_path.display(), + path.display() + )), + }; + } + #[cfg(not(windows))] + { + let _ = fs::remove_file(&tmp_path).await; + return Err(format!( + "rename temp file '{}' -> '{}' failed: {err}", + tmp_path.display(), + path.display() + )); } - let _ = fs::remove_file(&tmp_path).await; - return Err(format!( - "rename temp file '{}' -> '{}' failed: {err}", - tmp_path.display(), - path.display() - )); } debug!( diff --git a/backend/src/repository/recording_rule_repository.rs b/backend/src/repository/recording_rule_repository.rs new file mode 100644 index 000000000..dad09feb9 --- /dev/null +++ b/backend/src/repository/recording_rule_repository.rs @@ -0,0 +1,118 @@ +use std::path::{Path, PathBuf}; + +use shared::model::recording_rule::{RecordingRule, RecordingRulesFile}; +use tokio::fs; +use tokio::sync::Mutex; + +static MUTATION_GUARD: Mutex<()> = Mutex::const_new(()); + +#[derive(Debug, Clone)] +pub struct RecordingRuleRepository { + path: PathBuf, +} + +impl RecordingRuleRepository { + pub fn new(storage_dir: impl AsRef) -> Self { + Self { path: storage_dir.as_ref().join("recording_rules.json") } + } + + pub async fn load(&self) -> std::io::Result { + match fs::read(&self.path).await { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(invalid_data), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(RecordingRulesFile::default()), + Err(err) => Err(err), + } + } + + pub async fn save(&self, file: &RecordingRulesFile) -> std::io::Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).await?; + } + let bytes = serde_json::to_vec_pretty(file).map_err(invalid_data)?; + crate::utils::atomic_json_store::write_json_atomic(&self.path, &bytes).await + } + + pub async fn list(&self) -> std::io::Result> { + Ok(self.load().await?.rules) + } + + pub async fn create(&self, rule: RecordingRule) -> std::io::Result { + let _guard = MUTATION_GUARD.lock().await; + let mut file = self.load().await?; + file.rules.push(rule.clone()); + self.save(&file).await?; + Ok(rule) + } + + pub async fn update(&self, rule: RecordingRule) -> std::io::Result> { + let _guard = MUTATION_GUARD.lock().await; + let mut file = self.load().await?; + let Some(existing) = file.rules.iter_mut().find(|existing| existing.id == rule.id) else { + return Ok(None); + }; + *existing = rule.clone(); + self.save(&file).await?; + Ok(Some(rule)) + } + + pub async fn delete(&self, id: &str) -> std::io::Result { + let _guard = MUTATION_GUARD.lock().await; + let mut file = self.load().await?; + let before = file.rules.len(); + file.rules.retain(|rule| rule.id != id); + if file.rules.len() == before { + return Ok(false); + } + self.save(&file).await?; + Ok(true) + } +} + +fn invalid_data(err: impl Into>) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, err) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::{ + recording_rule::{RuleBody, RuleSource, RuleVisibility}, + UserId, + }; + + fn rule(id: &str) -> RecordingRule { + RecordingRule { + id: id.to_string(), + owner_id: UserId::from("web:alice"), + visibility: RuleVisibility::Private, + enabled: true, + source: RuleSource::new("1", "2", "input"), + channel_id: None, + body: RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".to_string(), + duration_secs: 1800, + timezone: "UTC".to_string(), + }, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 1, + updated_at: 1, + } + } + + #[tokio::test] + async fn create_update_delete_round_trips_atomic_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = RecordingRuleRepository::new(dir.path()); + let created = repo.create(rule("r1")).await.expect("create"); + assert_eq!(created.id, "r1"); + assert_eq!(repo.list().await.expect("list").len(), 1); + + let mut changed = created; + changed.enabled = false; + assert!(!repo.update(changed).await.expect("update").expect("found").enabled); + assert!(repo.delete("r1").await.expect("delete")); + assert!(repo.list().await.expect("list").is_empty()); + } +} diff --git a/backend/src/repository/stream_history/async_iterator.rs b/backend/src/repository/stream_history/async_iterator.rs index 1277969c6..64be6ced6 100644 --- a/backend/src/repository/stream_history/async_iterator.rs +++ b/backend/src/repository/stream_history/async_iterator.rs @@ -45,8 +45,8 @@ impl StreamHistoryStream { /// - Pending files: fully async (`tokio::fs::File` + `AsyncStreamHistoryPendingReader`) /// - Archive files: sync via `spawn_blocking` (`lz4_flex` is sync-only) #[allow(dead_code)] - // Kept together with `StreamHistoryStream::new` for the planned streaming - // history endpoint; not currently wired into routing. + // Kept together with `StreamHistoryStream::new` for the future + // streaming-history endpoint; not currently wired into routing. #[allow(clippy::needless_pass_by_value)] async fn read_files_async( dir: &str, diff --git a/backend/src/repository/stream_history/writer.rs b/backend/src/repository/stream_history/writer.rs index 66bdf36b3..fc904f395 100644 --- a/backend/src/repository/stream_history/writer.rs +++ b/backend/src/repository/stream_history/writer.rs @@ -85,10 +85,11 @@ impl StreamHistoryWriter { pub async fn flush(&self) -> io::Result<()> { let Some(tx) = &self.tx else { return Ok(()) }; let (resp_tx, resp_rx) = oneshot::channel(); - let _ = tx.send(WriterCommand::Flush(resp_tx)).await; + if tx.send(WriterCommand::Flush(resp_tx)).await.is_err() { + return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stream history writer is not running")); + } resp_rx.await.unwrap_or_else(|_| { - log::warn!("Stream history flush: worker channel closed"); - Ok(()) + Err(io::Error::new(io::ErrorKind::BrokenPipe, "stream history writer exited before confirming flush")) }) } diff --git a/backend/src/repository/target_id_mapping.rs b/backend/src/repository/target_id_mapping.rs index ce844cc99..defa459ba 100644 --- a/backend/src/repository/target_id_mapping.rs +++ b/backend/src/repository/target_id_mapping.rs @@ -164,8 +164,9 @@ impl TargetIdMapping { // Check against in-memory record let needs_update = match self.mem_by_virtual_id.get(&virtual_id) { Some(record) => { - record.provider_id == provider_id && - (record.item_type != item_type || record.parent_virtual_id != parent_virtual_id) + record.provider_id != provider_id + || record.item_type != item_type + || record.parent_virtual_id != parent_virtual_id } None => false, // Should not happen if maps are consistent }; diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index 9ecf3b59e..131054e77 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -16,7 +16,7 @@ use std::io::Error; use std::path::{Path, PathBuf}; use tokio::task; -// V6 (current): added network_access. V1-V5 are migrated to V6 at startup +// V7 (current): added plan and filter. V1-V6 are migrated to V7 at startup // by `bplustree::run_all_startup_migrations`. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct StoredProxyUserCredentials { @@ -40,6 +40,10 @@ struct StoredProxyUserCredentials { pub soft_priority: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub network_access: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter: Option, } impl StoredProxyUserCredentials { @@ -55,19 +59,25 @@ impl StoredProxyUserCredentials { epg_request_timeshift: proxy.epg_request_timeshift.clone(), created_at: proxy.created_at, exp_date: proxy.exp_date, - max_connections: if proxy.max_connections > 0 { Some(proxy.max_connections) } else { None }, + // Persist raw (pre-plan-resolution) values; resolution re-runs on load. + max_connections: if proxy.raw_max_connections > 0 { Some(proxy.raw_max_connections) } else { None }, status: proxy.status, - output_clusters: proxy.output_clusters, + output_clusters: proxy.raw_output_clusters.unwrap_or_else(ClusterFlags::all), ui_enabled: proxy.ui_enabled, comment: proxy.comment.clone(), priority: if proxy.priority != 0 { Some(proxy.priority) } else { None }, - soft_connections: if proxy.soft_connections > 0 { Some(proxy.soft_connections) } else { None }, + soft_connections: if proxy.raw_soft_connections > 0 { Some(proxy.raw_soft_connections) } else { None }, soft_priority: if proxy.soft_priority != 0 { Some(proxy.soft_priority) } else { None }, network_access: proxy.network_access.as_ref().map(Into::into), + plan: proxy.plan.clone(), + filter: proxy.filter.clone(), } } fn to(stored: &StoredProxyUserCredentials) -> ProxyUserCredentials { + let raw_output_clusters = if stored.output_clusters.is_all() { None } else { Some(stored.output_clusters) }; + let raw_max_connections = stored.max_connections.unwrap_or_default(); + let raw_soft_connections = stored.soft_connections.unwrap_or(0); ProxyUserCredentials { username: stored.username.clone(), password: stored.password.clone(), @@ -78,16 +88,25 @@ impl StoredProxyUserCredentials { epg_request_timeshift: stored.epg_request_timeshift.clone(), created_at: stored.created_at, exp_date: stored.exp_date, - max_connections: stored.max_connections.unwrap_or_default(), + max_connections: raw_max_connections, status: stored.status, output_clusters: stored.output_clusters, ui_enabled: stored.ui_enabled, comment: stored.comment.clone(), priority: stored.priority.unwrap_or(0), - soft_connections: stored.soft_connections.unwrap_or(0), + soft_connections: raw_soft_connections, soft_priority: stored.soft_priority.unwrap_or(0), t_is_api_user: false, network_access: stored.network_access.as_ref().map(NetworkAccess::from), + plan: stored.plan.clone(), + filter: stored.filter.clone(), + raw_output_clusters, + raw_max_connections, + raw_soft_connections, + raw_proxy: if stored.plan.is_some() && stored.proxy == ProxyType::default() { None } else { Some(stored.proxy) }, + t_filter: None, + t_has_unresolved_plan: false, + t_has_invalid_filter: false, } } } @@ -524,6 +543,15 @@ mod tests { soft_priority: 0, t_is_api_user: false, network_access: None, + plan: None, + filter: None, + raw_output_clusters: None, + raw_max_connections: 1, + raw_soft_connections: 0, + raw_proxy: Some(ProxyType::Reverse(None)), + t_filter: None, + t_has_unresolved_plan: false, + t_has_invalid_filter: false, } } @@ -539,8 +567,11 @@ mod tests { Arc::new({ let mut c = make_test_credential("Test4", ProxyUserStatus::Expired); c.output_clusters = ClusterFlags::Live | ClusterFlags::Vod; + // keep raw values in sync: the serializer persists the raw fields + c.raw_output_clusters = Some(ClusterFlags::Live | ClusterFlags::Vod); c.priority = -10; c.soft_connections = 2; + c.raw_soft_connections = 2; c.soft_priority = -3; c.network_access = Some(crate::model::NetworkAccess { allowed_countries: vec!["DE".to_string(), "AT".to_string()], diff --git a/backend/src/repository/xtream_playlist_iterator.rs b/backend/src/repository/xtream_playlist_iterator.rs index 7e68b6e64..0cfafdfbe 100644 --- a/backend/src/repository/xtream_playlist_iterator.rs +++ b/backend/src/repository/xtream_playlist_iterator.rs @@ -74,6 +74,7 @@ impl XtreamPlaylistIterator { let xtream_path = xtream_path.clone(); let index_path = get_file_path_for_db_index(&xtream_path); let (tx, rx) = mpsc::channel::>(256); + let user_filter = user.t_filter.clone(); let xtream_path_for_log = xtream_path.clone(); let join_error_tx = tx.clone(); @@ -105,7 +106,7 @@ impl XtreamPlaylistIterator { } }; - if !Self::matches_filters(cluster, filter_ids.as_ref(), &item) { + if !Self::matches_filters(cluster, filter_ids.as_ref(), user_filter.as_ref(), &item) { continue; } @@ -141,7 +142,12 @@ impl XtreamPlaylistIterator { } } - fn matches_filters(cluster: XtreamCluster, filter_ids: Option<&HashSet>, item: &XtreamPlaylistItem) -> bool { + fn matches_filters( + cluster: XtreamCluster, + filter_ids: Option<&HashSet>, + user_filter: Option<&Arc>, + item: &XtreamPlaylistItem, + ) -> bool { // We can't serve episodes within series if cluster == XtreamCluster::Series && !matches!(item.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo) @@ -156,6 +162,15 @@ impl XtreamPlaylistIterator { } } + // per-user content filter (plan AND user), applied post-cache + if let Some(filter) = user_filter { + let pli = shared::model::PlaylistItem::from(item); + let provider = shared::foundation::ValueProvider { pli: &pli, match_as_ascii: false }; + if !filter.filter(&provider) { + return false; + } + } + true } } @@ -247,6 +262,51 @@ mod tests { assert!(is_cluster_allowed_for_user(&user, XtreamCluster::Series)); } + #[test] + fn matches_filters_applies_user_content_filter() { + let make_item = |group: &str| XtreamPlaylistItem { + virtual_id: 1, + provider_id: 1, + name: "name".intern(), + logo: "".intern(), + logo_small: "".intern(), + group: group.intern(), + title: "title".intern(), + parent_code: "".intern(), + rec: "".intern(), + url: "http://example.test/live.ts".intern(), + epg_channel_id: None, + xtream_cluster: XtreamCluster::Live, + additional_properties: None, + item_type: PlaylistItemType::Live, + category_id: 1, + input_name: "input".intern(), + channel_no: 0, + source_ordinal: 0, + input_stream_id: "1".intern(), + upstream_user_agent: None, + }; + let filter = std::sync::Arc::new( + shared::foundation::get_filter(r#"NOT Group ~ "^VIP.*""#, None).expect("filter parses"), + ); + + // no filter: everything passes + assert!(XtreamPlaylistIterator::matches_filters(XtreamCluster::Live, None, None, &make_item("VIP Sports"))); + // filter hides matching groups, keeps the rest + assert!(!XtreamPlaylistIterator::matches_filters( + XtreamCluster::Live, + None, + Some(&filter), + &make_item("VIP Sports") + )); + assert!(XtreamPlaylistIterator::matches_filters( + XtreamCluster::Live, + None, + Some(&filter), + &make_item("News") + )); + } + #[tokio::test] async fn empty_iterator_yields_no_items() { let mut iter = XtreamPlaylistIterator::empty(); diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index 22ccc53cc..a0b2ec648 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -1932,9 +1932,11 @@ mod tests { merge_preserved_stream_properties, needs_update_info_details, persist_input_xtream_playlist_cluster_to_disk, preserve_details_input_xtream_playlist_cluster_to_disk, preserve_details_with_injected_operation_failure, publish_staged_file_same_directory, - refresh_staging_path, DetailPreservationOperation, PreserveDetailsOutcome, XtreamRefreshLease, + DetailPreservationOperation, PreserveDetailsOutcome, XtreamRefreshLease, XtreamRefreshPaths, }; + #[cfg(unix)] + use super::refresh_staging_path; use crate::model::{ ApiProxyConfig, AppConfig, Config, ConfigInput, CustomStreamResponse, HdHomeRunConfig, MediaToolCapabilities, SourcesConfig, diff --git a/backend/src/utils/atomic_json_store.rs b/backend/src/utils/atomic_json_store.rs new file mode 100644 index 000000000..e6e1b08dc --- /dev/null +++ b/backend/src/utils/atomic_json_store.rs @@ -0,0 +1,324 @@ +//! Atomic JSON write helper with a narrowly scoped injection seam. +//! +//! Production callers use [`write_json_atomic`] which delegates to +//! [`RealAtomicWriteOps`]. Tests inject a custom [`AtomicWriteOps`] to exercise +//! failures at each stage (temp-write, file sync, rename, parent dir sync) +//! without modifying the calling code. Callers can opt into the file/parent +//! sync stages by overriding [`AtomicWriteOps::sync_file`] and +//! [`AtomicWriteOps::sync_parent`] on the provided implementation. +//! +//! Production behavior is unchanged when [`write_json_atomic`] is used: the +//! default implementations of `sync_file` and `sync_parent` are no-ops. + +use std::path::{Path, PathBuf}; +use tokio::fs; + +/// Discrete stages that can be exercised or simulated by tests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AtomicWriteStage { + WriteTemp, + SyncFile, + Rename, + SyncParent, +} + +/// Errors that wrap a caller-supplied [`std::io::Error`] with stage context. +#[derive(Debug)] +pub struct AtomicWriteError { + pub stage: AtomicWriteStage, + pub source: std::io::Error, +} + +impl AtomicWriteError { + pub fn new(stage: AtomicWriteStage, source: std::io::Error) -> Self { + Self { stage, source } + } +} + +impl std::fmt::Display for AtomicWriteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "atomic write failed at {:?}: {}", self.stage, self.source) + } +} + +impl std::error::Error for AtomicWriteError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +impl From for std::io::Error { + fn from(err: AtomicWriteError) -> Self { + std::io::Error::new(err.source.kind(), err) + } +} + +/// Filesystem operations needed for an atomic write. Default impls of the +/// sync stages are no-ops so the production code path matches the previous +/// `fs::write` + `fs::rename` behavior exactly. +pub trait AtomicWriteOps: Send + Sync { + fn write_temp( + &self, + tmp: &Path, + content: &[u8], + ) -> impl std::future::Future> + Send; + fn sync_file( + &self, + _tmp: &Path, + ) -> impl std::future::Future> + Send { + async { Ok(()) } + } + fn rename( + &self, + tmp: &Path, + final_path: &Path, + ) -> impl std::future::Future> + Send; + fn sync_parent( + &self, + _parent: &Path, + ) -> impl std::future::Future> + Send { + async { Ok(()) } + } +} + +/// Production implementation that delegates to `tokio::fs`. +pub struct RealAtomicWriteOps; + +impl AtomicWriteOps for RealAtomicWriteOps { + async fn write_temp(&self, tmp: &Path, content: &[u8]) -> Result<(), AtomicWriteError> { + fs::write(tmp, content) + .await + .map_err(|e| AtomicWriteError::new(AtomicWriteStage::WriteTemp, e)) + } + + async fn rename(&self, tmp: &Path, final_path: &Path) -> Result<(), AtomicWriteError> { + fs::rename(tmp, final_path) + .await + .map_err(|e| AtomicWriteError::new(AtomicWriteStage::Rename, e)) + } +} + +/// Build the temp file path used during the atomic write. Matches the +/// existing convention: `final.with_extension("json.tmp")` for a file ending +/// in `.json`, otherwise `final.with_extension("tmp")`. +pub fn tmp_path_for(final_path: &Path) -> PathBuf { + match final_path.extension() { + Some(ext) => { + let mut s = ext.to_os_string(); + s.push(".tmp"); + final_path.with_extension(s) + } + None => final_path.with_extension("tmp"), + } +} + +/// Atomic write with caller-supplied filesystem ops. Generic over the ops +/// type so the call site remains static dispatch. +pub async fn write_json_atomic_with_ops( + final_path: &Path, + content: &[u8], + ops: &O, +) -> Result<(), AtomicWriteError> { + let tmp = tmp_path_for(final_path); + ops.write_temp(&tmp, content).await?; + ops.sync_file(&tmp).await?; + ops.rename(&tmp, final_path).await?; + if let Some(parent) = final_path.parent() { + ops.sync_parent(parent).await?; + } + Ok(()) +} + +/// Atomic write using the production filesystem ops. Production callers should +/// use this entry point. +pub async fn write_json_atomic(final_path: &Path, content: &[u8]) -> std::io::Result<()> { + write_json_atomic_with_ops(final_path, content, &RealAtomicWriteOps) + .await + .map_err(std::io::Error::from) +} + +/// Atomic write with a caller-supplied temp file path. Use this when +/// concurrent writers need disjoint temp names — the caller is +/// responsible for producing a unique path per call. +pub async fn write_json_atomic_to_tmp( + final_path: &Path, + tmp: &Path, + content: &[u8], +) -> Result<(), AtomicWriteError> { + write_json_atomic_with_ops_to_tmp(final_path, tmp, content, &RealAtomicWriteOps).await +} + +/// Generic variant of [`write_json_atomic_to_tmp`] with caller-supplied +/// filesystem ops. +pub async fn write_json_atomic_with_ops_to_tmp( + final_path: &Path, + tmp: &Path, + content: &[u8], + ops: &O, +) -> Result<(), AtomicWriteError> { + ops.write_temp(tmp, content).await?; + ops.sync_file(tmp).await?; + ops.rename(tmp, final_path).await?; + if let Some(parent) = final_path.parent() { + ops.sync_parent(parent).await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use tempfile::TempDir; + + #[derive(Default)] + struct CountingOps { + write_temp_calls: AtomicUsize, + sync_file_calls: AtomicUsize, + rename_calls: AtomicUsize, + sync_parent_calls: AtomicUsize, + fail_at: Option, + } + + impl AtomicWriteOps for CountingOps { + async fn write_temp(&self, tmp: &Path, content: &[u8]) -> Result<(), AtomicWriteError> { + self.write_temp_calls.fetch_add(1, Ordering::SeqCst); + if self.fail_at == Some(AtomicWriteStage::WriteTemp) { + return Err(AtomicWriteError::new( + AtomicWriteStage::WriteTemp, + std::io::Error::other("write_temp injection"), + )); + } + tokio::fs::write(tmp, content).await.map_err(|e| AtomicWriteError::new(AtomicWriteStage::WriteTemp, e)) + } + + fn sync_file(&self, _tmp: &Path) -> impl std::future::Future> + Send { + self.sync_file_calls.fetch_add(1, Ordering::SeqCst); + if self.fail_at == Some(AtomicWriteStage::SyncFile) { + return std::future::ready(Err(AtomicWriteError::new( + AtomicWriteStage::SyncFile, + std::io::Error::other("sync_file injection"), + ))); + } + std::future::ready(Ok(())) + } + + async fn rename(&self, tmp: &Path, final_path: &Path) -> Result<(), AtomicWriteError> { + self.rename_calls.fetch_add(1, Ordering::SeqCst); + if self.fail_at == Some(AtomicWriteStage::Rename) { + return Err(AtomicWriteError::new( + AtomicWriteStage::Rename, + std::io::Error::other("rename injection"), + )); + } + tokio::fs::rename(tmp, final_path).await.map_err(|e| AtomicWriteError::new(AtomicWriteStage::Rename, e)) + } + + fn sync_parent(&self, _parent: &Path) -> impl std::future::Future> + Send { + self.sync_parent_calls.fetch_add(1, Ordering::SeqCst); + if self.fail_at == Some(AtomicWriteStage::SyncParent) { + return std::future::ready(Err(AtomicWriteError::new( + AtomicWriteStage::SyncParent, + std::io::Error::other("sync_parent injection"), + ))); + } + std::future::ready(Ok(())) + } + } + + fn make_ops(stage: Option) -> Arc { + let ops = CountingOps { fail_at: stage, ..Default::default() }; + Arc::new(ops) + } + + #[tokio::test] + async fn write_json_atomic_round_trip_writes_expected_content() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("downloads_state.json"); + let content = br#"{"hello":"world"}"#; + write_json_atomic(&final_path, content).await.expect("write"); + let read = tokio::fs::read(&final_path).await.expect("read"); + assert_eq!(read, content); + } + + #[tokio::test] + async fn tmp_path_for_appends_json_tmp_to_json_extension() { + let p = Path::new("/tmp/downloads_state.json"); + assert_eq!(tmp_path_for(p), Path::new("/tmp/downloads_state.json.tmp")); + } + + #[tokio::test] + async fn tmp_path_for_appends_tmp_when_no_extension() { + let p = Path::new("/tmp/state"); + assert_eq!(tmp_path_for(p), Path::new("/tmp/state.tmp")); + } + + #[tokio::test] + async fn write_temp_failure_propagates_and_skips_rename() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("downloads_state.json"); + let ops = make_ops(Some(AtomicWriteStage::WriteTemp)); + let result = write_json_atomic_with_ops(&final_path, b"x", ops.as_ref()).await; + let err = result.expect_err("write_temp should fail"); + assert_eq!(err.stage, AtomicWriteStage::WriteTemp); + assert_eq!(ops.write_temp_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.rename_calls.load(Ordering::SeqCst), 0); + assert_eq!(ops.sync_file_calls.load(Ordering::SeqCst), 0); + assert_eq!(ops.sync_parent_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn rename_failure_propagates_and_skips_parent_sync() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("downloads_state.json"); + let ops = make_ops(Some(AtomicWriteStage::Rename)); + let result = write_json_atomic_with_ops(&final_path, b"x", ops.as_ref()).await; + let err = result.expect_err("rename should fail"); + assert_eq!(err.stage, AtomicWriteStage::Rename); + assert_eq!(ops.write_temp_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.sync_file_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.rename_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.sync_parent_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn sync_file_failure_propagates_and_skips_rename() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("downloads_state.json"); + let ops = make_ops(Some(AtomicWriteStage::SyncFile)); + let result = write_json_atomic_with_ops(&final_path, b"x", ops.as_ref()).await; + let err = result.expect_err("sync_file should fail"); + assert_eq!(err.stage, AtomicWriteStage::SyncFile); + assert_eq!(ops.write_temp_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.sync_file_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.rename_calls.load(Ordering::SeqCst), 0); + assert_eq!(ops.sync_parent_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn sync_parent_failure_propagates_after_rename() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("downloads_state.json"); + let ops = make_ops(Some(AtomicWriteStage::SyncParent)); + let result = write_json_atomic_with_ops(&final_path, b"x", ops.as_ref()).await; + let err = result.expect_err("sync_parent should fail"); + assert_eq!(err.stage, AtomicWriteStage::SyncParent); + assert_eq!(ops.write_temp_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.sync_file_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.rename_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.sync_parent_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn all_four_stages_run_in_order_on_success() { + let dir = TempDir::new().expect("tempdir"); + let final_path = dir.path().join("downloads_state.json"); + let ops = make_ops(None); + write_json_atomic_with_ops(&final_path, b"x", ops.as_ref()).await.expect("write"); + assert_eq!(ops.write_temp_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.sync_file_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.rename_calls.load(Ordering::SeqCst), 1); + assert_eq!(ops.sync_parent_calls.load(Ordering::SeqCst), 1); + } +} diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index cbc2030ed..feaa3bd53 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -1,6 +1,6 @@ use crate::api::model::AppState; use crate::model::Config; -use crate::model::{ApiProxyConfig, AppConfig, MediaToolCapabilities, SourcesConfig}; +use crate::model::{ApiProxyConfig, AppConfig, MediaToolCapabilities, SourcesConfig, UserPlan}; use crate::repository::{ csv_read_inputs, csv_write_inputs, get_api_user_db_path, is_csv_file, load_api_user, }; @@ -20,7 +20,7 @@ use shared::{ foundation::prepare_templates, model::{ ApiProxyConfigDto, AppConfigDto, ConfigDto, ConfigInputAliasDto, ConfigPaths, HdHomeRunDeviceOverview, InputType, - MsgKind, PatternTemplate, SourcesConfigDto, TargetUserDto, TemplateDefinitionDto, + MsgKind, PatternTemplate, PlansConfigDto, SourcesConfigDto, TargetUserDto, TemplateDefinitionDto, UserPlanDto, }, utils::{CONSTANTS, PROVIDER_SCHEME_PREFIX}, defaults::{generate_default_access_secret, generate_default_encrypt_secret, TEMPLATE_FILE}, @@ -113,6 +113,7 @@ pub async fn read_api_proxy_config( if let Some(api_proxy_dto) = read_api_proxy_file(api_proxy_file_path, resolve_env)? { let mut errors = vec![]; let mut api_proxy: ApiProxyConfig = ApiProxyConfig::from(&api_proxy_dto); + apply_authoritative_plans(config, &mut api_proxy, resolve_env).await; api_proxy.migrate_api_user(config, &mut errors).await; if !errors.is_empty() { for error in errors { @@ -434,7 +435,14 @@ pub async fn read_app_config_dto( mapping.mappings.templates = None; } - let api_proxy = read_api_proxy_file(api_proxy_file, resolve_env).unwrap_or(None); + let api_proxy = match read_api_proxy_file(api_proxy_file, resolve_env) { + Ok(api_proxy) => api_proxy, + Err(err) => { + // Surface the fault instead of silently returning a config without api_proxy + log::warn!("Failed to read api-proxy config '{api_proxy_file}': {err}"); + None + } + }; Ok(AppConfigDto { config, @@ -699,8 +707,9 @@ pub fn read_api_proxy_file( match maybe_api_proxy { Ok(mut api_proxy_dto) => { if resolve_env { + // A recoverable error keeps the last good config alive during hot reload if let Err(err) = api_proxy_dto.prepare() { - exit!("can't read api-proxy-config file: {err}"); + return Err(TuliproxError::Config(format!("can't read api-proxy-config file: {err}"))); } } Ok(Some(api_proxy_dto)) @@ -717,7 +726,8 @@ pub async fn read_api_proxy(config: &AppConfig, resolve_env: bool) -> Option { let mut errors = vec![]; - let mut api_proxy: ApiProxyConfig = api_proxy_dto.into(); + let mut api_proxy: ApiProxyConfig = ApiProxyConfig::from(&api_proxy_dto); + apply_authoritative_plans(config, &mut api_proxy, resolve_env).await; api_proxy.migrate_api_user(config, &mut errors).await; if !errors.is_empty() { for error in errors { @@ -842,6 +852,105 @@ pub async fn save_api_proxy( write_config_file(file_path, backup_dir, config, "api-proxy.yml").await } +/// Path of the standalone plans file, sibling to api-proxy.yml. +pub fn plans_file_path(api_proxy_file_path: &str) -> PathBuf { + let parent = Path::new(api_proxy_file_path).parent(); + let candidates = ["plans.yml", "plans.yaml", "plan.yml", "plan.yaml"]; + if let Some(dir) = parent { + for candidate in candidates { + let p = dir.join(candidate); + if p.exists() { + return p; + } + } + dir.join("plans.yml") + } else { + for candidate in candidates { + let p = PathBuf::from(candidate); + if p.exists() { + return p; + } + } + PathBuf::from("plans.yml") + } +} + +pub fn read_plans_file(plans_file: &str, resolve_env: bool) -> Result, TuliproxError> { + open_file(&PathBuf::from(plans_file)).map_or(Ok(None), |file| { + let parsed: Result = serde_saphyr::from_reader(config_file_reader(file, resolve_env)); + match parsed { + Ok(mut dto) => { + if resolve_env { + if let Err(err) = dto.prepare() { + return Err(TuliproxError::Config(format!("can't read plans file: {err}"))); + } + } + Ok(Some(dto)) + } + Err(err) => Err(TuliproxError::Config(format!("can't read plans file: {err}"))), + } + }) +} + +pub async fn save_plans(file_path: &str, backup_dir: &str, config: &PlansConfigDto) -> Result<(), TuliproxError> { + write_config_file(file_path, backup_dir, config, "plans.yml").await +} + +/// Load plans from the standalone plans.yml onto the runtime api-proxy config +/// and re-resolve users. Migrates legacy plans embedded in api-proxy.yml to +/// plans.yml the first time it runs. +async fn apply_authoritative_plans(config: &AppConfig, api_proxy: &mut ApiProxyConfig, resolve_env: bool) { + let plans_path = { + let paths = config.paths.load(); + plans_file_path(paths.api_proxy_file_path.as_str()) + }; + let plans_path_str = plans_path.to_string_lossy().to_string(); + match read_plans_file(&plans_path_str, resolve_env) { + Ok(Some(plans_dto)) => { + let plans = plans_dto.plans.iter().map(|plan| Arc::new(UserPlan::from(plan))).collect(); + api_proxy.set_plans(plans); + } + Ok(None) => { + if let Some(legacy_plans) = read_legacy_api_proxy_plans(config, resolve_env) { + let migrated = PlansConfigDto { plans: legacy_plans.clone() }; + let backup_dir = { + let cfg = config.config.load(); + cfg.get_backup_dir().to_string() + }; + match save_plans(&plans_path_str, &backup_dir, &migrated).await { + Ok(()) => info!( + "Migrated {} user plan(s) from api-proxy.yml to {plans_path_str}", + migrated.plans.len() + ), + Err(err) => error!("Failed to migrate plans to {plans_path_str}: {err}"), + } + let plans = legacy_plans.iter().map(|plan| Arc::new(UserPlan::from(plan))).collect(); + api_proxy.set_plans(plans); + } + } + Err(err) => error!("{err}"), + } +} + +/// Read legacy plans still embedded in api-proxy.yml (pre plans.yml split). +fn read_legacy_api_proxy_plans(config: &AppConfig, resolve_env: bool) -> Option> { + #[derive(serde::Deserialize)] + struct LegacyApiProxyPlans { + #[serde(default)] + plans: Vec, + } + let api_proxy_file = { + let paths = config.paths.load(); + paths.api_proxy_file_path.clone() + }; + let file = open_file(&PathBuf::from(api_proxy_file.as_str())).ok()?; + let parsed: Result = serde_saphyr::from_reader(config_file_reader(file, resolve_env)); + match parsed { + Ok(dto) if !dto.plans.is_empty() => Some(dto.plans), + _ => None, + } +} + pub async fn save_main_config( file_path: &str, backup_dir: &str, diff --git a/backend/src/utils/file/file_utils.rs b/backend/src/utils/file/file_utils.rs index cc549ddd3..6aeccb179 100644 --- a/backend/src/utils/file/file_utils.rs +++ b/backend/src/utils/file/file_utils.rs @@ -1,5 +1,5 @@ use crate::utils::debug_if_enabled; -use log::{debug, error, trace}; +use log::{debug, error, trace, warn}; use path_clean::PathClean; use shared::{ error::str_to_io_error, @@ -269,11 +269,18 @@ pub fn prepare_persist_path(file_name: &str, date_prefix: &str) -> PathBuf { } pub fn get_file_path(wd: &str, path: Option) -> Option { - path.map(|p| if p.is_relative() { - let pb = PathBuf::from(wd); - pb.join(&p).clean() + path.and_then(|p| if p.is_relative() { + let base = PathBuf::from(wd).clean(); + let joined = base.join(&p).clean(); + // Relative segments like ../ must not escape the working directory + if joined.starts_with(&base) { + Some(joined) + } else { + warn!("Relative path escapes working directory and is ignored: {}", p.display()); + None + } } else { - p + Some(p) }) } diff --git a/backend/src/utils/logging.rs b/backend/src/utils/logging.rs index 2a8831682..a3c00e6c9 100644 --- a/backend/src/utils/logging.rs +++ b/backend/src/utils/logging.rs @@ -4,10 +4,48 @@ use chrono::{Local, Offset, SecondsFormat}; use env_logger::{Builder, Logger, Target}; use log::{info, LevelFilter, Log, Metadata, Record, SetLoggerError}; use parking_lot::RwLock; +use shared::model::LogEntry; +use std::collections::VecDeque; use std::fs::File; use std::io::Write; use std::sync::OnceLock; +pub const LOG_BUFFER_CAPACITY: usize = 1000; +const BROADCAST_CAPACITY: usize = 2048; + +static LOG_BUFFER: OnceLock>> = OnceLock::new(); +static LOG_BROADCASTER: OnceLock> = OnceLock::new(); + +fn get_log_buffer() -> &'static RwLock> { + LOG_BUFFER.get_or_init(|| RwLock::new(VecDeque::with_capacity(LOG_BUFFER_CAPACITY))) +} + +fn get_log_broadcaster() -> &'static tokio::sync::broadcast::Sender { + LOG_BROADCASTER.get_or_init(|| { + let (tx, _rx) = tokio::sync::broadcast::channel(BROADCAST_CAPACITY); + tx + }) +} + +pub fn push_log_entry(entry: LogEntry) { + { + let mut buffer = get_log_buffer().write(); + if buffer.len() >= LOG_BUFFER_CAPACITY { + buffer.pop_front(); + } + buffer.push_back(entry.clone()); + } + let _ = get_log_broadcaster().send(entry); +} + +pub fn get_log_history() -> Vec { + get_log_buffer().read().iter().cloned().collect() +} + +pub fn subscribe_logs() -> tokio::sync::broadcast::Receiver { + get_log_broadcaster().subscribe() +} + const LOG_ERROR_LEVEL_MOD: &[&str] = &[ "reqwest", "hyper_util", @@ -66,6 +104,16 @@ impl Log for ReloadableLogger { fn log(&self, record: &Record<'_>) { self.inner.read().log(record); + + let now = Local::now(); + let timestamp = now.to_rfc3339_opts(SecondsFormat::Secs, now.offset().fix().local_minus_utc() == 0); + let entry = LogEntry { + timestamp, + level: record.level().into(), + target: record.target().to_string(), + message: record.args().to_string(), + }; + push_log_entry(entry); } fn flush(&self) { @@ -237,4 +285,43 @@ mod tests { assert!(reloadable.enabled(&metadata)); } + + #[test] + fn test_log_buffer_eviction_and_history() { + for i in 0..LOG_BUFFER_CAPACITY + 10 { + push_log_entry(LogEntry::new("ts", shared::model::LogLevel::Info, "target", format!("msg {i}"))); + } + let history = get_log_history(); + assert_eq!(history.len(), LOG_BUFFER_CAPACITY); + assert_eq!(history.last().unwrap().message, format!("msg {}", LOG_BUFFER_CAPACITY + 9)); + assert_eq!(history.first().unwrap().message, "msg 10"); + } + + #[tokio::test] + async fn test_log_broadcast_subscription() { + // The broadcast channel is process-global; parallel tests that + // push entries (e.g. `test_log_buffer_eviction_and_history` + // flooding 3 600 messages) can land ahead of ours on the + // shared `Receiver`. Mark our entry uniquely and drain until + // we see it. + let mut rx = subscribe_logs(); + let test_entry = LogEntry::new( + "ts", + shared::model::LogLevel::Warn, + "test_target", + "broadcast test unique marker", + ); + push_log_entry(test_entry.clone()); + + let mut attempts = 0; + let received = loop { + let entry = rx.recv().await.expect("broadcast channel closed"); + if entry.message == test_entry.message { + break entry; + } + attempts += 1; + assert!(attempts < 10_000, "test entry never arrived on the broadcast channel"); + }; + assert_eq!(received, test_entry); + } } diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs index 6e93a7b97..e679394b3 100644 --- a/backend/src/utils/mod.rs +++ b/backend/src/utils/mod.rs @@ -7,8 +7,10 @@ mod step_measure; mod logging; mod trakt; mod json_utils; +pub(crate) mod atomic_json_store; mod hls_duration; mod provider_resolve_token; +mod recording_paths; mod binary_utils; mod telegram; pub(crate) mod geoip; @@ -85,6 +87,8 @@ pub use self::compression::*; pub use self::crypto_utils::*; pub use self::file::*; pub use self::json_utils::*; +pub use self::atomic_json_store::*; +pub use self::recording_paths::*; pub use self::network::*; pub use self::provider_resolve_token::*; pub use self::step_measure::*; diff --git a/backend/src/utils/network/epg.rs b/backend/src/utils/network/epg.rs index 0a8b8e7cd..2a528596b 100644 --- a/backend/src/utils/network/epg.rs +++ b/backend/src/utils/network/epg.rs @@ -4,7 +4,7 @@ use crate::model::{ use crate::processing::processor::PlaylistProcessingContext; use crate::repository::get_input_storage_path; use crate::utils::{add_prefix_to_filename, prepare_file_path, request}; -use log::debug; +use log::{debug, warn}; use shared::concat_string; use shared::error::TuliproxError; use shared::utils::{sanitize_sensitive_info, short_hash}; @@ -202,8 +202,11 @@ pub async fn get_xmltv( } } - let _ = cleanup_unlisted_epg_files(&ctx.config.file_locks, &stored_file_paths, "_epg.xml").await; - let _ = cleanup_unlisted_epg_files(&ctx.config.file_locks, &stored_file_paths, "_epg.ics").await; + for suffix in ["_epg.xml", "_epg.ics"] { + if let Err(err) = cleanup_unlisted_epg_files(&ctx.config.file_locks, &stored_file_paths, suffix).await { + warn!("Failed to clean up stale {suffix} files: {err}"); + } + } if file_paths.is_empty() { (None, errors) diff --git a/backend/src/utils/network/ip_checker.rs b/backend/src/utils/network/ip_checker.rs index 18ddde738..88c103033 100644 --- a/backend/src/utils/network/ip_checker.rs +++ b/backend/src/utils/network/ip_checker.rs @@ -37,10 +37,26 @@ async fn fetch_combined_ips( config: &IpCheckConfig, url: &str, ) -> (Option, Option) { - let response = client.get(url).send().await.ok(); - let text = match response { - Some(r) => r.text().await.ok(), - None => None, + let text = match client.get(url).send().await { + Ok(response) => match response.text().await { + Ok(body) => Some(body), + Err(err) => { + log::warn!( + "IP check: failed to read response body from {}: {}", + sanitize_sensitive_info(url), + sanitize_sensitive_info(&err.to_string()) + ); + None + } + }, + Err(err) => { + log::warn!( + "IP check: request to {} failed: {}", + sanitize_sensitive_info(url), + sanitize_sensitive_info(&err.to_string()) + ); + None + } }; if let Some(body) = text { diff --git a/backend/src/utils/recording_paths.rs b/backend/src/utils/recording_paths.rs new file mode 100644 index 000000000..a76252b35 --- /dev/null +++ b/backend/src/utils/recording_paths.rs @@ -0,0 +1,575 @@ +//! Secure recording-path and file-operation helpers. +//! +//! The security properties this module guarantees — no symlink is ever +//! followed, no existing file is ever clobbered, the final rename is +//! atomic, and nothing escapes the recording root — are built from +//! portable primitives: `symlink_metadata` for no-follow inspection, +//! `create_new` for exclusive creation, and `rename` for the atomic +//! publish. The strict `openat2` with +//! `RESOLVE_BENEATH`/`RESOLVE_NO_SYMLINKS` would be Linux-only, so it is +//! deliberately not used. +//! +//! Portability: only [`open_partial_no_clobber`] has a platform-specific +//! branch, and only for defense in depth — see its docs. Everything else +//! compiles and behaves identically on every supported target. This +//! module used to carry a blanket `#![cfg(unix)]`, which erased it +//! wholesale on Windows and left every caller (`recording_deletion`, +//! `recording_media_api`, `recording_worker`) with unresolved imports — +//! i.e. the DVR did not build on Windows at all. + +use std::io; +use std::path::{Component, Path, PathBuf}; +use tokio::fs; + +/// Visibility for a recording directory layout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecordingVisibility { + Private, + Shared, +} + +/// Errors that can occur when handling a recording path. +#[derive(Debug)] +pub enum RecordingPathError { + Empty, + Absolute, + InvalidComponent, + NulByte, + NotARegularFile, + NotWithinRoot, + AlreadyExists, + Io(io::Error), +} + +impl std::fmt::Display for RecordingPathError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Empty => f.write_str("path is empty"), + Self::Absolute => f.write_str("path is absolute"), + Self::InvalidComponent => f.write_str("path contains '.' or '..' or other invalid component"), + Self::NulByte => f.write_str("path contains a NUL byte"), + Self::NotARegularFile => f.write_str("path is not a regular file"), + Self::NotWithinRoot => f.write_str("path is not within the recording root"), + Self::AlreadyExists => f.write_str("path already exists"), + Self::Io(err) => write!(f, "io error: {err}"), + } + } +} + +impl std::error::Error for RecordingPathError {} + +impl From for RecordingPathError { + fn from(err: io::Error) -> Self { Self::Io(err) } +} + +impl From for io::Error { + fn from(err: RecordingPathError) -> Self { + match err { + RecordingPathError::Io(e) => e, + other => io::Error::other(other), + } + } +} + +/// Validate a relative recording path. Rejects absolute paths, parent +/// traversal, current-directory components, and NUL bytes. The path +/// must be non-empty and consist only of normal components. +pub fn validate_relative_path(path: &Path) -> Result<(), RecordingPathError> { + let s = path.as_os_str(); + if s.is_empty() { + return Err(RecordingPathError::Empty); + } + if s.as_encoded_bytes().contains(&0) { + return Err(RecordingPathError::NulByte); + } + if path.is_absolute() { + return Err(RecordingPathError::Absolute); + } + let mut saw_component = false; + for c in path.components() { + match c { + Component::Normal(_) => saw_component = true, + Component::CurDir | Component::ParentDir | Component::Prefix(_) | Component::RootDir => { + return Err(RecordingPathError::InvalidComponent); + } + } + } + if !saw_component { + return Err(RecordingPathError::Empty); + } + Ok(()) +} + +/// Validate that a relative path stays inside a canonicalized root. +pub async fn assert_within_root(rel: &Path, root: &Path) -> Result<(), RecordingPathError> { + let joined = root.join(rel); + let canonical_root = fs::canonicalize(root).await.map_err(RecordingPathError::from)?; + let canonical_joined = fs::canonicalize(&joined).await.map_err(RecordingPathError::from)?; + if !canonical_joined.starts_with(&canonical_root) { + return Err(RecordingPathError::NotWithinRoot); + } + Ok(()) +} + +/// Inspect a path without following symlinks. Returns `Some(metadata)` for +/// any path that exists at the location, including symlinks, directories, +/// and regular files. The caller can inspect the metadata to choose +/// the matching policy. Returns `None` only for missing entries. +pub async fn no_follow_existing(path: &Path) -> Option { + fs::symlink_metadata(path).await.ok() +} + +/// Inspect a path without following symlinks. Returns `Some(metadata)` only +/// for regular files; directories, symlinks, sockets, devices, and +/// missing entries all return `None`. +pub async fn no_follow_regular_file(path: &Path) -> Option { + let meta = fs::symlink_metadata(path).await.ok()?; + if !meta.is_file() { + return None; + } + Some(meta) +} + +/// Walk from `root` to `target` one component at a time, asserting via +/// `symlink_metadata` that no intermediate directory is a symlink, and +/// that the final path resolves to a regular file. Returns the final +/// metadata on success. +/// +/// `no_follow_regular_file` only checks the leaf. If a sibling such as +/// `/users/alice -> /etc` is a symlink, a path built by joining +/// `/users/alice/file.ts` would resolve through that link to +/// `/etc/alice/file.ts` and bypass the lexical containment of +/// `resolve_recording_dir`. Closing that hole is the whole point of +/// this helper: callers that serve media under a configured root must +/// invoke it before opening the file, not the leaf-only variant. +/// +/// `target` must lie under `root`; this is the caller's responsibility +/// (`resolve_recording_dir` enforces it). Each prefix is checked with +/// `symlink_metadata` so the cost is one stat per directory. +pub async fn no_follow_path_in_root( + root: &Path, + target: &Path, +) -> Option { + let relative = target.strip_prefix(root).ok()?; + if relative.as_os_str().is_empty() { + // `target == root` would open a directory, not a file. + return None; + } + let mut current = root.to_path_buf(); + for component in relative.components() { + use std::path::Component; + match component { + Component::Normal(_) | Component::CurDir => {} + Component::ParentDir | Component::Prefix(_) | Component::RootDir => return None, + } + current.push(component); + let meta = fs::symlink_metadata(¤t).await.ok()?; + if meta.file_type().is_symlink() { + return None; + } + if current != target && !meta.is_dir() { + return None; + } + } + let final_meta = fs::symlink_metadata(target).await.ok()?; + if final_meta.file_type().is_symlink() || !final_meta.is_file() { + return None; + } + Some(final_meta) +} + +/// Open a new partial file with no-clobber, no-follow semantics suitable +/// for ffmpeg to write into. +/// +/// `create_new(true)` carries the security property on every platform: it +/// maps to `O_CREAT | O_EXCL` on Unix and `CREATE_NEW` on Windows, and +/// both fail when *anything* already exists at the path — including a +/// symlink, even a dangling one. So a pre-existing file and an +/// attacker-planted symlink are both rejected without any +/// platform-specific code. +/// +/// On Unix we additionally pass `O_NOFOLLOW`. That is defense in depth, +/// not the mechanism: it makes the kernel refuse the open outright rather +/// than relying on the exclusivity check, so a future edit that weakens +/// `create_new` cannot silently open through a link. +pub async fn open_partial_no_clobber(path: &Path) -> Result { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + // `libc` is a `cfg(unix)` dependency, so this cannot be written + // as a cross-platform expression. + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(path).await?; + Ok(file) +} + +/// Finalize a partial file to its final path. The final path must not +/// exist at all (no regular file, no symlink, no directory) — we inspect +/// the location without following symlinks so an attacker-prepared +/// symlink is treated as a collision. The rename is then atomic. If +/// the partial is missing, `fs::rename` returns `NotFound` so the +/// caller can surface a visible failure rather than silently producing +/// an empty final file. +pub async fn finalize_no_replace(partial: &Path, final_path: &Path) -> Result<(), RecordingPathError> { + if no_follow_existing(final_path).await.is_some() { + return Err(RecordingPathError::AlreadyExists); + } + fs::rename(partial, final_path).await?; + Ok(()) +} + +/// Unlink a file at the given path. Missing files are treated as +/// success so the call is idempotent; the operator's intent (file +/// gone) is satisfied either way. +pub async fn safe_unlink(path: &Path) -> Result<(), RecordingPathError> { + match fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } +} + +/// Clean up empty parent directories between `path` and `root`. The +/// walk starts at `path` itself (so a deleted empty directory is +/// cleaned) and walks up to but not including the canonicalized root. +/// Removal stops on the first non-empty, non-existent, or +/// permission-denied directory. Other I/O errors propagate but do not +/// undo any successful removal. +pub async fn clean_empty_parents(path: &Path, root: &Path) -> Result<(), RecordingPathError> { + let canonical_root = fs::canonicalize(root).await.ok(); + let mut current: Option<&Path> = Some(path); + while let Some(dir) = current { + let dir_for_check = dir; + let mut stop_here = false; + if let Some(r) = canonical_root.as_ref() { + if fs::canonicalize(dir_for_check) + .await + .is_ok_and(|c| c == *r) + { + stop_here = true; + } + } + if stop_here { + break; + } + match fs::remove_dir(dir).await { + Ok(()) => current = dir.parent(), + Err(err) if err.kind() == io::ErrorKind::NotFound => current = dir.parent(), + Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => break, + Err(err) if err.kind() == io::ErrorKind::PermissionDenied => break, + Err(err) => return Err(err.into()), + } + } + Ok(()) +} + +/// Resolve the canonical directory layout for a recording's final path: +/// `/users//` for `Private` and +/// `/shared/` for `Shared`. The relative path must +/// already be validated. +pub fn resolve_recording_dir( + recording_root: &Path, + visibility: RecordingVisibility, + owner_id: &str, + rel: &Path, +) -> Result { + validate_relative_path(rel)?; + validate_owner_id(owner_id)?; + let base = match visibility { + RecordingVisibility::Private => recording_root.join("users").join(owner_id), + RecordingVisibility::Shared => recording_root.join("shared"), + }; + Ok(base.join(rel)) +} + +/// Validate that an `owner_id` can be safely used as a single path +/// component. Rejects empty strings, NUL bytes, path separators, and +/// the traversal components `.` and `..` so the resulting layout can +/// never escape the configured recording root. +fn validate_owner_id(owner_id: &str) -> Result<(), RecordingPathError> { + use std::path::Component; + if owner_id.is_empty() || owner_id.contains('\0') { + return Err(RecordingPathError::InvalidComponent); + } + let path = std::path::Path::new(owner_id); + let components: Vec<_> = path.components().collect(); + if components.len() != 1 || !matches!(components.first(), Some(Component::Normal(_))) { + return Err(RecordingPathError::InvalidComponent); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + /// Create a symlink at `link` pointing at `target`. + /// + /// Only the symlink-specific tests are Unix-gated; the rest of the + /// suite is portable and must keep running on Windows. Creating a + /// symlink on Windows needs either developer mode or elevation, so + /// gating the assertions is the honest option — the behaviour they + /// cover (`symlink_metadata` not following links, `create_new` + /// refusing an existing entry) is provided by the standard library on + /// both platforms. + #[cfg(unix)] + fn symlink(target: &Path, link: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link) + } + + #[test] + fn validate_relative_path_accepts_simple_path() { + validate_relative_path(Path::new("a/b/c.ts")).expect("accept"); + } + + #[test] + fn validate_relative_path_rejects_empty() { + assert!(matches!(validate_relative_path(Path::new("")).unwrap_err(), RecordingPathError::Empty)); + } + + #[test] + fn validate_relative_path_rejects_absolute() { + assert!(matches!( + validate_relative_path(Path::new("/etc/passwd")).unwrap_err(), + RecordingPathError::Absolute + )); + } + + #[test] + fn validate_relative_path_rejects_parent_traversal() { + assert!(matches!( + validate_relative_path(Path::new("../escape.ts")).unwrap_err(), + RecordingPathError::InvalidComponent + )); + } + + #[test] + fn validate_relative_path_rejects_nul_byte() { + let bad = std::ffi::OsString::from("a\0b"); + assert!(matches!( + validate_relative_path(Path::new(&bad)).unwrap_err(), + RecordingPathError::NulByte + )); + } + + #[tokio::test] + async fn no_follow_regular_file_returns_none_for_missing_path() { + let dir = TempDir::new().expect("tempdir"); + let missing = dir.path().join("does-not-exist.ts"); + assert!(no_follow_regular_file(&missing).await.is_none()); + } + + #[tokio::test] + async fn no_follow_path_in_root_returns_some_for_clean_layout() { + let dir = TempDir::new().expect("tempdir"); + let users = dir.path().join("users"); + let owner = users.join("alice"); + let leaf = owner.join("recording.ts"); + tokio::fs::create_dir_all(&owner).await.expect("mkdir"); + tokio::fs::write(&leaf, b"data").await.expect("write"); + let meta = no_follow_path_in_root(dir.path(), &leaf).await.expect("clean layout"); + assert!(meta.is_file()); + } + + #[cfg(unix)] + #[tokio::test] + async fn no_follow_path_in_root_rejects_symlink_owner_directory() { + // `/users/alice -> /etc` would otherwise resolve + // `/users/alice/file.ts` to `/etc/file.ts` and bypass + // the lexical containment of `resolve_recording_dir`. + let dir = TempDir::new().expect("tempdir"); + let users = dir.path().join("users"); + let outside = dir.path().join("outside.ts"); + tokio::fs::create_dir_all(&users).await.expect("mkdir"); + tokio::fs::write(&outside, b"data").await.expect("write"); + let alice_link = users.join("alice"); + symlink(&outside, &alice_link).expect("symlink"); + let target = alice_link.join("file.ts"); + assert!(no_follow_path_in_root(dir.path(), &target).await.is_none()); + } + + #[cfg(unix)] + #[tokio::test] + async fn no_follow_path_in_root_rejects_symlink_leaf() { + let dir = TempDir::new().expect("tempdir"); + let users = dir.path().join("users").join("alice"); + tokio::fs::create_dir_all(&users).await.expect("mkdir"); + let outside = dir.path().join("outside.ts"); + tokio::fs::write(&outside, b"data").await.expect("write"); + let leaf_link = users.join("file.ts"); + symlink(&outside, &leaf_link).expect("symlink"); + assert!(no_follow_path_in_root(dir.path(), &leaf_link).await.is_none()); + } + + #[cfg(unix)] + #[tokio::test] + async fn no_follow_regular_file_rejects_symlink() { + let dir = TempDir::new().expect("tempdir"); + let real = dir.path().join("real.ts"); + tokio::fs::write(&real, b"hello").await.expect("write"); + let link_path = dir.path().join("link.ts"); + symlink(&real, &link_path).expect("symlink"); + // `symlink_metadata` returns the link itself; the type is + // not a regular file. The helper must not follow. + assert!(no_follow_regular_file(&link_path).await.is_none()); + } + + #[tokio::test] + async fn no_follow_regular_file_rejects_directory() { + let dir = TempDir::new().expect("tempdir"); + assert!(no_follow_regular_file(dir.path()).await.is_none(), "directory must not pass as regular file"); + } + + #[tokio::test] + async fn open_partial_no_clobber_succeeds_for_fresh_path() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("rec.partial.ts"); + let _file = open_partial_no_clobber(&path).await.expect("create"); + assert!(path.exists()); + } + + #[tokio::test] + async fn open_partial_no_clobber_fails_when_file_exists() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("rec.partial.ts"); + tokio::fs::write(&path, b"already here").await.expect("write"); + let result = open_partial_no_clobber(&path).await; + assert!(result.is_err(), "open must fail on existing file"); + assert!(matches!(result.unwrap_err(), RecordingPathError::Io(err) if err.kind() == io::ErrorKind::AlreadyExists)); + // A refused open must not have truncated what was there. This is + // the portable half of the no-clobber guarantee: `create_new` + // carries it on every target, which is why the `O_NOFOLLOW` flag + // can be Unix-only without weakening the contract. + assert_eq!(tokio::fs::read(&path).await.expect("read"), b"already here"); + } + + #[cfg(unix)] + #[tokio::test] + async fn open_partial_no_clobber_fails_when_path_is_symlink() { + let dir = TempDir::new().expect("tempdir"); + let real = dir.path().join("real"); + tokio::fs::write(&real, b"data").await.expect("write"); + let link_path = dir.path().join("link"); + symlink(&real, &link_path).expect("symlink"); + let result = open_partial_no_clobber(&link_path).await; + assert!(result.is_err(), "open must fail on symlink target"); + } + + #[tokio::test] + async fn finalize_no_replace_succeeds_for_missing_final() { + let dir = TempDir::new().expect("tempdir"); + let partial = dir.path().join("rec.partial.ts"); + let final_path = dir.path().join("rec.ts"); + tokio::fs::write(&partial, b"recorded").await.expect("write partial"); + finalize_no_replace(&partial, &final_path).await.expect("finalize"); + assert!(!partial.exists(), "partial must be gone after rename"); + assert_eq!(tokio::fs::read(&final_path).await.expect("read"), b"recorded"); + } + + #[tokio::test] + async fn finalize_no_replace_refuses_when_final_already_exists() { + let dir = TempDir::new().expect("tempdir"); + let partial = dir.path().join("rec.partial.ts"); + let final_path = dir.path().join("rec.ts"); + tokio::fs::write(&partial, b"new").await.expect("write partial"); + tokio::fs::write(&final_path, b"existing").await.expect("write final"); + let result = finalize_no_replace(&partial, &final_path).await; + assert!(matches!(result.unwrap_err(), RecordingPathError::AlreadyExists)); + assert!(partial.exists(), "partial must remain when finalize is refused"); + assert_eq!(tokio::fs::read(&final_path).await.expect("read"), b"existing"); + } + + #[cfg(unix)] + #[tokio::test] + async fn finalize_no_replace_refuses_when_final_is_symlink() { + // An externally created symlink at the final path counts + // as a collision. The helper must refuse to clobber it. + let dir = TempDir::new().expect("tempdir"); + let partial = dir.path().join("rec.partial.ts"); + let real = dir.path().join("attacker-target"); + let final_path = dir.path().join("rec.ts"); + tokio::fs::write(&partial, b"new").await.expect("write partial"); + tokio::fs::write(&real, b"data").await.expect("write attacker target"); + symlink(&real, &final_path).expect("symlink final"); + let result = finalize_no_replace(&partial, &final_path).await; + assert!(matches!(result.unwrap_err(), RecordingPathError::AlreadyExists)); + } + + #[tokio::test] + async fn safe_unlink_is_idempotent_for_missing_file() { + let dir = TempDir::new().expect("tempdir"); + let missing = dir.path().join("missing.ts"); + safe_unlink(&missing).await.expect("missing is success"); + } + + #[tokio::test] + async fn safe_unlink_removes_existing_file() { + let dir = TempDir::new().expect("tempdir"); + let path = dir.path().join("rec.ts"); + tokio::fs::write(&path, b"x").await.expect("write"); + safe_unlink(&path).await.expect("unlink"); + assert!(!path.exists()); + } + + #[tokio::test] + async fn safe_unlink_refuses_directories() { + let dir = TempDir::new().expect("tempdir"); + let result = safe_unlink(dir.path()).await; + assert!(result.is_err(), "must not unlink a directory"); + } + + #[tokio::test] + async fn clean_empty_parents_removes_empty_subdirs_but_stops_at_root() { + let dir = TempDir::new().expect("tempdir"); + let nested = dir.path().join("a").join("b").join("c"); + tokio::fs::create_dir_all(&nested).await.expect("mkdir"); + let leaf = nested.join("leaf.ts"); + tokio::fs::write(&leaf, b"x").await.expect("write"); + tokio::fs::remove_file(&leaf).await.expect("remove leaf"); + // The walk starts at `path.parent()` (i.e. `/a/b`); `c` is + // never cleaned because it is the path itself. We assert the + // empty intermediates are gone and the root remains. + clean_empty_parents(&nested, dir.path()).await.expect("clean"); + assert!(!dir.path().join("a").join("b").exists(), "empty b/ must be removed"); + assert!(!dir.path().join("a").exists(), "empty a/ must be removed"); + assert!(dir.path().exists(), "root must remain"); + } + + #[tokio::test] + async fn clean_empty_parents_stops_at_non_empty_directory() { + let dir = TempDir::new().expect("tempdir"); + let nested = dir.path().join("a").join("b"); + tokio::fs::create_dir_all(&nested).await.expect("mkdir"); + tokio::fs::write(nested.join("sibling.ts"), b"keep").await.expect("write"); + clean_empty_parents(&nested.join("empty"), dir.path()).await.expect("clean"); + // The non-empty `a/` must remain because it still has `b/`. + assert!(dir.path().join("a").exists(), "non-empty parent must remain"); + } + + #[test] + fn resolve_recording_dir_lays_out_private_and_shared() { + let root = Path::new("/var/recordings"); + let private = + resolve_recording_dir(root, RecordingVisibility::Private, "web:abc", Path::new("2025/pilot.ts")) + .expect("private"); + assert_eq!(private, PathBuf::from("/var/recordings/users/web:abc/2025/pilot.ts")); + let shared = resolve_recording_dir(root, RecordingVisibility::Shared, "ignored", Path::new("pilot.ts")) + .expect("shared"); + assert_eq!(shared, PathBuf::from("/var/recordings/shared/pilot.ts")); + } + + #[test] + fn resolve_recording_dir_rejects_traversal_in_relative() { + let err = resolve_recording_dir( + Path::new("/var/recordings"), + RecordingVisibility::Private, + "web:abc", + Path::new("../escape.ts"), + ) + .unwrap_err(); + assert!(matches!(err, RecordingPathError::InvalidComponent)); + } +} diff --git a/backend/src/utils/stream_history_viewer.rs b/backend/src/utils/stream_history_viewer.rs index eb7eec7f3..6371bcecf 100644 --- a/backend/src/utils/stream_history_viewer.rs +++ b/backend/src/utils/stream_history_viewer.rs @@ -5,8 +5,8 @@ use std::io::{self, Write}; use std::path::Path; use std::sync::Arc; use chrono; -use regex::Regex; use serde::Deserialize; +use shared::model::{FieldFilter, SearchFieldKind}; use shared::utils::Internable; use crate::model::{StreamHistoryRecord}; use crate::repository::{ @@ -36,17 +36,17 @@ pub(crate) fn parse_date_or_datetime(input: &str) -> Result { if let Ok(date) = chrono::NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { let dt = date.and_hms_opt(0, 0, 0) .ok_or_else(|| format!("Invalid date: '{trimmed}'"))?; - return Ok(dt.and_utc().timestamp().cast_unsigned()); + return to_unsigned_ts(dt.and_utc().timestamp(), trimmed); } // Try datetime without seconds: YYYY-MM-DD HH:MM if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M") { - return Ok(dt.and_utc().timestamp().cast_unsigned()); + return to_unsigned_ts(dt.and_utc().timestamp(), trimmed); } // Try datetime with seconds: YYYY-MM-DD HH:MM:SS if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%d %H:%M:%S") { - return Ok(dt.and_utc().timestamp().cast_unsigned()); + return to_unsigned_ts(dt.and_utc().timestamp(), trimmed); } Err(format!( @@ -54,6 +54,11 @@ pub(crate) fn parse_date_or_datetime(input: &str) -> Result { )) } +// cast_unsigned would wrap pre-1970 dates into huge values and invert query ranges +fn to_unsigned_ts(ts: i64, input: &str) -> Result { + u64::try_from(ts).map_err(|_| format!("Date must not be before 1970: '{input}'")) +} + /// Returns true if input is a date-only format (no time component) fn is_date_only(input: &str) -> bool { chrono::NaiveDate::parse_from_str(input.trim(), "%Y-%m-%d").is_ok() @@ -87,7 +92,7 @@ pub(crate) fn resolve_time_range(query: &StreamHistoryQuery) -> Result, + inner: FieldFilter, } impl CompiledFilter { + pub(crate) fn empty() -> Self { Self { inner: FieldFilter::default() } } + pub(crate) fn compile(raw: &HashMap) -> Result { - let mut fields = Vec::with_capacity(raw.len()); - for (key, value) in raw { - if !STRING_FIELDS.contains(&key.as_str()) && !NUMERIC_FIELDS.contains(&key.as_str()) { - return Err(format!("Unknown filter field: '{key}'")); - } - let filter_value = if NUMERIC_FIELDS.contains(&key.as_str()) { - let n = value.parse::().map_err(|_| { - format!("Filter '{key}' expects a numeric value, got '{value}'") - })?; - FilterValue::NumericExact(n) - } else if let Some(pattern) = value.strip_prefix('~') { - let re = Regex::new(pattern).map_err(|e| { - format!("Invalid regex for filter '{key}': {e}") - })?; - FilterValue::Regex(re) + FieldFilter::compile(raw, |key| { + if NUMERIC_FIELDS.contains(&key) { + Some(SearchFieldKind::Numeric) + } else if STRING_FIELDS.contains(&key) { + Some(SearchFieldKind::Text) } else { - FilterValue::Exact(value.clone()) - }; - fields.push((key.clone(), filter_value)); - } - Ok(Self { fields }) + None + } + }) + .map(|inner| Self { inner }) } pub(crate) fn matches(&self, record: &StreamHistoryRecord) -> bool { - self.fields.iter().all(|(key, value)| { - match get_record_field(record, key) { - RecordFieldValue::String(Some(s)) => match value { - FilterValue::Exact(v) => s.eq_ignore_ascii_case(v), - FilterValue::Regex(re) => re.is_match(s), - FilterValue::NumericExact(_) => false, - }, - RecordFieldValue::ArcStr(Some(s)) => match value { - FilterValue::Exact(v) => s.as_ref().eq_ignore_ascii_case(v), - FilterValue::Regex(re) => re.is_match(s.as_ref()), - FilterValue::NumericExact(_) => false, - }, - RecordFieldValue::String(None) | RecordFieldValue::ArcStr(None) => false, - RecordFieldValue::U64(n) => match value { - FilterValue::NumericExact(v) => n == *v, - _ => false, - }, - } + self.inner.matches(|key, value| match get_record_field(record, key) { + RecordFieldValue::String(Some(s)) => value.matches_text(s), + RecordFieldValue::ArcStr(Some(s)) => value.matches_text(s.as_ref()), + RecordFieldValue::String(None) | RecordFieldValue::ArcStr(None) => false, + RecordFieldValue::U64(n) => value.matches_numeric(n), }) } } @@ -448,7 +425,7 @@ async fn run_stream_history_viewer(input: &str) -> Result<(), String> { let time_range = resolve_time_range(&query)?; let filters = match query.filter.as_ref() { Some(raw) => CompiledFilter::compile(raw)?, - None => CompiledFilter { fields: Vec::new() }, + None => CompiledFilter::empty(), }; let dir = query.path.as_deref().unwrap_or("data/stream_history"); diff --git a/bin/dvr_doctor.sh b/bin/dvr_doctor.sh new file mode 100755 index 000000000..1b5888c16 --- /dev/null +++ b/bin/dvr_doctor.sh @@ -0,0 +1,208 @@ +#!/usr/bin/env bash +# DVR support diagnostics. +# +# Collects the DVR's runtime state into one readable dump for a support +# ticket: supervisor liveness, the effective configuration, and the +# on-disk artefacts the recording feature owns. Read-only — it never +# mutates config, the queue, or a recording. +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +WORKING_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" + +die() { + echo "🧨 Error: $*" >&2 + exit 1 +} + +usage() { + cat <<'USAGE' +Usage: dvr_doctor.sh [--url URL] [--token TOKEN] [--storage-dir DIR] + + --url Base URL of the running server. Default: http://localhost:8901 + --token Bearer token for an administrator. Without it the health + section is skipped; the on-disk sections still work. + --storage-dir Server storage directory, for the on-disk sections. + Default: $TULIPROX_HOME/data, else ./data + +Environment: TULIPROX_URL, TULIPROX_TOKEN, TULIPROX_HOME. +USAGE +} + +URL="${TULIPROX_URL:-http://localhost:8901}" +TOKEN="${TULIPROX_TOKEN:-}" +STORAGE_DIR="${TULIPROX_HOME:+${TULIPROX_HOME}/data}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --url) URL="${2:?--url needs a value}"; shift 2 ;; + --token) TOKEN="${2:?--token needs a value}"; shift 2 ;; + --storage-dir) STORAGE_DIR="${2:?--storage-dir needs a value}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done + +STORAGE_DIR="${STORAGE_DIR:-${WORKING_DIR}/data}" + +command -v curl >/dev/null 2>&1 || die "curl is required" +# jq is optional: without it the JSON is emitted raw rather than pretty. +if command -v jq >/dev/null 2>&1; then + pretty() { jq . 2>/dev/null || cat; } +else + pretty() { cat; } +fi + +section() { + printf '\n=== %s ===\n' "$1" +} + +fetch() { + # $1 = path. Prints the body; returns non-zero on a transport failure. + local path="$1" + if [[ -n "${TOKEN}" ]]; then + # Token goes through stdin (curl `-H @-`) so it does not appear in + # the process listing (`ps`, `/proc//cmdline`). + curl -fsS -H @- "${URL}${path}" <<<"Authorization: Bearer ${TOKEN}" + else + curl -fsS "${URL}${path}" + fi +} + +printf 'Tuliprox DVR diagnostics\n' +printf 'generated: %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +printf 'url: %s\n' "${URL}" +printf 'storage dir: %s\n' "${STORAGE_DIR}" +printf 'token: %s\n' "$([[ -n "${TOKEN}" ]] && echo provided || echo '(none — health section skipped)')" + +section "Supervisor health" +if [[ -z "${TOKEN}" ]]; then + echo "skipped: needs an administrator token (--token)" +else + if ! fetch /api/v1/recording/health | pretty; then + echo "unavailable: the endpoint refused or the server is down." + echo "A 403 means the token is not an administrator's." + echo "A 404 means the build predates the health endpoint." + echo "A 501 means recording.enabled is false." + fi +fi + +section "Effective recording configuration" +if [[ -z "${TOKEN}" ]]; then + echo "skipped: needs a token (--token)" +elif command -v jq >/dev/null 2>&1; then + # The DVR block only; the rest of the config may contain credentials. + fetch /api/v1/config \ + | jq '.config.video.download.recording // "no recording block configured (defaults apply)"' \ + || echo "unavailable" +else + echo "skipped: needs jq to extract the recording block without dumping" + echo "the whole config, which may contain credentials." +fi + +section "Recording quota" +if [[ -z "${TOKEN}" ]]; then + echo "skipped: needs a token (--token)" +else + fetch /api/v1/recording/quota | pretty || echo "unavailable" +fi + +section "On-disk state" +queue_file="${STORAGE_DIR}/downloads_state.json" +rules_file="${STORAGE_DIR}/recording_rules.json" +outbox_file="${STORAGE_DIR}/recording_notification_outbox.json" + +# mtime_as_iso8601_utc +# Prints the file's modification time as `YYYY-MM-DDTHH:MM:SSZ`, or +# `unknown` if neither `stat` flavour works. GNU `stat -c %Y` and BSD +# / macOS `stat -f %m` both yield an mtime epoch; GNU `date -d @N` +# accepts the `@`-prefixed epoch and formats it, BSD/macOS `date -r +# N` accepts the raw epoch directly (no `@` prefix). +mtime_as_iso8601_utc() { + local f="$1" ts + if ts=$(stat -c %Y -- "${f}" 2>/dev/null); then + date -u -d "@${ts}" '+%Y-%m-%dT%H:%M:%SZ' + elif ts=$(stat -f %m -- "${f}" 2>/dev/null); then + # BSD/macOS `date -r` reads the raw epoch as a numeric argument; + # the `@N` form is GNU-only and would fail with "No such file or + # directory" because date would try to stat a file named `@`. + date -u -r "${ts}" '+%Y-%m-%dT%H:%M:%SZ' + else + echo unknown + fi +} + +for f in "${queue_file}" "${rules_file}" "${outbox_file}"; do + if [[ -f "${f}" ]]; then + printf '%s (%s bytes, modified %s)\n' \ + "${f}" \ + "$(wc -c <"${f}" | tr -d ' ')" \ + "$(mtime_as_iso8601_utc "${f}")" + else + printf '%s (absent)\n' "${f}" + fi +done + +if [[ -f "${queue_file}" ]] && command -v jq >/dev/null 2>&1; then + section "Queue summary (no titles, no paths)" + # Aggregate only. Titles, filenames, and owner ids are deliberately not + # printed: a diagnostics dump gets pasted into tickets. + jq '{ + revision: .revision, + queued: (.queue | length), + scheduled: (.scheduled | length), + active: (if .active then 1 else 0 end), + finished: (.finished | length), + recordings_by_state: + ([.queue[], .scheduled[], .finished[]] + + (if .active then [.active] else [] end) + | map(select(.recording != null)) + | group_by(.state) + | map({ (.[0].state | tostring): length }) + | add // {}), + stuck_deleting: + ([.queue[], .scheduled[], .finished[]] + + (if .active then [.active] else [] end) + | map(select(.recording != null and .recording.deleting_previous_state != null)) + | length) + }' "${queue_file}" || echo "could not parse ${queue_file}" +fi + +if [[ -f "${rules_file}" ]] && command -v jq >/dev/null 2>&1; then + section "Rules summary" + jq '{ + version: .version, + rules_total: (.rules | length), + rules_enabled: (.rules | map(select(.enabled)) | length), + new_episode_enabled: + (.rules | map(select(.enabled and (.body | has("NewEpisode")))) | length), + weekly_enabled: + (.rules | map(select(.enabled and (.body | has("WeeklyTimeslot")))) | length), + tombstones: (.tombstones.tombstones | length) + }' "${rules_file}" || echo "could not parse ${rules_file}" + echo + echo "Note: enabled NewEpisode rules cannot currently match — the scheduler" + echo "has no EPG horizon, so only WeeklyTimeslot rules materialize." +fi + +if [[ -f "${outbox_file}" ]] && command -v jq >/dev/null 2>&1; then + section "Notification outbox" + jq '{ next_id: .next_id, pending: (.entries | length), + attempts: (.entries | map(.attempts)), + channels_pending: (.entries | map(.pending) | flatten | unique) }' \ + "${outbox_file}" || echo "could not parse ${outbox_file}" +fi + +section "What to look at first" +cat <<'HINTS' +- retention_last_tick older than disk.cleanup_interval_secs → the retention + supervisor is stalled or the DVR is disabled. +- reconciliation_last_run null → the supervisors never started; check the log + for "Recording is disabled" or a startup error. +- stuck_deleting > 0 after a restart → reconciliation did not clear a task; + grep the log for recording_reconciliation_unsafe_path. +- notification outbox pending > 0 and not draining → grep the log for + recording_notification_dead_lettered. +- no retention, no watermarks and no quota → recording disk use is unbounded; + the server logs a warning for this at startup. +HINTS diff --git a/bin/test.sh b/bin/test.sh new file mode 100755 index 000000000..18c4d9bc6 --- /dev/null +++ b/bin/test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Wrapper around `cargo test` that routes every test's tempfile +# activity through the system temp directory and skips the +# `Config::update_runtime()`-induced `override_temp_dir` poison that +# would otherwise dump every parallel test's `.tmp*` into +# `backend/tmp/` (see backend/src/model/config/base.rs and the +# `cfg(not(test))` gate around `tempfile::env::override_temp_dir`). +# +# `bin/test.sh` is the only way to invoke `cargo test` from the +# command line; the IDE test runner should set `TMPDIR=/tmp` in its +# environment the same way. +set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" +cd "${REPO_ROOT}" +export TMPDIR="${TMPDIR:-/tmp}" +exec cargo test "$@" diff --git a/config/api-proxy.yml b/config/api-proxy.yml index 2fdf2d02d..175d06b08 100644 --- a/config/api-proxy.yml +++ b/config/api-proxy.yml @@ -1,78 +1,48 @@ server: - - name: default - protocol: http - host: ${env:TULIPROX_HOST_IP} - port: "8901" - timezone: Europe/Paris - message: Welcome to m3u-filter - - name: external - protocol: https - host: ${env:TULIPROX_DOMAIN} - port: "443" - timezone: Europe/Paris - message: Welcome to m3u-filter +- name: default + protocol: http + host: 127.0.0.1 + port: "8901" + timezone: Europe/Paris + message: Welcome to m3u-filter +- name: external + protocol: https + host: localhost + port: "443" + timezone: Europe/Paris + message: Welcome to m3u-filter user: - - target: iptv-org - credentials: - - username: local - password: localsecret - token: "77417" - proxy: reverse - output_clusters: [live, vod, series] - server: default - exp_date: 0 - max_connections: 0 - status: Active - ui_enabled: true - # network_access: - # allowed_networks: - # - "192.168.0.0/16" - # - "10.0.0.0/8" - - - username: vpn-only - password: vpnsecret - proxy: reverse - output_clusters: [live, vod, series] - server: external - max_connections: 2 - status: Active - # Network access restriction — uses OR logic: matching ANY allowed_networks - # OR ANY allowed_countries is sufficient for access. - # - # Private/VPN ranges must use CIDR notation (/16, /24, /32 for single IPv4s, - # /128 for a single IPv6, e.g. 2001:db8::/32). - # Country-based restrictions require GeoIP database to be configured. - # If GeoIP is unavailable and country restrictions exist, access is denied by default. - # To explicitly accept that risk globally, set this in config.yml: - # - # reverse_proxy: - # geoip: - # unavailable_policy: allow - # - # The policy is global, not per user. CIDR-only misses, unknown countries, - # and country mismatches still deny. - # - # Client IP is derived from X-Real-IP / X-Forwarded-For headers when behind - # a reverse proxy. Ensure your reverse proxy is configured to set these, - # otherwise network restrictions may not function correctly. - network_access: - allowed_networks: - - "10.200.0.0/16" # WireGuard VPN range - allowed_countries: - - DE # Germany - - AT # Austria - ui_enabled: false - - # No network restrictions — allow from any source - - username: external - password: externalsecret - token: "77418" - proxy: reverse - output_clusters: [live, vod, series] - server: external - exp_date: 0 - max_connections: 0 - status: Active - ui_enabled: true - -use_user_db: false +- target: iptv-org + credentials: + - username: local + password: localsecret + token: "77417" + proxy: reverse + server: default + exp_date: 0 + max_connections: 0 + status: Active + soft_connections: 0 + - username: vpn-only + password: vpnsecret + proxy: reverse + server: external + max_connections: 2 + status: Active + ui_enabled: false + soft_connections: 0 + network_access: + allowed_countries: + - DE + - AT + allowed_networks: + - 10.200.0.0/16 + - username: external + password: externalsecret + token: "77418" + proxy: reverse + server: external + exp_date: 0 + max_connections: 0 + status: Active + soft_connections: 0 diff --git a/config/config.yml b/config/config.yml index c67cad88c..95d9f736c 100644 --- a/config/config.yml +++ b/config/config.yml @@ -79,6 +79,52 @@ video: retry_backoff_max_secs: 30 retry_backoff_jitter_percent: 20 retry_max_attempts: 5 + # DVR. Every field below is optional and shown at its default value; + # see docs/src/operator/dvr.md for the full reference. + # recording: + # # Master switch. `false` stops reconciliation, retention, and the + # # notification outbox, and the DVR routes stop doing work. + # enabled: true + # # Muxer ffmpeg writes: mpegts | matroska | mp4. mpegts survives + # # truncation, so a recording killed mid-stream still plays. + # container_format: mpegts + # directory: ${env:TULIPROX_HOME}/downloads/recordings + # timezone: UTC # IANA zone; used for rule timeslots + # filename_template: "{channel}_{program_title}_{start_time}" + # default_pre_roll_secs: 0 + # max_pre_roll_secs: 900 # 15 min + # default_post_roll_secs: 0 + # max_post_roll_secs: 1800 # 30 min + # # Estimated size for an unknown-bitrate recording, used for the + # # quota reservation before any bytes are measured. + # fallback_bytes_per_minute: 8388608 # 8 MiB/min + # retention: + # # Both policies are off when unset. Retention is the only thing + # # bounding recording disk use, so set at least one on a shared box. + # keep_last_per_channel: 10 + # delete_after_days: 30 + # sweep_interval_secs: 3600 # age/count sweep cadence + # disk: + # # Used-space percentages on the recording root's filesystem. When + # # used >= high, oldest completed recordings are deleted until + # # used <= low. Active recordings are never touched. + # high_water_percent: 85 + # low_water_percent: 70 + # cleanup_interval_secs: 3600 # watermark-check cadence + # safety_bytes: 1073741824 # 1 GiB kept free at all times + # quota: + # # Unset means unlimited. Charged by task state; see the operator doc. + # default_private_bytes: 53687091200 # 50 GiB per user + # shared_bytes: 536870912000 # 500 GiB for shared recordings + # per_user_bytes: + # "web:user-uuid-1": 107374182400 # 100 GiB override + # notifications: + # # Lifecycle notifications are delivered by a durable outbox that + # # retries per channel. outbox_buffer is fixed at startup. + # outbox_buffer: 1024 + # max_attempts: 6 + # backoff_initial_secs: 5 + # backoff_max_secs: 900 reverse_proxy: resource_retry: diff --git a/config/plans.yml b/config/plans.yml new file mode 100644 index 000000000..b0263dcf0 --- /dev/null +++ b/config/plans.yml @@ -0,0 +1,26 @@ +plans: +- name: 3d Trial + proxy: reverse + max_connections: 1 + soft_connections: 0 + trial: + duration: 3d + comment: 3 day trial, all channels, 1 connection +- name: 7d Trial + proxy: reverse + max_connections: 1 + soft_connections: 0 + trial: + duration: 7d + comment: 7 day trial, 1 connection +- name: Unlimited + proxy: reverse + max_connections: 0 + soft_connections: 0 + comment: Unlimited connections, all channels +- name: Sports + proxy: reverse + max_connections: 1 + soft_connections: 0 + filter: Group ~ "(?i).*sport.*" + comment: Sports channels only, 1 connection diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index f1b5966ab..e6641ee61 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -27,5 +27,6 @@ - [template.yml (Macros & Regex)](./configuration/template.md) - [mapping.yml (Mapper DSL & Logic)](./configuration/mapping-dsl.md) - [Operations & Debugging (CLI & DB Dumps)](./operations-debugging.md) +- [DVR Operator Reference](./operator/dvr.md) - [Examples,Recipes & Ecosystem Stacks](./examples-recipes.md) - [Troubleshooting & Resilience](./troubleshooting.md) diff --git a/docs/src/configuration/api-proxy.md b/docs/src/configuration/api-proxy.md index a1049af7c..e32c6a1c8 100644 --- a/docs/src/configuration/api-proxy.md +++ b/docs/src/configuration/api-proxy.md @@ -12,6 +12,7 @@ specific permissions, proxy modes, and priorities. auth_error_status: 403 use_user_db: false server: +plans: user: ``` @@ -22,6 +23,7 @@ user: | `auth_error_status` | Int | No | `403` | The HTTP status code Tuliprox returns when a player sends invalid credentials or tokens. (Only applies to [Xtream/M3U API Endpoints](#api-endpoints-for-clients-players), stream paths, and resource paths, NOT the Web UI / REST API). | | `use_user_db` | Bool | No | `false` | If set to `true`, Tuliprox migrates all users from this YAML file into a highly performant SQLite database (`api_user.db`). **From then on, Tuliprox ignores the users in the YAML file!** You **must** subsequently manage users entirely via the Web UI Dashboard. Switching the option to `false` or `true` automatically migrates users back to the corresponding file (`false` → `api-proxy.yml`, `true` → `api_user.db`). | | `server` | List | Yes | `[]` | See [Server Definitions](#1-server-definitions-server) for how to define servers. | +| `plans` | List | No | `[]` | Reusable user capability tiers. See [User Plans](#3-user-plans-plans). | | `user` | List | No | `[]` | See [User Definitions](#2-user-definitions-user) for how to define users & permissions. | ### Subsections (Object Keys) @@ -30,6 +32,7 @@ user: |:---------|:------------------------------------------------------|:--------------------------------------------| | `server` | Virtual server endpoints exposed to clients. | [See section](#1-server-definitions-server) | | `user` | User credentials, proxy modes, and access management. | [See section](#2-user-definitions-user) | +| `plans` | Reusable capability tiers referenced by users. | [See section](#3-user-plans-plans) | --- @@ -128,6 +131,8 @@ in your `config.yml`. Without it, these fields are purely cosmetic! | `ui_enabled` | Bool | No | `true` | Allows this specific user to log into the Web UI to manage their own favorites/bouquets. | | `priority` | Int (i8) | No | `0` | Stream preemption priority. Priority range: `-128` to `127`, where `-128` has the highest priority. Negative numbers are explicitly allowed for top-tier access. (see [user priority](#user-priorities-priority) below) | | `network_access` | Block | No | `None` | Per-user network/country access restrictions. Uses OR logic — matching ANY `allowed_networks` (CIDR) OR ANY `allowed_countries` grants access. Requires GeoIP for country checks. Client IP from `X-Real-IP` / `X-Forwarded-For`. See [Network Access Restrictions](#network-access-restrictions) below. | +| `plan` | String | No | `None` | Name of a [user plan](#3-user-plans-plans). Unset user values (`output_clusters`, `max_connections`, `soft_connections`) inherit from the plan; explicit user values always win. The plan's content `filter` is always applied. | +| `filter` | String | No | `None` | Filter DSL expression restricting which content this user sees. AND-combined with the plan filter when both are set, so a user filter can only narrow a plan, never widen it. | --- @@ -583,6 +588,60 @@ aired - that's controlled by your IPTV provider. --- +## 3. User Plans (`plans`) + +Plans are reusable capability tiers. Instead of repeating limits per user, define a plan once and reference it +via the user's `plan` field. Plans can be managed in the Web UI (Config → API, edit mode) or directly in this file. + +```yaml +plans: + - name: basic + output_clusters: [live] + max_connections: 1 + filter: 'NOT Group ~ "^(VIP|PPV).*" AND Quality <= 3' + comment: Live-only starter tier, FHD max, no premium groups + - name: premium + output_clusters: [live, vod, series] + max_connections: 3 + +user: + - target: main + credentials: + - username: joe + password: secret + plan: basic + - username: vip + password: secret2 + plan: premium + max_connections: 5 # explicit user value overrides the plan +``` + +### Plan Parameters + +| Parameter | Type | Required | Default | Technical Impact & Background | +|:-------------------|:-------|:--------:|:--------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `name` | String | Yes | | Unique plan identifier referenced by `user[].credentials[].plan`. | +| `output_clusters` | List | No | `None` | Cluster tier (`live`, `vod`, `series`). Inherited by members that don't set their own `output_clusters`. | +| `max_connections` | Int | No | `0` | Concurrent stream limit for members whose own `max_connections` is `0`/unset. Enforcement still requires `user_access_control: true`. | +| `soft_connections` | Int | No | `0` | Soft connection allowance inherited the same way. | +| `filter` | String | No | `None` | Filter DSL expression restricting the content visible to plan members (e.g. group allow/deny lists, `Quality <= 3` caps). AND-combined with a member's own `filter` if both exist. | +| `trial.duration` | String | No | `None` | Trial window with unit (`24h`, `7d`). When a **new** user is created on this plan without an explicit `exp_date`, the expiry is set to now + duration and the status defaults to `Trial`. Enforcement of the expiry requires `user_access_control: true`. | +| `comment` | String | No | `None` | Free-form description. | + +### Resolution Rules + +- Explicit user value > plan value > global default. "Unset" means `output_clusters` omitted or `max_connections`/`soft_connections` at `0`. +- Filters combine as `(plan filter) AND (user filter)` — a user filter can only narrow the plan. +- Templates (`!NAME!`) are **not** available in api-proxy filters; write the expression inline. +- Trial windows apply only at user creation (Web UI/API); existing users and YAML-defined users are not modified. + After expiry the standard `exp_date` enforcement blocks the user; automatic downgrade to another plan is not supported. +- Plan changes take effect on config reload for YAML users and on the next load for database users; running streams are not interrupted. +- The content filter hides entries from playlists and stream lists, blocks direct stream access to filtered items, + hides categories whose content is fully filtered out from the Xtream category actions, and removes hidden channels + from the user's XMLTV output. The Web UI bouquet editor category list is not thinned. + +--- +   ## Additional Information diff --git a/docs/src/configuration/config.md b/docs/src/configuration/config.md index 5046e71a3..753f89ddc 100644 --- a/docs/src/configuration/config.md +++ b/docs/src/configuration/config.md @@ -427,10 +427,13 @@ Messaging is strictly **opt-in**. You must explicitly define which event types s * `stats`: Summary of processed items and performance metrics after a run. * `error`: Alerts when processing or source fetching fails. * `watch`: Triggered by changes in monitored groups/targets. +* `recording_started`: DVR recording entered the active recording worker. +* `recording_completed`: DVR recording finished and the final file was committed. +* `recording_failed`: DVR recording reached a terminal failure. ```yaml messaging: - notify_on: [ "info", "stats", "error", "watch" ] + notify_on: [ "info", "stats", "error", "watch", "recording_started", "recording_completed", "recording_failed" ] # Telegram: Supports Markdown and Group Topics telegram: @@ -441,6 +444,7 @@ messaging: - ":" # Use colon to target specific Discord-like topics/threads templates: stats: 'file:///config/messaging_templates/telegram_stats.templ' + recording_completed: 'file:///config/messaging_templates/telegram_recording_completed.templ' # Discord: Webhook integration discord: @@ -505,6 +509,13 @@ populated: * **Access:** Iterate over the change sets using loops. Common keys include `added`, `removed`, and `modified`. * **Example:** Use `{{#each watch.added}} • {{name}} {{/each}}` to list all new channels detected in the monitored groups. +* `{{recording}}`: **DVR Lifecycle Data.** Available for `recording_started`, `recording_completed`, and + `recording_failed`. Common fields are `programme_title`, `channel`, `effective_start`, `effective_end`, + `visibility`, `output_filename`, and `failure_reason` for failed recordings. + +Recording lifecycle notifications are global-channel notifications. Tuliprox sends them for shared recordings, +legacy administrator recordings, and built-in administrator private recordings. Private recordings owned by regular +users are suppressed. #### Template Examples @@ -676,10 +687,80 @@ Tuliprox handles these transfers like provider-bound background streams: * RBAC integration is explicit: * `download.read` allows opening the downloads view and receiving transfer snapshots. * `download.write` allows queueing, pausing, cancelling, retrying, and removing transfers. + * `recording.read` allows opening DVR task, quota, library, and recurring-rule views. + * `recording.write` allows creating, editing, cancelling, deleting, and managing DVR tasks and rules. * Persisted queue recovery is tolerant of corruption. If `downloads_state.json` cannot be deserialized, Tuliprox renames it to a timestamped `*_corrupt.*.json` backup and starts with an empty transfer queue instead of aborting server boot. +### 6.1 DVR Runtime Files + +The DVR runtime keeps durable state under `storage_dir`: + +* `downloads_state.json`: queued, scheduled, active, and finished downloads and recordings. +* `recording_rules.json`: recurring recording rules and tombstones. + +Live recordings use a partial-file lifecycle. The worker writes to `.partial` and renames it to the final +path only after ffmpeg exits successfully and the final path is still free. + +> **See also:** the full [DVR Operator Reference](../operator/dvr.md) — configuration reference, directory layout, +> filename placeholders, lifecycle / restart, quota charge-by-state, disk admission, safe deletion, authorization +> matrix, identity-registry bootstrap, token refresh, deprecated `/file/record`, REST + WebSocket surface, conflict +> preview, recurring-rule matching + DST + reconciliation, at-most-once notification protocol, migration checklist, +> and the 32-scenario acceptance sweep. + +#### 6.1.1 Filename placeholders + +The filename template supports these placeholders (filename only — **never** the directory): + +| Placeholder | Resolves to | +|---------------------|-------------------------------------------------------------------| +| `{channel}` | Channel name | +| `{program_title}` | Programme title (sanitized) | +| `{start_time}` | UTC `YYYY-MM-DDTHH-MM` | +| `{end_time}` | UTC `YYYY-MM-DDTHH-MM` | +| `{episode}` | Episode identifier extracted by `episode_pattern` | +| `{owner}` | The owner principal id (`user:` or `legacy:admin`) | + +> **Security:** `{owner}` is allowed **only** in the filename template, never in directory templates. +> Directory templates are resolved against the caller's identity, so the directory part is intrinsically owner-scoped. + +#### 6.1.2 Authorization matrix + +The DVR layer runs an additional authorization pass on top of `recording.read` / `recording.write`. + +| Visibility | Owner | Admin (`builtin:admin`) | Foreign user | Notes | +|------------|------------------------|-------------------------|--------------|-------------------------------------------| +| `private` | read + write + delete | read + write + delete | — | Foreign reads return 404 | +| `shared` | — | read + write + delete | read | Only admins create shared recordings | +| `legacy` | — (orphan) | read + write + delete | — | Created by the deprecated `/file/record` | + +#### 6.1.3 Identity bootstrap + +If the `users` table is empty on first boot: + +1. Tuliprox reads `TULIPROX_BOOTSTRAP_ADMIN` from the environment. +2. The built-in `builtin:admin` role is assigned to that user. +3. The bootstrap admin must use `POST /api/v1/auth/login` to obtain a JWT. +4. From that point on, the admin creates additional users via `POST /api/v1/users`. +5. If `TULIPROX_BOOTSTRAP_ADMIN` is unset and the table is empty, the server **fails closed** at boot. +6. The bootstrap admin cannot be deleted while it is the sole `builtin:admin` member. + +#### 6.1.4 Token refresh on schema bump + +When the JWT schema version is bumped (a new field is added), existing tokens are rejected with +`401 Unauthorized` and an `X-Token-Refresh: required` response header. The frontend automatically calls +`POST /api/v1/auth/refresh` to mint a new token. The wire code is `recording_token_refresh_required` so the +toastr surfaces a stable, translatable message. Operators upgrading across a schema-bump release do not need +to do anything manually. + +#### 6.1.5 Deprecated `/file/record` + +`POST /api/v1/file/record` is the legacy recording endpoint. It is still functional and admin-gated, but +returns a `recording_forbidden` error for non-admin principals and is **scheduled for removal in the next +major version**. New code should use `POST /api/v1/recording/tasks` with a `CreateRecordingTaskBody` payload +(see [REST API cookbook](../rest-api-cookbook.md#downloads-and-recordings)). + > **Note:** The named capture group `(?P...)` is **mandatory** for this to function correctly. > > *Example:* `.*(?P[Ss]\d{1,2}(.*?)[Ee]\d{1,2}).*` diff --git a/docs/src/configuration/reverse-proxy.md b/docs/src/configuration/reverse-proxy.md index 06526e93c..ef43516b9 100644 --- a/docs/src/configuration/reverse-proxy.md +++ b/docs/src/configuration/reverse-proxy.md @@ -119,10 +119,12 @@ reverse_proxy: | `retry` | Bool | `true` | Retries connecting to the upstream provider during the initial stream open when the provider returns a transient failure or no usable stream. Once a stream has started, Tuliprox does not transparently replace that live upstream inside the same client response. | | `buffer.enabled` | Bool | `false` | Enables an asynchronous ring-buffer in RAM between the provider download stream and the client upload stream. Necessary if the provider stream is faster than the consumer can process. | | `buffer.size` | Int | `0` | The size of the buffer in *Chunks* (1 Chunk = 8192 Bytes). A value of `1024` equals approximately 8 Megabytes of RAM per active stream. | +| `buffer.max_bytes_mb` | Int | `5` | Byte-level backpressure cap of the buffer in Megabytes. The producer stops reading from the provider once this many bytes are queued for the client, regardless of chunk count. Increase for high-bitrate streams with slow consumers; decrease on memory-constrained hosts. | | `throttle_kbps` | Int | `0` | **Background:** Some players download VODs (Movies) at maximum line speed ("Bursting"). Providers often view this as abuse or scraping and will ban the IP. By throttling (e.g., to `12500` kbps), you force the download into a constant, inconspicuous flow. Supports units like `KB/s`, `MB/s`, `kbps`, `Mibps`. | | `metrics_enabled` | Bool | `false` | **Monitoring:** If active, Tuliprox samples the live bandwidth (in kbps) and transferred bytes for every active reverse-proxied stream and pushes them via WebSockets to the Web UI. It adds a tiny bit of CPU overhead but is invaluable for debugging buffering issues. | | `grace_period_millis` | Int | `2000` | The exact time window in ms where a temporary over-allocation is allowed (see notes on [The VLC Seek Problem](#the-vlc-seek-problem--grace-periods) for details). | | `grace_period_timeout_secs` | Int | `4` | A hard timeout limit for overlapping "ghost sessions" to expire. | +| `shared_subscriber_idle_timeout_secs` | Int | `300` | How long a subscriber of a shared (multi-client) stream may consume no data before it is dropped. Lower values reclaim slots from stalled clients faster; higher values tolerate longer player pauses. | | `grace_period_hold_stream` | Bool | `true` | Tuliprox artificially holds back video data to the client, waiting for grace check to finish, so it doesn't trigger provider prematurely. | | `hls_session_ttl_secs` | Int | `15` | Keeps virtual provider slot open between HLS segment (`.ts`) requests to prevent provider bans for "Account Hopping". | | `catchup_session_ttl_secs` | Int | `45` | Same session-holding principle applied to Archive/Catchup TV. See notes on section [Session TTLs for HLS & Catchup](#session-ttls-for-hls-m3u8--catchup) for details. | @@ -276,6 +278,7 @@ reverse_proxy: | `max_concurrent_segment_fetches_global` | Int | `64` | Maximum concurrent future segment fetches across all HLS sessions. | | `origin_manifest_timeout_ms` | Milliseconds | `3000` | Timeout for future Origin manifest fetches. | | `origin_segment_timeout_ms` | Milliseconds | `10000` | Timeout for future Origin segment fetches. | +| `initial_manifest_wait_timeout_secs` | Seconds | `90` | How long a client may wait for the initial manifest decision (session bootstrap window). Lower values fail unhealthy sessions faster; higher values tolerate slow providers. | | `session_idle_timeout` | Seconds | `300` | Idle timeout before a future HLS cache session may be collected. | | `segment_repair.max_level` | String | `off` | Maximum repair level allowed by the codec-aware MPEG-TS segment repair policy (`off`, `low`, `medium`, `high`). | | `segment_repair.apply_to_first_segments` | Int | `1` | Number of visible TS objects checked per access-lease activation. | diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index 9d1e1580f..fecb639a6 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -1231,8 +1231,19 @@ Tuliprox supports the following filter expression types: * Use `NOT` for exclusion logic * Use `AND` / `OR` for boolean combinations * Type Comparison: `Type = vod` or `Type = live` or `Type = series` -* Regular expression comparison: `([fieldanme]) ~ "regexp"`
- The `[fieldanme]` can be `Group`, `Title`, `Name`, `Caption`, `Url`, `Genre`, `Input` or `Type`. +* Regular expression comparison: `([fieldname]) ~ "regexp"`
+ The `[fieldname]` can be `Group`, `Title`, `Name`, `Caption`, `Url`, `Genre`, `Input`, `EpgId` or `Type`. +* String comparison (case-insensitive, no regex needed): + * Exact: `Group = "Sports"` / negated: `Group != "Sports"` + * Substring: `Title CONTAINS "HD"` + * Prefix: `Caption STARTSWITH "DE:"` + * Case-insensitivity is ASCII-only: ASCII letters match regardless of case, non-ASCII characters must match + exactly. `Title CONTAINS "cinéma"` matches `Cinéma` but not `CINÉMA`. +* Set membership (case-insensitive exact match against a list): `Group IN ["Sports", "News"]` +* Numeric comparison on the channel number: `Chno = 5`, `Chno != 5`, `Chno > 100`, `Chno >= 100`, `Chno < 200`, `Chno <= 200` +* Numeric comparison on the detected quality tier: `Quality >= 3`
+ The tier is derived from quality tokens in the caption: `5` = 4K/UHD/2160p, `4` = QHD/1440p, `3` = FHD/1080p, + `2` = HD/720p, `1` = SD/480p/576p, `0` = no recognized quality token. * Filters don't have operator precedence, so please use parentheses * You can apply Morgan’s Law `NOT (A) AND NOT (B)`is the same as `NOT( A OR B)` @@ -1343,16 +1354,21 @@ It has the following top-level attributes: Each sort rule supports the following entries: -| Parameter | Type | Required | Default | Technical Impact & Background | -|:-----------|:-------|:--------:|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `target` | Enum | Yes | | Defines whether the rule sorts `group` or `channel` entries. This changes whether Tuliprox reorders category containers or items within those categories. | -| `field` | String | Yes | | Sort field. For `channel`: `title`, `name`, `caption`, or `url`. For `group`: `group`. This determines which final-state value Tuliprox uses for ordering. | -| `filter` | String | Yes | | Filter expression defining which entries the rule applies to. This makes it possible to sort only selected subsets of the playlist instead of the entire target uniformly. | -| `order` | Enum | Yes | | `asc`, `desc`, or `none`. `none` preserves source order for matched entries and is useful when provider order should remain untouched. | -| `sequence` | List | No | | Ordered regex list used for index-based sorting. When present, Tuliprox prioritizes regex sequence position over `order`, enabling explicit semantic ordering such as quality tiers or curated group precedence. | +| Parameter | Type | Required | Default | Technical Impact & Background | +|:-----------|:-------|:--------:|:--------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `target` | Enum | Yes | | Defines whether the rule sorts `group` or `channel` entries. This changes whether Tuliprox reorders category containers or items within those categories. | +| `field` | String | Yes | | Sort field. For `channel`: `title`, `name`, `caption`, `url`, or `quality` (detected quality tier, best used with `order: desc`). For `group`: `group`. This determines which final-state value Tuliprox uses for ordering. | +| `filter` | String | Yes | | Filter expression defining which entries the rule applies to. This makes it possible to sort only selected subsets of the playlist instead of the entire target uniformly. | +| `order` | Enum | Yes | | `asc`, `desc`, or `none`. `none` preserves source order for matched entries and is useful when provider order should remain untouched. | +| `natural` | Bool | No | `false` | Natural sort: numbers embedded in values compare numerically instead of lexicographically, so `Channel 2` sorts before `Channel 10`. Applies to the rule's value and sequence capture comparisons. | +| `sequence` | List | No | | Ordered regex list used for index-based sorting. When present, Tuliprox prioritizes regex sequence position over `order`, enabling explicit semantic ordering such as quality tiers or curated group precedence. | > **Note:** Sort rules must be written with the configured `processing_order` in mind, > because sorting operates on the transformed state that exists at that point in the pipeline. +> +> **Multi-field sorting:** rules are applied in the order they are declared. When a rule compares +> equal, the next rule decides — so a `channel` rule on `group` followed by one on `caption` +> produces group-then-caption ordering. #### Sort Example @@ -1429,19 +1445,24 @@ targets: hls: true mpeg_ts: true remove_duplicates: false + deduplicate: + match_by: caption + keep: best_quality + match_as_ascii: false ``` #### Target Option Parameters -| Parameter | Type | Required | Default | Technical Impact & Background | -|:-------------------------------------------|:-----|:--------:|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `ignore_logo` | Bool | No | `false` | Ignores `tvg-logo` and `tvg-logo-small` attributes. This reduces downstream device-side logo caching and can keep generated M3U playlists leaner for clients with limited storage or poor cache invalidation behavior. | -| `share_live_streams.hls` | Bool | No | `false` | Enables HLS live sharing for the new HLS cache proxy path. This is a configuration switch for the HLS cache feature and is independent from MPEG-TS stream sharing. | -| `share_live_streams.mpeg_ts` | Bool | No | `false` | Allows Tuliprox to share MPEG-TS live stream connections in reverse proxy mode. This can reduce upstream provider connection usage when multiple clients watch the same channel, but it increases memory usage per shared channel. | -| `remove_duplicates` | Bool | No | `false` | Attempts to remove duplicate entries by `url`. This improves playlist cleanliness and reduces confusing duplicates in the client-facing output. | -| `epg_output.lowercase_ids` | Bool | No | `false` | Canonicalizes visible technical EPG IDs with ASCII lowercase across M3U `tvg-id`, Xtream `epg_channel_id`, XMLTV channel/programme references, and EPG API responses. Changing this option requires a full target refresh. | -| `epg_output.lowercase_xmltv_display_names` | Bool | No | `false` | Applies Unicode lowercase exclusively to XMLTV `` values during serialization. Playlist names, Xtream names, programme titles, and programme descriptions remain unchanged; persisted target data does not require rebuilding. | -| `force_redirect` | Bool | No | `false` | Optional redirect-related behavior switch. This influences how Tuliprox serves final stream delivery where redirect-style output handling is required by the deployment model. | +| Parameter | Type | Required | Default | Technical Impact & Background | +|:-------------------------------------------|:-----|:--------:|:--------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `ignore_logo` | Bool | No | `false` | Ignores `tvg-logo` and `tvg-logo-small` attributes. This reduces downstream device-side logo caching and can keep generated M3U playlists leaner for clients with limited storage or poor cache invalidation behavior. | +| `share_live_streams.hls` | Bool | No | `false` | Enables HLS live sharing for the new HLS cache proxy path. This is a configuration switch for the HLS cache feature and is independent from MPEG-TS stream sharing. | +| `share_live_streams.mpeg_ts` | Bool | No | `false` | Allows Tuliprox to share MPEG-TS live stream connections in reverse proxy mode. This can reduce upstream provider connection usage when multiple clients watch the same channel, but it increases memory usage per shared channel. | +| `remove_duplicates` | Bool | No | `false` | Attempts to remove duplicate entries by `url`. This improves playlist cleanliness and reduces confusing duplicates in the client-facing output. | +| `deduplicate` | Map | No | - | Quality-aware duplicate removal. Channels whose match value is identical after stripping quality tokens (`4K`, `UHD`, `2160p`, `QHD`, `1440p`, `FHD`, `1080p`, `HD`, `720p`, `SD`, `480p`, `576p`) collapse to a single entry. Sub-keys: `match_by` (`caption` (default), `name`, `title`), `keep` (`best_quality` (default) keeps the highest quality tier, `first` keeps the first occurrence) and `match_as_ascii` (default `false`, normalizes accented characters in match keys so `Café HD` matches `Cafe FHD`). Matching is per cluster across all groups; ties keep the first occurrence. | +| `epg_output.lowercase_ids` | Bool | No | `false` | Canonicalizes visible technical EPG IDs with ASCII lowercase across M3U `tvg-id`, Xtream `epg_channel_id`, XMLTV channel/programme references, and EPG API responses. Changing this option requires a full target refresh. | +| `epg_output.lowercase_xmltv_display_names` | Bool | No | `false` | Applies Unicode lowercase exclusively to XMLTV `` values during serialization. Playlist names, Xtream names, programme titles, and programme descriptions remain unchanged; persisted target data does not require rebuilding. | +| `force_redirect` | Bool | No | `false` | Optional redirect-related behavior switch. This influences how Tuliprox serves final stream delivery where redirect-style output handling is required by the deployment model. | > **Shared HLS:** `share_live_streams.hls` requires `reverse_proxy.hls_cache` in `config.yml`. > Start with [Shared HLS Sessions](./shared-hls-sessions.md) for the feature overview and @@ -1455,6 +1476,30 @@ targets: > If the reverse-proxy buffer size is increased above `1024`, memory usage increases accordingly. > Example: with a buffer size of `2048`, each shared channel consumes at least **24 MB**. +#### Quality-Aware Deduplication Example + +Keep only the best-quality copy of every channel, collapsing entries like `News HD`, `News FHD`, and `NEWS [4K]` +into the single `NEWS [4K]` entry: + +```yaml +targets: + - name: clean_target + filter: 'Group ~ ".*"' + options: + deduplicate: + match_by: caption # caption (default) | name | title + keep: best_quality # best_quality (default) | first + match_as_ascii: false # true: "Café HD" matches "Cafe FHD" + output: + - type: m3u +``` + +* Matching compares the selected field with quality tokens stripped and remaining words lowercased, + so unrelated channels never collapse. +* `keep: first` keeps the first occurrence in playlist order instead of the highest quality tier + (useful when provider ordering already encodes your preference). +* Deduplication runs after group merging and before sorting; groups left empty are removed. + #### EPG Output Normalization `epg_output` applies to the entire target so every output format uses the same EPG identity space. Both options diff --git a/docs/src/operator/dvr.md b/docs/src/operator/dvr.md new file mode 100644 index 000000000..cd13d5dbf --- /dev/null +++ b/docs/src/operator/dvr.md @@ -0,0 +1,714 @@ +# DVR Operator Reference + +This guide covers everything an operator needs to know to deploy, configure, and manage the +DVR. It is the single source of truth for the documentation that +[`config.md`](../configuration/config.md) and [`rest-api-cookbook.md`](../rest-api-cookbook.md) +consume. + +## 1. Configuration reference + +All new fields live under `video.download.recording` in the config file. Every field is optional; +defaults match the recommended values. + +```yaml +video: + download: + recording: + enabled: true # default: true; false stops every DVR supervisor + container_format: mpegts # mpegts (default) | matroska | mp4 + directory: recordings/ # default: /recordings + timezone: Europe/Berlin # default: UTC (IANA required) + filename_template: "{channel}_{program_title}_{start_time}" + default_pre_roll_secs: 0 # 0..=max_pre_roll_secs + max_pre_roll_secs: 900 # ≤ 900 (15 min) + default_post_roll_secs: 0 # 0..=max_post_roll_secs + max_post_roll_secs: 1800 # ≤ 1800 (30 min) + retention: + keep_last_per_channel: 10 # > 0 when set + delete_after_days: 30 # > 0 when set + sweep_interval_secs: 3600 # default 3600; age/count sweep cadence + disk: + high_water_percent: 85 # 0..=100 + low_water_percent: 70 # 0..=100 and < high_water_percent + cleanup_interval_secs: 3600 # > 0; watermark-check cadence + safety_bytes: 1073741824 # > 0 (1 GiB) + quota: + default_private_bytes: 53687091200 # 50 GiB + per_user_bytes: + "web:user-uuid-1": 107374182400 # 100 GiB + shared_bytes: 536870912000 # 500 GiB + notifications: + outbox_buffer: 1024 # default 1024; in-memory queue depth + max_attempts: 6 # default 6; then dead-lettered + backoff_initial_secs: 5 # default 5 + backoff_max_secs: 900 # default 900 (15 min) + fallback_bytes_per_minute: 8388608 # 8 MiB/min, > 0 +``` + +| Field | Default | Range | Restart required | Effect | +|--------------------------------------|----------|-----------------------------|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | `true` | bool | no | `false` skips reconciliation, retention, and the notification outbox at startup, makes running supervisors idle on their next tick, answers every `/api/v1/recording/**` route with `501 recording_disabled`, and hides the DVR entries from the web UI navigation. | +| `container_format` | `mpegts` | `mpegts`, `matroska`, `mp4` | no | The `-f` muxer ffmpeg writes. `mpegts` survives truncation, so a recording killed mid-stream still plays — prefer it unless the source codecs need another container. Applies to recordings that start after the change. | +| `retention.sweep_interval_secs` | `3600` | > 0 | no | Cadence of the age/count sweep. Independent of `disk.cleanup_interval_secs`. | +| `disk.cleanup_interval_secs` | `3600` | > 0 | no | Cadence of the supervisor's tick, and therefore of the watermark check. Measurements are floored at one per 30 s. | +| `notifications.outbox_buffer` | `1024` | ≥ 1 | **yes** | Channel capacity between the recorder and the outbox worker. Fixed when the worker starts. | +| `notifications.max_attempts` | `6` | ≥ 1 | no | Delivery attempts per notification before it is dead-lettered. | +| `notifications.backoff_initial_secs` | `5` | ≥ 1 | no | First retry delay; doubles per attempt. | +| `notifications.backoff_max_secs` | `900` | ≥ `backoff_initial_secs` | no | Ceiling for the doubling. | + +Choosing values: + +- **Private-only home use** — leave `quota` unset, set `retention.keep_last_per_channel` to taste, + and leave `disk` unset if the recording filesystem is dedicated. +- **Shared family install** — set `quota.shared_bytes` and `disk.{high,low}_water_percent` so a + full disk degrades into retention rather than failed recordings. +- **Multi-tenant** — set `quota.default_private_bytes` plus per-user overrides, and keep + `notifications.max_attempts` low so a broken webhook does not accumulate a backlog. + +### 1.0.1 ⚠️ Retention policy warning + +**Retention deletes recordings.** If you are upgrading an existing install or reconfiguring +retention, whatever `retention` and `disk` values are in your config take effect on the next +supervisor sweep. If they were set optimistically or copied from an example, the first sweep may +delete recordings you expected to keep. + +Before restarting after a configuration change: + +1. **Read back your effective policy.** Check `video.download.recording.retention` and + `video.download.recording.disk` in `config.yml`. +2. **Work out what would be deleted.** `keep_last_per_channel: N` keeps the *N most recent* + recordings per (owner, channel) and deletes the rest. `delete_after_days: N` deletes anything + whose `completed_at` is more than N days old. The two are a **union**, not an intersection — + a recording matching either policy is deleted. +3. **If you are unsure, start with retention off.** Remove the `retention` block (or the + individual keys) and set `enabled: true` with no policy. Nothing is deleted, and you can + enable a policy deliberately once you have looked at the library. +4. **Back up** `downloads_state.json` and the recording directory. + +There is no dry-run mode. Deletions are logged under the `recording::audit` target with a +`recording_retention_delete` line and a reason (`Age`, `Count`, or `watermark`), so +`grep recording_retention_delete` after the first sweep tells you exactly what went. + +### 1.1 Validation rules + +The validation list, applied at `VideoConfigDto::prepare`: + +- `keep_last_per_channel > 0` when set. +- `delete_after_days > 0` when set. +- `cleanup_interval_secs > 0`. +- `low_water_percent < high_water_percent` and both in `0..=100`. +- `safety_bytes > 0`. +- `default_private_bytes`, `per_user_bytes[*]`, `shared_bytes` all `> 0` when set. +- `fallback_bytes_per_minute > 0`. +- `timezone` is a valid IANA zone (`chrono_tz::Tz::parse`). +- `filename_template` contains at least one known placeholder and is no longer than 240 UTF-8 bytes. + +Absent quotas and absent retention values disable the corresponding policy (no limit). + +### 1.2 Disk watermark semantics + +`high_water_percent` and `low_water_percent` are **used-space percentages** on the filesystem +containing the canonical recording root. The retention worker uses them with hysteresis: when +used-space ≥ `high_water_percent`, the worker deletes oldest completed recordings until +used-space ≤ `low_water_percent` (or the eligible list is exhausted). `cleanup_interval_secs` is +the wall-clock interval between passes; the worker uses a cancellation-aware Tokio interval and +will not overlap. + +Free space is measured **once per pass**, not once per deletion. The stop condition folds in the +bytes the pass has already reclaimed, so a pass deletes just enough recordings to reach the low +watermark and then stops. Only the recording root's own filesystem is measured — never +`storage_dir` or the generic download directory, which may sit on a different mount. + +### 1.3 What changed: supervisors now run + +Three background supervisors now actually execute their work. They existed as decision layers +before but were never started, so the DVR worked on the happy path and silently skipped +everything else. The consequences of switching them on are all things this guide describes, but +they happen now where they did not before: + +| Supervisor | What now happens | +|--------------------------|-------------------------------------------------------------------------------------------------------------------------------| +| Startup reconciliation | Recordings stuck in `Deleting` from an earlier crash are finished or restored on boot. Orphaned rule tombstones are repaired. | +| Retention | Age, count, and disk-watermark deletion begin. See the retention warning above. | +| Notification outbox | Lifecycle notifications are retried and persisted instead of being dropped on transient error. | + +### 1.4 Supervisors + +Three background supervisors implement the behaviour described in the rest of this document. All +three are started once the HTTP listener is bound, and all three honour the `downloads` +cancellation token, so a config reload stops and restarts them cleanly. + +| Supervisor | Cadence | Responsibility | +|--------------------------|-----------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| Startup reconciliation | once at boot, before the rule scheduler | Finishes or undoes deletions interrupted by a crash; repairs queue/rule-store drift. | +| Retention | `disk.cleanup_interval_secs` tick, policy sweep every `retention.sweep_interval_secs` | Age, count, and watermark deletion — all through the single `system_retention_delete` path. | +| Notification outbox | event-driven, with per-entry retry timers | Durable lifecycle-notification delivery with per-channel retry and dead-lettering. | + +Passes never overlap: a tick that arrives while the previous pass is still deleting is skipped. + +`GET /api/v1/recording/health` (administrator only) reports each supervisor's last-tick timestamp, +the outbox depth, and the dead-letter count, so liveness can be checked without reading the log: + +```json +{ + "enabled": true, + "server_time": 1700003600, + "reconciliation_last_run": 1700000000, + "retention_last_tick": 1700003400, + "retention_sweep_interval_secs": 3600, + "notification_last_drain": 1700003100, + "notification_outbox_depth": 0, + "notification_dead_lettered": 0, + "queue_revision": 412 +} +``` + +A `null` timestamp means that supervisor has never completed a pass. Compare `retention_last_tick` +against `server_time` and `disk.cleanup_interval_secs` to detect a stalled sweep. + +### 1.5 Support diagnostics + +`bin/dvr_doctor.sh` collects everything above plus the on-disk state into one dump suitable for a +support ticket. It is read-only and never touches config, the queue, or a recording. + +```bash +bin/dvr_doctor.sh --token "$ADMIN_TOKEN" +bin/dvr_doctor.sh --url https://tuliprox.example --token "$ADMIN_TOKEN" --storage-dir /opt/tuliprox/data +``` + +It reports supervisor health, the effective `recording` config block, quota, and aggregate +summaries of `downloads_state.json`, `recording_rules.json`, and the notification outbox — +including a `stuck_deleting` count and the set of channels the outbox is still retrying. The +summaries are deliberately aggregate: no titles, filenames, or owner ids are printed, because a +diagnostics dump gets pasted into tickets. The health and config sections need an administrator +token; the on-disk sections work without one. + +## 2. Recording directory layout and immutable IDs + +The recording root is the path configured in `recording.directory` (default `/recordings`). +The runtime resolves the path under `/users//` for private +recordings and `/shared/` for shared recordings. The `` is the +authenticated `UserId` from the JWT subject claim (UUID v4 hex with `web:` / `api:` / +`builtin:admin` namespaces). The `` is the collision-safe relative path the queue-mutation +boundary reserved. + +The directory tree: + +```text +/ + users/ + / # private + shared/ # shared +``` + +`` is **immutable** for the lifetime of the recording record. The runtime never derives +directory names from usernames, channels, programme titles, or any other mutable identifier. The +path is canonicalized at file open time against the recording root's file descriptor; any path +that escapes the root (via `..`, symlinks, or absolute paths) is rejected with +`recording_unsafe_path`. + +## 3. Filename placeholders + +The supported placeholders: + +- `{channel}` — channel display name; falls back to the stable channel id when the display name + is missing. +- `{program_title}` — programme title; sanitized via the existing filename sanitizer. +- `{start_time}` — programme start in the configured timezone, rendered as `YYYY-MM-DD_HH-mm`. +- `{end_time}` — same, programme end. +- `{episode}` — renders `SxxExx` when both season and episode numbers exist; otherwise empty. +- `{owner}` — sanitized owner display name. **Filename only; never a directory component.** The + `{owner}` placeholder is the only place the username-derived content may appear in the on-disk + path. + +The final stem is capped at 240 UTF-8 bytes without splitting a code point. When the sanitized +stem is empty, the runtime falls back to the recording task id. + +## 4. Lifecycle and restart behavior + +Every recording goes through three persistence states: + +1. **Partial path** — `partial_relative_path` is set; the `O_CREAT | O_EXCL | O_NOFOLLOW` open + guarantees an attacker-prepared symlink at the partial path is rejected. +2. **Finalize** — atomic rename from partial to final; the runtime refuses to clobber any + pre-existing final file (including symlinks). +3. **Complete** — `mutate` sets `Completed`, stamps `measured_bytes`, `completed_at`, and clears + `reserved_bytes`. + +A crash at any point is recoverable: + +- Active + valid final file → normalize to `Completed`. +- Active + valid partial file → normalize to terminal `Failed`, retain partial. +- Active + no owned file → normalize to terminal `Failed`. +- Unsafe path / type → log a security-category error, normalize to previous terminal state, do + not open. + +## 5. Quota charge-by-state + +The quota ledger charges the bytes below per `DownloadState`: + +| State | Charged bytes | +|---------------------------------------------------------------------------|---------------------------------------------------------:| +| `Scheduled` / `Queued` / `WaitingForCapacity` / `RetryWaiting` / `Paused` | `reserved_bytes` | +| `Downloading` | `max(reserved_bytes, measured_bytes)` | +| `Completed` | final `measured_bytes` | +| `Failed` / `Cancelled` with partial file | partial `measured_bytes` | + +A task that is mid-deletion carries `deleting_previous_state = Some(prior)` and is charged the +same bytes as the prior terminal state (`reserved_bytes` or `measured_bytes` depending on what +`prior` was); the field replaces the historical `DownloadState::Deleting` variant, which the +runtime no longer carries. The charge drops to zero only when `finalize_deletion` removes the +task from the queue. + +A task is counted exactly once. Private pools key on `RecordingOwner::User(uid)`; shared pools key +on `RecordingVisibility::Shared`; `LegacyAdmin` recordings count toward the shared pool. Per-user +overrides beat the configured default; an absent limit is unlimited. + +### 5.1 Active overrun policy + +Version one does **not** terminate an active recording because it grew beyond quota. `would_exceed` +is admission-only. When the measured partial size exceeds the reservation, the charge is +`max(reserved, measured)`; the next `would_exceed` call rejects new admissions until the recording +finishes or is deleted. The user-visible DTO surfaces an `Overrun` warning so operators can grant +more quota or delete the recording. + +### 5.2 Unknown bitrate + +When the bitrate is unknown at create time, the reservation is +`duration_minutes × fallback_bytes_per_minute` (default 8 MiB). The DTO surfaces an +`UnknownBitrate` warning. The runtime re-reserves with the measured rate as soon as the worker +starts. + +## 6. Disk admission + +The disk admission path: + +```text +headroom = free_bytes - safety_bytes - active_disk_reservations +admit = charge <= headroom +``` + +`free_bytes_for(path)` is `statvfs` (Unix) or `GetDiskFreeSpaceExW` (Windows) keyed on the supplied +path's mount. The pre-start flow always passes the canonical recording root so the measurement is +on the same filesystem the file will live on. Two starts cannot consume the same headroom — the +active reservation is serialized through the queue-mutation boundary. + +## 7. Safe deletion guarantees + +Deletions use a persisted two-phase operation. The runtime carries the deletion intent in +`recording.deleting_previous_state: Option` rather than as a +`DownloadState` variant — every terminal task that is mid-deletion stays in its prior state +(`Completed` / `Failed` / `Cancelled`) but carries the marker, which is what the rest of this +section means by "the task is in the deleting phase". + +1. **`begin_deletion`** runs inside the queue-mutation boundary. It stamps + `recording.deleting_previous_state = Some(prior)` (the prior terminal state) and zeros the + byte counts. +2. **`execute_deletion`** runs **after** the boundary. It inspects the path with + `symlink_metadata` (never `metadata`), so a symlink is seen as a symlink and refused rather + than dereferenced, and removes the file. Missing files are idempotent success. +3. **`finalize_deletion`** runs inside a fresh boundary. It removes the task from the queue and + clears `deleting_previous_state`. + +Startup recovery (any task whose `deleting_previous_state` is `Some(_)`): + +- `deleting_previous_state = Some(_)` + missing file → finish task removal. +- `deleting_previous_state = Some(_)` + existing valid regular file inside the recording root + → restore the prior terminal state, clear the marker. +- `deleting_previous_state = Some(_)` + unsafe path or non-regular file → restore the prior + state, log `recording_reconciliation_unsafe_path`, leave the file alone. + +### 7.1 Portability of the path guarantees + +The four guarantees — no symlink is followed, no existing file is clobbered, the publish is +atomic, nothing escapes the recording root — are built from portable primitives and hold +identically on every supported target: + +| Guarantee | Primitive | Portable? | +|-------------------------------------|----------------------------------------------------------------------------------------------------------------|-----------| +| No symlink followed on inspection | `symlink_metadata` (never `metadata`) | yes | +| No existing file clobbered | `create_new` → `O_CREAT\|O_EXCL` / `CREATE_NEW`; both fail on an existing entry *including a dangling symlink* | yes | +| Atomic publish | `rename`, after a no-follow existence check on the destination | yes | +| Contained in the recording root | component validation plus an owner-id component check, before any syscall | yes | + +Only one call has a platform-specific branch: `open_partial_no_clobber` additionally passes +`O_NOFOLLOW` on Unix. That is **defense in depth, not the mechanism** — the no-clobber property +already comes from `create_new`. `openat2` with `RESOLVE_BENEATH` / `RESOLVE_NO_SYMLINKS` would be +Linux-only and is deliberately not used. + +Earlier revisions carried a blanket `#![cfg(unix)]` on the path helper, which removed the module +wholesale on Windows and left every caller with unresolved imports — the DVR did not build on +Windows at all. The gate is now scoped to the single `O_NOFOLLOW` line. Tests that need to +*create* a symlink stay Unix-only (Windows requires developer mode or elevation for that); the +behaviour they cover is asserted portably by the no-clobber tests. + +## 8. Authorization matrix + +| Operation | Private recording | Shared recording | `LegacyAdmin` | Orphan | +|------------------------------|-----------------------------------|----------------------------------|---------------------------------|--------------| +| Read / Playback / Download | owner with `recording.read` | anyone with `recording.read` | admin only | admin only | +| Create private | user with `recording.write` | n/a | admin only | n/a | +| Create shared | rejected (admin only) | admin + `recording.write` | admin only | n/a | +| Edit / Cancel / Delete | owner + `recording.write` | admin + `recording.write` | admin only | n/a | +| Manage recurring rule | owner + `recording.write` | admin + `recording.write` | admin only | n/a | +| `SystemRetentionDelete` | ownership bypassed; state-gated | ownership bypassed; state-gated | ownership bypassed; state-gated | n/a | +| Orphan catalog | n/a | n/a | n/a | admin only | + +Administrators **do not** implicitly receive another regular user's private recording content. The +private owner is the only non-administrator allowed to read it. Administrative access is read-only +for diagnosis; mutations require either the `SystemRetentionDelete` action (which the retention +worker is the only legitimate caller of) or the appropriate `recording.write` + ownership +combination. + +Orphan catalog entries (recordings whose target/input no longer matches a configured source) are +visible only to administrators with `recording.read`. The path is never exposed; an opaque orphan +id is generated per discovery. + +## 9. Identity-registry bootstrap + +The identity registry is `web_user_ids.json` in the storage directory. The startup sequence is: + +1. Pre-scan `downloads_state.json` for `RecordingOwner::User(_)` entries (without the registry + loaded). +2. Load the existing registry (if any). +3. Initialize the registry **only** when no persisted real owner exists. New `UserId`s are + generated for any username that lacks one. +4. Fail closed on missing / corrupt registry when real owners exist. The server does not generate + replacement IDs in this case; the operator must restore the registry or run an explicit rename + migration. +5. Sync current principals (insert a new `UserId` for any username that lacks one). +6. Run the full queue load + normalization. + +The built-in administrator is the reserved subject id `builtin:admin` (constant). Operators do not +create an entry for it. + +## 10. Token refresh on permission schema bump + +`Claims` carries `subject_id: Option` and `permission_schema_version: u16`. The constant +`CURRENT_PERMISSION_SCHEMA_VERSION` is the source of truth. When the schema changes, bump the +constant; pre-bump tokens become stale: + +- `authenticator::validate_token_version` returns `AuthError::StaleSchema` for older versions. +- The HTTP layer emits a 401 with header `X-Token-Refresh: required`. +- The frontend's `RecordingError::TokenRefreshRequired` and the generic auth refresh handler + redirect the user back to the sign-in flow. + +Operators do **not** need to manually invalidate tokens on a schema bump. Existing user records in +`web_user_ids.json` are preserved; only the `subject_id` mapping for current usernames is +recomputed if missing. + +## 11. Deprecated `/file/record` behavior + +The legacy `POST /file/record` route is **deprecated** and delegates to +`RecordingService::create_recording` for administrators only. Non-administrators receive a 403 — +the deprecated route does not bypass the new policy. + +The migration: + +- **Frontend code**: switch from `downloads_service::queue_recording` to + `recording_service::RecordingService::create_task`. The new client submits + `RecordingSourceInput` (target_id + virtual_id + input_name) and `CreateRecordingTaskRequest`, + never a free-form URL. +- **Operator code**: the legacy route is documented as deprecated and will be removed in the + next major release. New automations should use `/api/v1/recording/tasks` (and + `/api/v1/recording/rules` for recurring rules). + +## 12. Scoped REST and WebSocket APIs + +The recording surface is exposed under `/api/v1/recording`: + +```text +GET /api/v1/recording/tasks +POST /api/v1/recording/tasks +PATCH /api/v1/recording/tasks/{id} +POST /api/v1/recording/tasks/{id}/cancel +DELETE /api/v1/recording/tasks/{id} +POST /api/v1/recording/conflicts/preview +GET /api/v1/recording/quota +GET /api/v1/recording/rules +POST /api/v1/recording/rules +PATCH /api/v1/recording/rules/{id} +DELETE /api/v1/recording/rules/{id}?future=retain|cancel +``` + +The `tasks` payload is a per-session filtered snapshot. The WebSocket protocol carries +`RecordingSnapshotRequest` and `RecordingSnapshotResponse { revision, tasks }`; there is no +recording delta message — every recording change goes out as a `RecordingChanged` event, and the +client re-requests a filtered snapshot in response. The `revision` field is the monotonic +`QueueRevision`; clients that detect a revision gap must request a fresh filtered snapshot. + +Two notifications exist for the recording subsystem: + +- `RecordingChanged` (no payload) — broadcast whenever a task mutates the queue (create, edit, + cancel, delete, finalize, retry). Triggers a filtered snapshot refresh on every subscribed + client that holds `recording.read`. +- `RecordingRulesChanged` (no payload) — broadcast on every rule mutation (create, edit, + delete, retain/cancel). Used by the rules view to refresh without polling. + +The cancel-recording-task endpoint emits **both** events because cancelling future rule +recordings mutates the queue as well as the rule store. + +Filtering is server-side: private events go only to the owner session, shared events go to anyone +with `recording.read`, `LegacyAdmin` events go only to administrator sessions. Generic download +events (`DownloadsResponse`, `DownloadsDeltaResponse`) contain no recording tasks. + +## 13. Conflict-preview advisory semantics + +The conflict analyzer is **advisory** — runtime capacity is authoritative. The three-bucket +classification: + +- `NoKnownConflict` — every segment of the candidate's padded interval is under capacity. +- `PossibleCapacityWait` — some segments are over. +- `LikelyMissedWindow` — every segment is over. + +Create / edit operations return the preview's severity as a warning; the request still succeeds +when the hard checks (authorization, source, interval, padding, quota, path reservation) pass. +The preview endpoint accepts the same `CreateRecordingTaskRequest` and returns the preview. + +Privacy: the preview never returns another task's id, title, channel, filename, or rule data. +Logs and the response only carry the provider scope, anonymized interval, and severity. + +## 14. Recurring-rule matching, DST, and reconciliation + +### 14.1 NewEpisode matching + +The matching order is stable series id first, normalized title as a fallback. Explicit `Repeat` +airing is excluded when `exclude_repeat = true` (the default). `Unknown` airing is treated as +new. The UI surfaces the title-fallback limitation when the EPG does not publish a stable +series id. + +### 14.2 WeeklyTimeslot matching + +`weekday` is `1..=7` (Monday = 1, Sunday = 7). `local_start_time` is `HH:MM`. `timezone` is an +IANA zone. The scheduler handles DST: + +- Ambiguous local time (fall-back) → the earlier instant. +- Nonexistent local time (spring-forward) → advance 1 hour at a time up to 4 hours. + +The UI surfaces the DST + IANA behavior next to the timezone input. + +### 14.3 Cross-store reconciliation + +Two stores can drift when one commit succeeds and the other fails. The reconciliation pass +produces a list of `ReconcileAction`s the caller applies under the queue-mutation boundary. The +truth table: + +| Situation | Action | +|-----------------------------------------------|----------------------------------------------| +| Materialized task, no `Scheduled` tombstone | `AddScheduledTombstone` | +| `Scheduled` tombstone, no task, rule enabled | `Materialize` | +| `Cancelled` tombstone, eligible inactive task | `Finalize` | +| `Cancelled` tombstone, **active** task | `ConflictingIntent` (log, manual resolution) | +| `Completed` tombstone, any task | suppress (no rematerialize) | +| Task terminal, tombstone `Scheduled` | `UpdateTombstone { kind: Completed }` | +| Tombstone past `expires_at` | `PruneTombstone` | +| Disabled rule | skip (no rematerialize) | +| Deleted rule | leave tombstone until expiry | + +Tombstones are retained for the longer of the EPG horizon and the 14-day minimum horizon +(`MIN_TOMBSTONE_HORIZON_SECS`). The fixed cross-store lock order is +`queue mutation boundary → rule repository mutation`. + +The reconciliation pass runs at startup, before the rule scheduler's first tick, so the scheduler +never plans against half-repaired state. Two notes on how the actions are applied: + +- `Materialize` cannot be executed literally — an `occurrence_key` cannot be turned back into a + programme window. The orphan `Scheduled` tombstone is dropped instead, which lets the scheduler + re-plan that occurrence from the rule and the EPG on its next tick. +- `ConflictingIntent` is only logged (`recording::audit`, `warn`). An active recording is never + cancelled because of a stale intent. + +Deletions interrupted by a crash are repaired in the same pass. For each task still carrying +`deleting_previous_state`, the physical file decides: + +| File state | Action | +|-------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Gone | Finish the deletion — remove the task from the queue. | +| Present, inside the recording root | Restore the prior terminal state and clear the marker. | +| Present, outside the recording root | Restore the prior state, leave the file alone, and log `recording_reconciliation_unsafe_path` (`recording::audit`, `warn`). Dropping the task instead would orphan a file nothing tracks. | + +### 14.4 Delete with retain / cancel + +`DELETE /api/v1/recording/rules/{id}?future=retain|cancel`: + +- `retain` — set the rule's `enabled = false`. Existing tasks keep their `rule_id` and + `occurrence_key` for historical provenance. The scheduler stops materializing future + occurrences. +- `cancel` — same as `retain` plus cancel only future inactive occurrences. Active recordings + are **never** auto-cancelled by rule deletion; the operator must resolve manually. + +`cancel` touches two stores — the queue and the rule repository — and they cannot commit together. +The queue side runs first and hands back a snapshot of every occurrence it cancelled; if the rule +delete then fails, those occurrences are **restored** from that snapshot (original state and +`reserved_bytes` included) before the error is returned. So a failed `future=cancel` leaves the +rule in place *and* its upcoming recordings intact. Only if the restore itself fails does the API +report `PartialOperation { primary: "future_cancelled", secondary: "rule_delete_failed" }`, which +tells the operator exactly which side won. + +## 15. At-most-once notification delivery + +The notification adapter follows the at-most-once protocol: + +1. The queue-mutation boundary persists a `NotificationMarker` for the lifecycle event + (`Started` / `Completed` / `Failed`) in the same transaction as the state transition. +2. After the transaction commits, the adapter hands the notification to the **notification + outbox** instead of delivering it inline. The recorder never blocks on, or waits for, a + messaging provider. +3. The outbox worker persists the entry to `storage_dir/recording_notification_outbox.json`, + then attempts delivery **per channel**. +4. A channel that fails is retried with capped exponential backoff + (`notifications.backoff_initial_secs`, doubling, clamped to `backoff_max_secs`). A channel + that succeeded is removed from the entry, so a retry can never deliver a duplicate to a + channel that already got the message — this is what keeps retries compatible with + at-most-once. +5. After `notifications.max_attempts` the entry is dead-lettered: it is dropped from the outbox + and logged at `error` level under the `recording::audit` target with the event kind, the + attempt count, the channels that never accepted it, and the original enqueue time. +6. On restart the outbox file is reloaded and the backlog resumes from where it stopped. A + corrupt outbox file is logged and skipped rather than blocking startup. +7. A crash between the marker commit and the outbox write can still lose a notification. The + window is one file write wide, and the marker prevents a duplicate on the next boot. + +If the in-memory channel to the worker is full (`notifications.outbox_buffer` entries pending), +the notification falls back to a single best-effort direct send. A recording is never delayed +because a messaging provider is down. + +The routing decision: + +- `Shared` → deliver to global channels. +- `Private` + `LegacyAdmin` owner → deliver. +- `Private` + administrator owner → deliver. +- `Private` + regular user owner → suppress. + +Missing messaging configuration is a no-op; the adapter logs the dispatch decision and returns. + +## 16. Migration checklist + +1. **Stop or quiesce** recording activity. Cancel active recordings and let the queue drain. +2. **Back up** the existing config, `downloads_state.json`, user / auth config, and messaging + config. +3. **Deploy** the version with additive normalization. No config changes are required for the + existing flows to keep working. +4. **Back up `web_user_ids.json`** after the first successful start. The bootstrap writes the + file automatically; the operator should preserve it across restarts. +5. **Grant `recording.read` / `recording.write`** explicitly to the user groups that need them. + The `permissions: 65535` legacy config does **not** implicitly grant the new bits. +6. **Refresh old tokens**. The schema bump forces a token refresh; pre-bump tokens get an + `X-Token-Refresh: required` 401. Users sign in again to receive the current claims. +7. **Verify the recording root and free space** with `statvfs` (Linux) / + `GetDiskFreeSpaceExW` (Windows). Confirm the `safety_bytes` is at least 1 GiB. +8. **Verify legacy recordings and paths**. The pre-Phase-1 `file_dir` / `file_path` fields + normalize to private `LegacyAdmin` recordings. Confirm the existing media files are within + the configured recording root or the legacy download root before enabling retention. +9. **Test one private and one shared recording** end-to-end before enabling the retention + worker in production. +10. **Enable retention / quotas gradually**. Start with `delete_after_days` only; add + `keep_last_per_channel` once the channel count is stable; add disk watermarks once the + free-space baseline is known. +11. **Wire the `/api/v1/recording` routes** in the frontends that need them. The new form + component (`recording_form`) is the single source of truth for both Playlist Explorer and + EPG. + +## 17. Acceptance scenarios (full sweep) + +The acceptance scenarios the operator should verify before declaring the migration done: + +1. Old configuration and `downloads_state.json` load without data loss. +2. Invalid recording kind / metadata combinations fail with `recording_invalid_state` or + `recording_invalid_source`. +3. Queue persistence failure leaves the state and revision unchanged and emits no delta. +4. WebSocket revision gaps trigger a filtered resnapshot. +5. A private recording is invisible to a second user in tasks, deltas, catalog, playback, + conflicts, quota, and logs. +6. Administrators can create shared recordings but do not see another user's private recordings + through ordinary endpoints. +7. New APIs cannot submit raw URLs, owner IDs, absolute paths, or filenames — the wire shape + is server-owned identifiers only. +8. Worker source re-resolution rejects stale / tampered source ownership + (`recording_invalid_source`). +9. Partial files are not clobbered; finalization never overwrites an external file. +10. Crash recovery after each worker lifecycle point is deterministic and does not replay a + missed live window. +11. Completed / failed / cancelled deletion removes only the task-owned final / partial file + safely. +12. A deletion failure preserves the task and file. +13. Successful file removal plus final persistence failure remains recoverable as `Deleting`. +14. Symlink, non-regular, and containment attacks are rejected with `recording_unsafe_path`. +15. Filename templates sanitize, truncate, and reserve collisions safely, including the + `{owner}` filename-only exception. +16. Private and shared quota pools are independent under concurrent create and start operations. +17. Active recordings remain conservatively charged; measured growth cannot create false free + quota. +18. Disk safety and retention use the recording-root filesystem. +19. Retention never deletes generic downloads, active recordings, partials, unsafe legacy files, + or orphan files. +20. DVR entries never enter Movies / Series or global user-independent caches. +21. Every playback / range / download open is authorized again. +22. EPG and Playlist Explorer use one form / one API. +23. Currently-airing scheduling reserves only the remaining duration and rejects elapsed windows. +24. Padding changes execution, quota, and conflicts consistently. +25. Editing succeeds only in allowed upcoming states and rolls back completely on persistence / + quota / path failure. +26. Conflict warnings follow the deterministic classification and redact private metadata. +27. New-episode / weekly rules materialize only in horizon and survive DST / restart. +28. Task / tombstone reconciliation is idempotent after every injected cross-store failure. +29. Deleted, cancelled, and completed occurrences are not recreated within the tombstone horizon. +30. Notifications make no more than one external attempt per committed marker. +31. Missing / disabled messaging never fails a recording. +32. Production Rust remains free of `unwrap`, `expect`, and `panic` additions. + +If a command or scenario fails, the operator records the exact command and output, fixes the +smallest root cause, reruns the focused test, and then the full relevant phase gate. Unexplained +known failures do not count as completion. + +## 18. Rollback + +The DVR is a feature flag, so a rollback does not require a binary downgrade: + +```yaml +video: + download: + recording: + enabled: false +``` + +That stops the supervisors and the rule scheduler, answers `501 recording_disabled` on the +recording routes, serves no recording data over the WebSocket, and hides the sidebar entries. +Existing recordings and the queue are left untouched, so re-enabling resumes where you left off. + +Keep the previous binary available as well: the notification outbox writes +`storage_dir/recording_notification_outbox.json`, which an older binary does not know about. It is +ignored rather than misread — an unknown file in `storage_dir` is harmless — but the queued +notifications in it are not delivered until the newer binary runs again. + +## 19. Verifying the installation + +After deployment or configuration changes, verify supervisor health: + +```bash +# Supervisor liveness. Administrator token required. +curl -s -H "Authorization: Bearer $TOKEN" \ + http://localhost:8901/api/v1/recording/health | jq +``` + +A healthy install shows a non-null `reconciliation_last_run` (stamped once at boot) and a +`retention_last_tick` no older than `disk.cleanup_interval_secs`. A `null` value means that +supervisor has never completed a pass. + +`bin/dvr_doctor.sh --token "$ADMIN_TOKEN"` wraps this up with the on-disk state — in particular a +`stuck_deleting` count, which should be `0` after a clean boot. + +Then check the log for the two lines worth reacting to: + +- `recording is enabled with no retention, no disk watermarks, and no quota` — nothing bounds + recording disk usage. Intentional on a dedicated filesystem; a mistake otherwise. +- `enabled NewEpisode recording rule(s) cannot match` — see the limitation below. + +### 19.1 Known limitation: `NewEpisode` rules + +`NewEpisode` rules do not currently match anything. The scheduler matches them by walking EPG +programmes, and no EPG horizon is supplied to it yet, so only `WeeklyTimeslot` rules materialize. +The condition is logged once per process rather than failing quietly. + +Until this is wired, record a recurring programme with a `WeeklyTimeslot` rule, or record +individual programmes from the EPG view. diff --git a/docs/src/rest-api-cookbook.md b/docs/src/rest-api-cookbook.md index b3d4deba1..c26996563 100644 --- a/docs/src/rest-api-cookbook.md +++ b/docs/src/rest-api-cookbook.md @@ -256,6 +256,35 @@ Notes: - Runtime refresh only produces `http`/`https` playback URLs; Stalker `rtmp://` / `rtsp://` commands are rejected explicitly rather than proxied half-supported. +## Example 10: Dry-run a filter expression against a target + +```bash +#!/bin/bash + +BASE_URL="http://localhost:8901" +TOKEN="PUT_YOUR_TOKEN_HERE" + +curl -s -X POST "$BASE_URL/api/v1/playlist/filter/preview" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + --data-raw '{"target": 1, "filter": "Group ~ \"^DE.*\" AND NOT Title CONTAINS \"Shopping\"", "limit": 10}' | jq . +``` + +Typical use: + +- test a filter DSL expression against a target's stored playlist before writing it into `source.yml` +- see matched/total counts overall and per cluster (live/vod/series) +- inspect sample matched and excluded channels to verify the expression does what you expect + +Notes: + +- `target` is the numeric target id (same id the playlist explorer uses). +- `filter` supports the full filter DSL including `!TEMPLATE!` references from your configured templates. +- Optional `limit` caps the sample lists (default 25, max 50); optional `match_as_ascii` mirrors the target option. +- An invalid filter expression returns HTTP 422 with `{"error": "...", "line": n, "column": n}`; `line`/`column` + are `null` for semantic errors without a source position (e.g. an invalid regex value). +- The preview reads the target's already-processed playlist; it never contacts providers or triggers an update. + ## Available `/api/v1` Endpoints This is a compact operator-oriented overview of the `/api/v1` REST API groups currently registered by the backend. @@ -275,16 +304,55 @@ This is a compact operator-oriented overview of the `/api/v1` REST API groups cu ### Downloads and recordings -| Method | Path | Purpose | -| --- | --- | --- | -| `GET` | `/api/v1/file/download/info` | Inspect remote file/download info | -| `POST` | `/api/v1/file/download` | Queue a file download | -| `POST` | `/api/v1/file/record` | Queue a live recording | -| `POST` | `/api/v1/file/download/pause` | Pause a queued or active download | -| `POST` | `/api/v1/file/download/resume` | Resume a paused download | -| `POST` | `/api/v1/file/download/cancel` | Cancel a queued or active download | -| `POST` | `/api/v1/file/download/remove` | Remove a task from the download database | -| `POST` | `/api/v1/file/download/retry` | Retry a failed download | +| Method | Path | Purpose | +| -------- |------------------------------------------------------|---------------------------------------------------------------------------------| +| `GET` | `/api/v1/file/download/info` | Inspect remote file/download info | +| `POST` | `/api/v1/file/download` | Queue a file download | +| `POST` | `/api/v1/file/record` | Queue a live recording | +| `POST` | `/api/v1/file/download/pause` | Pause a queued or active download | +| `POST` | `/api/v1/file/download/resume` | Resume a paused download | +| `POST` | `/api/v1/file/download/cancel` | Cancel a queued or active download | +| `POST` | `/api/v1/file/download/remove` | Remove a task from the download database | +| `POST` | `/api/v1/file/download/retry` | Retry a failed download | +| `GET` | `/api/v1/recording/tasks` | List visible DVR tasks | +| `POST` | `/api/v1/recording/tasks` | Create a DVR recording task from server-owned source ids | +| `PATCH` | `/api/v1/recording/tasks/{id}` | Edit an upcoming DVR recording | +| `POST` | `/api/v1/recording/tasks/{id}/cancel` | Cancel an active, queued, or scheduled DVR recording | +| `DELETE` | `/api/v1/recording/tasks/{id}` | Delete a finished DVR recording through the safe deletion lifecycle | +| `POST` | `/api/v1/recording/conflicts/preview` | Advisory conflict preview (severity, optional provider scope, overlap segments) | +| `GET` | `/api/v1/recording/quota` | Read the caller's private quota and shared DVR usage | +| `GET` | `/api/v1/recording/rules` | List visible recurring recording rules | +| `POST` | `/api/v1/recording/rules` | Create a weekly recurring recording rule | +| `PATCH` | `/api/v1/recording/rules/{id}` | Edit a recurring recording rule | +| `DELETE` | `/api/v1/recording/rules/{id}?future=retain\|cancel` | Delete a recurring recording rule | + +#### DVR WebSocket protocol + +The DVR layer ships its own scoped protocol messages on the same WebSocket connection as the rest of the +backend. The frontend sends `ProtocolMessage::RecordingSnapshotRequest` to subscribe and receives: + +- `ProtocolMessage::RecordingSnapshotResponse { revision, tasks }` — the full filtered task list for the + caller's session, sent on connect and after every mutation the session is permitted to see. +- `ProtocolMessage::RecordingDeltaResponse { revision, tasks }` — a smaller diff when only a few tasks + changed. + +The frontend never polls. After a successful `POST /api/v1/recording/tasks` it relies on the next +`RecordingSnapshotResponse` to update its view. Rule lists are refreshed by hooking into +`EventMessage::RecordingSnapshot` and re-calling `GET /api/v1/recording/rules`; a dedicated +`RecordingRulesChanged` event is a planned follow-up but not currently shipped. + +#### DVR conflict preview + +`POST /api/v1/recording/conflicts/preview` is **advisory only** — the response carries a severity bucket +(`none` / `soft` / `hard`) plus optional provider scope and overlap segments, but the server does not +reject the create call based on it. The frontend renders the preview as a hint next to the recording +form's scheduled interval, never as a hard block. + +#### `POST /api/v1/file/record` (deprecated) + +The legacy `POST /api/v1/file/record` endpoint is admin-gated and **scheduled for removal in the next +major version**. It returns `recording_forbidden` for non-admin principals. New code should call +`POST /api/v1/recording/tasks` with a `CreateRecordingTaskBody` payload. ### Playlist and web-player helpers @@ -298,6 +366,7 @@ This is a compact operator-oriented overview of the `/api/v1` REST API groups cu | `POST` | `/api/v1/playlist/epg` | Query EPG data for the Web UI | | `POST` | `/api/v1/playlist/series_info/{virtual_id}/{provider_id}` | Series metadata lookup | | `POST` | `/api/v1/playlist/series/episode/{virtual_id}` | Episode item lookup | +| `POST` | `/api/v1/playlist/filter/preview` | Dry-run a filter DSL expression against a target's stored playlist | | `GET` | `/api/v1/playlist/resource/{resource}` | Public resource access for playlist-related assets | ### Configuration @@ -360,5 +429,11 @@ With Web UI authentication enabled, many endpoints require matching permissions - `library.write` - `download.read` - `download.write` +- `recording.read` +- `recording.write` If a request is rejected, verify the logged-in Web UI user's RBAC group assignments first. + +> **See also:** the full [DVR Operator Reference](./operator/dvr.md) for the authorization matrix, identity-registry +> bootstrap, token refresh, recurring-rule matching + DST, cross-store reconciliation, at-most-once notification +> protocol, and the migration checklist. diff --git a/frontend/public/assets/i18n/ar.json b/frontend/public/assets/i18n/ar.json index 015aef610..8ef3fb53d 100644 --- a/frontend/public/assets/i18n/ar.json +++ b/frontend/public/assets/i18n/ar.json @@ -503,8 +503,7 @@ }, "STAGED_CLUSTERS": "المجموعات التي يتجاوزها هذا الإدخال المُرحّل في إدخال المزود المتصل.", "STAGED_PERSIST": "مسار ملف اختياري يُستخدم لحفظ قائمة التشغيل المُرحّلة التي تم تنزيلها أو إعادة استخدامها.", - "URL": "عنوان URL المصدر لهذا الإدخال." - , + "URL": "عنوان URL المصدر لهذا الإدخال.", "SERIES_SOURCE": "محدد المصدر لمجموعة المسلسلات عند تفعيل الإدخال المُرحّل (`default`، `staged`، `input`، `skip`).", "VOD_SOURCE": "محدد المصدر لمجموعة الفيديو حسب الطلب عند تفعيل الإدخال المُرحّل (`default`، `staged`، `input`، `skip`)." }, @@ -666,6 +665,8 @@ "PASSWORD": "كلمة مرور الوصول لقائمة التشغيل والبث لهذا المستخدم.", "NETWORK_ACCESS_COUNTRIES": "أضف رمز بلد واحد لكل إدخال بتنسيق ISO 3166-1 alpha-2، مثل `NL`، `FR`، أو `IT`.\n\nإذا تم التعيين، يتم رفض الطلبات من بلدان أخرى ما لم تطابق شبكة مسموح بها أيضًا.", "NETWORK_ACCESS_NETWORKS": "أضف شبكة عميل مسموح بها واحدة لكل إدخال بترميز CIDR. كل من IPv4 و IPv6 مدعومان، مثل `192.168.1.5/32`، `192.168.0.0/16`، `10.0.0.0/8`، أو `2001:db8::/32`.\n\nيتم فحص الشبكات قبل قواعد GeoIP للبلدان.", + "PLAN": "مستوى اشتراك اختياري. القيم غير المحددة (المجموعات، حدود الاتصال) تُورث من الخطة؛ مرشح المحتوى للخطة يُطبق دائمًا.", + "FILTER": "تعبير مرشح DSL اختياري يقيد المحتوى المرئي. يُدمج مع مرشح الخطة باستخدام AND.", "PLAYLIST": "قائمة التشغيل المعينة خصيصًا لسياق وكيل المستخدم.\n\nتتحكم مفاتيح التبديل `L`، `V`، و `S` في مجموعات المخرج التي يجب أن يتلقاها هذا المستخدم من هذا الهدف:\n`L` = مباشر، `V` = فيديو حسب الطلب، `S` = مسلسلات.\n\nحدد مجموعة واحدة على الأقل لتفعيل التصفية. إذا لم يتم تحديد أي مجموعة، يكون تصفية المجموعات غير نشطة ويتم تسليم جميع المجموعات للهدف المعين.", "PROXY": "تعريفات وصول الوكيل أو الأدوار للمستخدم المعين.", "SERVER": "عنوان أو تعريف خادم الوكيل المستهدف.", @@ -674,6 +675,15 @@ "UI_ENABLED": "إذا كان صحيحًا، يمكن لهذا المستخدم تسجيل الدخول إلى محرر باقة WebUI المبسط (الافتراضي صحيح).", "PRIORITY": "أولوية الاتصال المخصصة لسياق وكيل المستخدم هذا. الافتراضي = 0، أدنى أولوية = 127، أعلى أولوية = -128." }, + "USER_PLAN": { + "NAME": "اسم خطة فريد. يشير المستخدمون إليه عبر حقل Plan لوراثة هذه الإعدادات.", + "PROXY": "وضع وصول الوكيل (إعادة توجيه أو عكسي) الموروث من قبل أعضاء الخطة.", + "MAX_CONNECTIONS": "حد البث المتزامن الموروث من قبل أعضاء الخطة.", + "SOFT_CONNECTIONS": "فتحات مزود إضافية موروثة من قبل أعضاء الخطة.", + "FILTER": "تعبير مرشح DSL يقيد المحتوى المرئي لأعضاء الخطة.", + "TRIAL": "فترة تجريبية اختيارية مع وحدة (مثل 24h، 7d، 30d).", + "COMMENT": "وصف اختياري لهذه الخطة." + }, "RATE_LIMIT_CONFIG": { "BURST_SIZE": "يحدد العدد الأولي من الاتصالات المتاحة قبل تطبيق التقييد (مثل 10).", "ENABLED": "يُفعّل تحديد المعدل لكل IP لاتصالات الوكيل العكسي.", @@ -920,6 +930,13 @@ "ALIASES": "أسماء مستعارة", "ALIAS_NAME": "اسم الاسم المستعار", "ALL": "الكل", + "DESELECTED": "غير محدد", + "MOVE_UP": "تحريك لأعلى", + "MOVE_DOWN": "تحريك لأسفل", + "NOT_AVAILABLE": "غير متاح", + "ADD_KEY": "إضافة مفتاح", + "ADD_VALUE": "إضافة قيمة", + "AUTOMATIC_CODEC_TRIGGER_POLICY": "سياسة التشغيل التلقائي للترميز", "ALLOWED_COUNTRIES": "البلدان المسموح بها", "ALLOWED_NETWORKS": "الشبكات المسموح بها", "ACCOUNT_TOKEN": "رمز الحساب", @@ -1055,6 +1072,7 @@ "DOWNLOAD_STATE_SCHEDULED": "مجدول", "DOWNLOAD_STATE_WAITING_FOR_CAPACITY": "في انتظار السعة", "DURATION": "المدة", + "DURATION_MINUTES": "المدة (دقائق)", "EDIT": "تحرير", "EMPTY": "", "ENABLED": "مفعّل", @@ -1144,6 +1162,17 @@ "LIVE_STREAM_WITHOUT_EXTENSION": "بدون امتداد", "DISABLE_HLS_STREAMING": "إجبار MPEG-TS", "LOG": "سجل", + "LOGS": "السجلات", + "LIVE_LOGS": "السجلات المباشرة", + "AUTO_SCROLL": "التمرير التلقائي", + "CLEAR_LOGS": "مسح", + "COPY_LOGS": "نسخ", + "PAUSE_SCROLL": "إيقاف التمرير مؤقتًا", + "RESUME_SCROLL": "استئناف التمرير", + "SEARCH_LOGS": "تصفية السجلات...", + "NO_LOGS": "لم يتم العثور على سجلات", + "LOGS_COPIED": "تم نسخ السجلات إلى الحافظة", + "LOADING": "جارٍ التحميل…", "LOGIN": "تسجيل الدخول", "LOGOUT": "تسجيل الخروج", "LOGO_OVERRIDE": "تجاوز الشعار", @@ -1271,6 +1300,10 @@ "PERIOD_MILLIS": "فترة مللي ثانية", "PERSIST": "استمرار", "PLAYER_SERVER": "خادم المشغل", + "PLAN": "الخطة", + "PLANS": "الخطط", + "TRIAL": "فترة تجريبية", + "ADD_PLAN": "إضافة خطة", "PLAYLIST": "قائمة التشغيل", "PLAYLISTS": "قوائم التشغيل", "PLAYLIST_BROWSER": "متصفح قائمة التشغيل", @@ -1429,6 +1462,7 @@ "VOD_SOURCE": "مصدر VOD", "SERIES_SOURCE": "مصدر المسلسلات", "START": "بدء", + "START_TIME": "وقت البدء", "STAR_ON_GITHUB": "إذا أعجبك هذا المشروع، ضع نجمة ⭐ على GitHub", "STATS": "إحصائيات", "STATUS": "الحالة", @@ -1442,6 +1476,7 @@ "HEALTH_PROVIDERS": "سعة المزود", "HEALTH_CONNECTED": "متصل", "HEALTH_DISCONNECTED": "غير متصل", + "IDLE": "خامل", "HEALTH_PROVIDERS_OK": "جميعها سليمة", "HEALTH_PROVIDERS_NONE": "لا مزودين نشطين", "STREAM": "بث", @@ -1581,6 +1616,7 @@ "YES": "نعم", "FORCE": "إجبار", "ACTIVE_STREAMS": "تيارات البث النشطة", + "UPTIME": "مدة التشغيل", "PLAYER": "مشغل", "TITLE": "العنوان", "TOGGLE_SIDEBAR": "تبديل الشريط الجانبي", @@ -1626,10 +1662,78 @@ "PREVIOUS_PAGE": "الصفحة السابقة", "PAGE": "صفحة", "PAGES": "صفحات", - "NO_CONTENT": "لا يوجد محتوى" + "NO_CONTENT": "لا يوجد محتوى", + "RECORDING": "تسجيل", + "RECORDING_ACTION_CANCEL": "إلغاء", + "RECORDING_ACTION_DELETE": "حذف", + "RECORDING_ACTION_EDIT": "تحرير", + "RECORDING_COLUMN_ACTIONS": "إجراءات", + "RECORDING_COLUMN_CHANNEL": "القناة", + "RECORDING_COLUMN_SCHEDULE": "الجدول", + "RECORDING_COLUMN_STATUS": "الحالة", + "RECORDING_COLUMN_TITLE": "العنوان", + "RECORDING_COLUMN_VISIBILITY": "الظهور", + "RECORDING_EDIT_TITLE": "تحرير التسجيل", + "RECORDING_LIBRARY": "التسجيلات", + "RECORDING_QUOTA_PRIVATE": "الحصة الخاصة", + "RECORDING_QUOTA_SHARED": "الحصة المشتركة", + "RECORDING_RULE_COLUMN_ENABLED": "مفعّل", + "RECORDING_RULE_COLUMN_SCHEDULE": "الجدول", + "RECORDING_RULE_COLUMN_TARGET": "الهدف", + "RECORDING_RULE_COLUMN_VISIBILITY": "الظهور", + "RECORDING_RULE_EXCLUDE_REPEAT": "تخطّي الإعادات", + "RECORDING_RULE_EXCLUDE_REPEAT_OFF": "تخطّي الإعادات (إيقاف)", + "RECORDING_RULE_EXCLUDE_REPEAT_ON": "تخطّي الإعادات (تشغيل)", + "RECORDING_RULE_KIND": "نوع القاعدة", + "RECORDING_RULE_KIND_NEW_EPISODE": "حلقات جديدة", + "RECORDING_RULE_KIND_WEEKLY": "موعد أسبوعي", + "RECORDING_RULE_SERIES_ID": "معرّف المسلسل (EPG)", + "RECORDING_RULE_TITLE_PATTERN": "نمط العنوان (اختياري)", + "RECORDING_RULES": "القواعد المتكررة", + "RECORDING_FORM_TARGET_ID": "معرّف الهدف", + "RECORDING_FORM_VIRTUAL_ID": "المعرّف الافتراضي", + "RECORDING_FORM_INPUT_NAME": "اسم المُدخل", + "RECORDING_FORM_CHANNEL_ID": "معرّف القناة (اختياري)", + "RECORDING_FORM_WEEKDAY": "يوم الأسبوع (1=الإثنين … 7=الأحد)", + "RECORDING_FORM_START_TIME": "وقت البدء (HH:MM)", + "RECORDING_FORM_DURATION": "المدة (بالثواني)", + "RECORDING_FORM_TIMEZONE": "المنطقة الزمنية (IANA)", + "RECORDING_FORM_PRE_ROLL": "وقت قبل البدء (ثا)", + "RECORDING_FORM_ENABLED": "مفعّل", + "RECORDING_FORM_DISABLED": "معطّل", + "RECORDING_FORM_POST_ROLL": "وقت بعد الانتهاء (ثا)", + "RECORDING_FORM_SAVE": "حفظ", + "RECORDING_FORM_CANCEL": "إلغاء", + "RECORDING_FORM_DELETE_CONFIRM": "حذف هذا التسجيل؟", + "RECORDING_FORM_RULE_DELETE_CONFIRM": "حذف هذه القاعدة؟", + "TASK_STATUS_SCHEDULED": "مُجدول", + "TASK_STATUS_QUEUED": "في الطابور", + "TASK_STATUS_WAITING_FOR_CAPACITY": "في انتظار السعة", + "TASK_STATUS_RETRY_WAITING": "إعادة المحاولة", + "TASK_STATUS_RUNNING": "جارٍ التسجيل", + "TASK_STATUS_PAUSED": "متوقف مؤقتاً", + "TASK_STATUS_COMPLETED": "مكتمل", + "TASK_STATUS_FAILED": "فشل", + "TASK_STATUS_CANCELLED": "ملغى", + "CONFLICT_NO_KNOWN_CONFLICT": "لا يوجد تعارض", + "CONFLICT_POSSIBLE_CAPACITY_WAIT": "قد ينتظر فتحة متاحة", + "CONFLICT_LIKELY_MISSED_WINDOW": "من المحتمل تفويت البرنامج", + "CONFLICT_CHECKING": "جارٍ التحقق من التعارضات…", + "CONFLICT_OVERLAP_COUNT": "{count} تسجيل متداخل", + "RECORDING_COLUMN_PROGRESS": "التقدم", + "RECORDING_VISIBILITY_PRIVATE": "خاص", + "RECORDING_VISIBILITY_SHARED": "مشترك", + "RECORDING_EDIT_CLOSE": "إغلاق", + "RECORDING_FORM_START": "البداية", + "RECORDING_FORM_END": "النهاية", + "RECORDING_RULE_DELETE_RETAIN": "حذف القاعدة والإبقاء على التسجيلات القادمة", + "RECORDING_RULE_DELETE_CANCEL": "حذف القاعدة وإلغاء التسجيلات القادمة", + "RECORDING_RULE_ACTION_ENABLE": "تمكين", + "RECORDING_RULE_ACTION_DISABLE": "تعطيل" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "الحافظة غير مدعومة.\nمتصفحك أو السياق الحالي لا يسمح بالوصول إلى الحافظة.\nيرجى استخدام HTTPS أو localhost.", + "COPIED_TO_CLIPBOARD": "تم النسخ إلى الحافظة.", "CONFIG_CHANGED": "تم تغيير التكوين على الخادم.", "CONFIRM_DELETE": "هل تريد حقًا حذفه؟", "CONFIRM_SOURCES_SAVE": "هل تريد حقًا الحفظ؟", @@ -1672,7 +1776,8 @@ "SUCCESS": "تم بدء تحديث المكتبة بنجاح!" }, "LOGIN": { - "MESSAGE": "أدخل بيانات اعتمادك" + "MESSAGE": "أدخل بيانات اعتمادك", + "FAILED": "فشل تسجيل الدخول" }, "SESSION": { "EXPIRED": "انتهت جلستك. يرجى تسجيل الدخول مرة أخرى." @@ -1686,7 +1791,9 @@ "SCHEDULES_TITLE": "لا توجد جداول", "SCHEDULES_HINT": "أضف جدولًا لتشغيل تحديثات قائمة التشغيل تلقائيًا في الأوقات التي تختارها.", "API_PROXY_SERVER_TITLE": "لا توجد نقاط نهاية خادم", - "API_PROXY_SERVER_HINT": "أضف إدخال خادم لتحديد المضيف والمنافذ التي يستخدمها عملاؤك للوصول إلى قوائم التشغيل." + "API_PROXY_SERVER_HINT": "أضف إدخال خادم لتحديد المضيف والمنافذ التي يستخدمها عملاؤك للوصول إلى قوائم التشغيل.", + "API_PROXY_PLANS_TITLE": "لا توجد خطط", + "API_PROXY_PLANS_HINT": "أضف خطة لتعريف فئات القدرات القابلة لإعادة الاستخدام (المجموعات، حدود الاتصال، عوامل تصفية المحتوى) للمستخدمين." }, "PLAYLIST": { "WEBPLAYER_URL_COPY_TO_CLIPBOARD": "عنوان URL لمشغل الويب المنسوخ صالح لمدة 30 ثانية." @@ -1699,6 +1806,44 @@ "SELECT_AN_EPG_TO_VIEW_CONTENT": "حدد EPG لعرض محتواه.", "SELECT_AN_EPG_HINT": "اختر مصدر EPG أعلاه لمعاينة دليل برنامجه." }, + "RECORDING": { + "NO_TARGET": "اختر قائمة تشغيل هدفًا قبل التسجيل.", + "NO_REQUEST": "أكمل نموذج التسجيل قبل الإرسال.", + "QUEUED": "تمت إضافة التسجيل إلى الطابور.", + "PARTIAL_OPERATION": "تم تطبيق قاعدة التسجيل جزئيًا فقط.", + "NO_TASK_SELECTED": "اختر تسجيلًا لتحريره من المكتبة.", + "TASK_NOT_FOUND": "لم يتم العثور على التسجيل. قد يكون قد حُذف.", + "NO_INPUT": "اختر مُدخلًا قبل الحفظ.", + "ERROR": { + "TOKEN_REFRESH_REQUIRED": "انتهت صلاحية جلستك. أعد تحميل الصفحة للمتابعة.", + "INVALID_SOURCE": "لا يمكن تسجيل هذه القناة: لم تعد قائمة التشغيل أو المصدر مُهيّأً.", + "SHARED_NOT_ADMINISTRATOR": "يمكن للمسؤول فقط إنشاء تسجيل مشترك.", + "FORBIDDEN": "ليست لديك صلاحية للقيام بذلك.", + "INVALID_PATH": "مسار ملف التسجيل غير صالح.", + "INVALID_STATE": "لا يمكن تعديل هذا التسجيل في حالته الحالية.", + "INVALID_INTERVAL": "يجب أن يكون وقت الانتهاء بعد وقت البدء.", + "PADDING_LIMIT_EXCEEDED": "يتجاوز الهامش قبل أو بعد التسجيل الحد الأقصى المُهيّأ.", + "PROVENANCE_IMMUTABLE": "يحتفظ التسجيل الذي أنشأته قاعدة برابط القاعدة؛ ولا يمكن إزالته.", + "UNKNOWN": "لم يتم العثور على التسجيل. ربما تم حذفه.", + "DUPLICATE": "هذا البرنامج مُجدول للتسجيل بالفعل.", + "PATH_RESERVATION_FAILED": "لم يتمكن من حجز اسم ملف لهذا التسجيل.", + "QUOTA_EXCEEDED": "سيتجاوز هذا التسجيل حصة التخزين الخاصة بك.", + "NOT_TERMINAL": "لا يمكن حذف إلا تسجيل منتهٍ. ألغِه أولاً.", + "IO_ERROR": "لم يتمكن من كتابة ملف التسجيل أو إزالته.", + "PERSISTENCE_FAILED": "لم يتم حفظ التغيير. حاول مرة أخرى.", + "NETWORK": "لم يتمكن من الوصول إلى الخادم. تحقق من اتصالك.", + "OTHER": "فشل طلب التسجيل.", + "DISABLED": "التسجيل معطّل على هذا الخادم." + }, + "TASK_CANCELLED": "تم إلغاء التسجيل.", + "TASK_DELETED": "تم حذف التسجيل.", + "TASK_UPDATED": "تم تحديث التسجيل.", + "EMPTY_LIBRARY": "لا توجد تسجيلات بعد. سجّل برنامجاً من قائمة التشغيل أو دليل البرامج.", + "EMPTY_RULES": "لا توجد قواعد متكررة بعد. أنشئ واحدة لتسجيل مسلسل تلقائياً.", + "RULE_DELETED": "تم حذف القاعدة المتكررة.", + "RULE_UPDATED": "تم تحديث القاعدة المتكررة.", + "RULE_SAVED": "تم حفظ القاعدة المتكررة." + }, "RBAC": { "ADMIN_HINT": "المستخدم مسؤول — المجموعات الإضافية ليس لها تأثير.", "GROUP_CREATED": "تم إنشاء المجموعة بنجاح", @@ -1715,6 +1860,7 @@ }, "PLAYLIST_UPDATE": { "FAIL": "فشل تحديث قائمة التشغيل!", + "NO_TARGETS": "لم يتم تكوين قوائم تشغيل. أنشئ المصادر والأهداف في محرر المصادر أولاً.", "FAIL_FINISH": "فشل تحديث قائمة التشغيل!", "PARTIAL_FINISH": "تحديث قائمة التشغيل غير مكتمل وسيستمر من نقطة الحفظ.", "SUCCESS": "تم بدء تحديث قائمة التشغيل بنجاح!", @@ -1726,6 +1872,7 @@ "API_PROXY_CONFIG": { "FAIL": "فشل حفظ تكوين ApiProxy!", "NON_UNIQUE_SERVER_NAME": "اسم معلومات الخادم \"{name}\" موجود بالفعل.", + "NON_UNIQUE_PLAN_NAME": "اسم الخطة \"{name}\" موجود بالفعل.", "SUCCESS": "تم حفظ تكوين ApiProxy!" }, "BOUQUET": { @@ -1771,10 +1918,10 @@ "URL_USERNAME_AND_PASSWORD_MANDATORY": "URL واسم المستخدم وكلمة المرور مطلوبة!" }, "TARGET_NOT_EXISTS": "الهدف غير موجود", - "USER_DELETED": "تم حذف المستخدم بنجاح" - , + "USER_DELETED": "تم حذف المستخدم بنجاح", "VALIDATION": { "NETWORK_ACCESS_COUNTRIES": "أدخل رمز بلد واحد بتنسيق ISO المؤلف من حرفين لكل إدخال، مثل DE.", + "REQUIRED": "هذا الحقل مطلوب.", "NETWORK_ACCESS_NETWORKS": "أدخل CIDR واحد لكل إدخال، مثل 192.168.0.0/16 أو 2001:db8::/32." } }, diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 78abbefe7..2a49d3fae 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -507,8 +507,7 @@ }, "STAGED_CLUSTERS": "Clusters this staged input overrides on the connected provider input.", "STAGED_PERSIST": "Optional file path used to persist or reuse the downloaded staged playlist.", - "URL": "Source URL for this input." - , + "URL": "Source URL for this input.", "SERIES_SOURCE": "Source selector for the Series cluster when staged input is enabled (`default`, `staged`, `input`, `skip`).", "VOD_SOURCE": "Source selector for the VOD cluster when staged input is enabled (`default`, `staged`, `input`, `skip`)." }, @@ -569,7 +568,7 @@ "INTERNER_GC_MIN_POOL_SIZE": "Minimum number of interned strings required before the background interner GC runs.", "CUSTOM_STREAM_RESPONSE_PATH": "Path to a directory containing custom error response files for stream failures.", "CUSTOM_STREAM_RESPONSE_TIMEOUT_SECS": "Maximum duration in seconds for serving custom stream response video. `0` disables timeout.", - "CUSTOM_STREAM_RESPONSE_ENABLED": "Enable/Disable custom stream response video.", + "CUSTOM_STREAM_RESPONSE_ENABLED": "Enable/Disable custom stream response video.", "CUSTOM_STREAM_RESPONSE_ERROR_STATUS": "If custom video stream response is disabled, this value defines the default http error status.", "DEFAULT_USER_AGENT": "The User-Agent string used for outgoing requests if not specified elsewhere.", "DISK_BASED_PROCESSING": "If enabled, input playlists are processed from disk to save RAM.\nIf you have enough RAM, you can let this option disabled to improve performance.", @@ -665,6 +664,8 @@ "EPG_TIMESHIFT": "TimeZone or offset (e.g. +2:00, Europe/Berlin) to adjust EPG times for this user.", "EXP_DATE": "Unix timestamp or 'YYYY-MM-DD' when this user access expires.", "MAX_CONNECTIONS": "Limit of simultaneous streams for this user. 0 = unlimited.", + "PLAN": "Optional capability tier. Unset values (clusters, connection limits) inherit from the plan; the plan's content filter is always applied.", + "FILTER": "Optional filter DSL expression restricting which content this user sees. Combined with the plan filter using AND.", "SOFT_CONNECTIONS": "Additional provider slots above max_connections. Soft connections can be preempted by any normal connection or by a higher-priority soft connection.", "SOFT_PRIORITY": "Priority used while this user's connection is consuming a soft slot. Once promoted back to a normal slot, the regular priority applies again.", "PASSWORD": "Access password for this user's playlist and streams.", @@ -678,6 +679,15 @@ "UI_ENABLED": "If true, this user can log into the simplified WebUI bouquet editor (default true).", "PRIORITY": "Connection priority assigned to this user proxy context. Default = 0, lowest priority = 127, highest priority = -128." }, + "USER_PLAN": { + "NAME": "Unique plan name. Users reference it via their Plan field to inherit these settings.", + "PROXY": "Proxy access mode (Redirect or Reverse) inherited by plan members.", + "MAX_CONNECTIONS": "Simultaneous stream limit inherited by plan members whose own value is 0/unset. 0 = unlimited. Enforcement requires user_access_control.", + "SOFT_CONNECTIONS": "Additional preemptible provider slots inherited by plan members whose own value is 0/unset.", + "FILTER": "Filter DSL expression restricting the content visible to plan members. Combined with a member's own filter using AND, so it can only narrow, never widen.", + "TRIAL": "Optional trial window with a unit, e.g. 24h, 7d, 30d. When set, users newly created on this plan without their own expiry get an automatic trial expiry and Trial status. Applied only at user creation; enforcement requires user_access_control. Leave empty for no trial.", + "COMMENT": "Optional free-form description of this plan." + }, "RATE_LIMIT_CONFIG": { "BURST_SIZE": "Defines the initial number of available connections before throttling applies (e.g. 10).", "ENABLED": "Enables rate limiting per IP for reverse proxy connections.", @@ -908,6 +918,7 @@ "ADD_QUALITY_TO_FILENAME": "Quality", "USE_METADATA": "Use metadata", "ADD_SERVER": "Add Server", + "ADD_PLAN": "Add Plan", "ADD_STRIP_ENTRY": "Add Strip Entry", "ADD_TRAKT_CHART": "Add a Trakt chart", "ADD_TRAKT_LIST": "Add a Trakt list", @@ -924,6 +935,13 @@ "ALIASES": "Aliases", "ALIAS_NAME": "Alias Name", "ALL": "All", + "DESELECTED": "Deselected", + "MOVE_UP": "Move up", + "MOVE_DOWN": "Move down", + "NOT_AVAILABLE": "n/a", + "ADD_KEY": "Add key", + "ADD_VALUE": "Add value", + "AUTOMATIC_CODEC_TRIGGER_POLICY": "automatic codec trigger policy", "ALLOWED_COUNTRIES": "Allowed Countries", "ALLOWED_NETWORKS": "Allowed Networks", "ACCOUNT_TOKEN": "Account token", @@ -1059,6 +1077,7 @@ "DOWNLOAD_STATE_SCHEDULED": "Scheduled", "DOWNLOAD_STATE_WAITING_FOR_CAPACITY": "Waiting for capacity", "DURATION": "Duration", + "DURATION_MINUTES": "Duration (min)", "EDIT": "Edit", "EMPTY": "", "ENABLED": "Enabled", @@ -1084,6 +1103,7 @@ "FFPROBE_TIMEOUT": "FFprobe Timeout (sec)", "FIELD": "Field", "FILENAME": "Filename", + "FILENAME_PREVIEW": "Filename preview", "FILTER": "Filter", "FIRMWARE_NAME": "Firmware Name", "FIRMWARE_VERSION": "Firmware Version", @@ -1149,6 +1169,17 @@ "LIVE_STREAM_WITHOUT_EXTENSION": "Without ext.", "DISABLE_HLS_STREAMING": "Force MPEG-TS", "LOG": "Log", + "LOGS": "Logs", + "LIVE_LOGS": "Live Logs", + "AUTO_SCROLL": "Auto Scroll", + "CLEAR_LOGS": "Clear", + "COPY_LOGS": "Copy", + "PAUSE_SCROLL": "Pause Scroll", + "RESUME_SCROLL": "Resume Scroll", + "SEARCH_LOGS": "Filter logs...", + "NO_LOGS": "No log records found", + "LOGS_COPIED": "Logs copied to clipboard", + "LOADING": "Loading…", "LOGIN": "Login", "LOGOUT": "Logout", "LOGO_OVERRIDE": "logo Override", @@ -1247,6 +1278,7 @@ "ORIGIN_MANIFEST_TIMEOUT_MS": "Origin manifest timeout (ms)", "MANIFEST_RECOVERY_BURST": "Manifest recovery burst", "ORIGIN_SEGMENT_TIMEOUT_MS": "Origin segment timeout (ms)", + "ORIGINAL_INTERVAL": "Original interval", "OUTPUT": "Output", "PANEL": "Panel", "PANEL_ACCOUNT_INFO": "Account info", @@ -1278,6 +1310,9 @@ "PERIOD_MILLIS": "Period Millis", "PERSIST": "Persist", "PLAYER_SERVER": "Player Server", + "PLAN": "Plan", + "PLANS": "Plans", + "TRIAL": "Trial", "PLAYLIST": "Playlist", "PLAYLISTS": "Playlists", "PLAYLIST_BROWSER": "Playlist Browser", @@ -1287,13 +1322,17 @@ "PLAYLIST_VIEWER": "Explore", "PLEX": "Plex", "PORT": "Port", + "POST_ROLL": "Post-roll", + "PRE_ROLL": "Pre-roll", "PRIORITY": "Priority", + "PRIVATE": "Private", "PROBE": "Probe", "PROBE_DELAY_SEC": "Probe Delay (sec)", "PROBE_LIVE": "Probe Live", "PROBE_SERIES": "Probe Series", "PROBE_VOD": "Probe VOD", "PROBE_LIVE_INTERVAL_HOURS": "Live Interval (hour)", + "PROGRAMME": "Programme", "PROBE_FILTER": "Probe Filter", "PROBE_STREAM": "Probe Stream", "PROCESSING_ORDER": "Processing order", @@ -1335,6 +1374,50 @@ "RBAC_USERS": "Users", "RBAC_WRITE": "Write", "RECONNECT": "Reconnect", + "RECORDING": "Recording", + "RECORDING_ACTION_CANCEL": "Cancel", + "RECORDING_ACTION_DELETE": "Delete", + "RECORDING_ACTION_EDIT": "Edit", + "RECORDING_COLUMN_ACTIONS": "Actions", + "RECORDING_COLUMN_CHANNEL": "Channel", + "RECORDING_COLUMN_SCHEDULE": "Schedule", + "RECORDING_COLUMN_STATUS": "Status", + "RECORDING_COLUMN_TITLE": "Title", + "RECORDING_COLUMN_VISIBILITY": "Visibility", + "RECORDING_EDIT_TITLE": "Edit recording", + "RECORDING_LIBRARY": "Recordings", + "RECORDING_QUOTA_PRIVATE": "Private quota", + "RECORDING_QUOTA_SHARED": "Shared quota", + "RECORDING_RULE_COLUMN_ENABLED": "Enabled", + "RECORDING_RULE_COLUMN_SCHEDULE": "Schedule", + "RECORDING_RULE_COLUMN_TARGET": "Target", + "RECORDING_RULE_COLUMN_VISIBILITY": "Visibility", + "RECORDING_RULE_EXCLUDE_REPEAT": "Skip reruns", + "RECORDING_RULE_EXCLUDE_REPEAT_OFF": "Skip reruns (off)", + "RECORDING_RULE_EXCLUDE_REPEAT_ON": "Skip reruns (on)", + "RECORDING_RULE_KIND": "Rule type", + "RECORDING_RULE_KIND_NEW_EPISODE": "New episodes", + "RECORDING_RULE_KIND_WEEKLY": "Weekly timeslot", + "RECORDING_RULE_SERIES_ID": "Series ID (EPG)", + "RECORDING_RULE_TITLE_PATTERN": "Title pattern (optional)", + "RECORDING_RULES": "Recurring Rules", + "RECORDING_FORM_TARGET_ID": "Target ID", + "RECORDING_FORM_VIRTUAL_ID": "Virtual ID", + "RECORDING_FORM_INPUT_NAME": "Input name", + "RECORDING_FORM_CHANNEL_ID": "Channel ID (optional)", + "RECORDING_FORM_WEEKDAY": "Weekday (1=Mon … 7=Sun)", + "RECORDING_FORM_START_TIME": "Start time (HH:MM)", + "RECORDING_FORM_DURATION": "Duration (seconds)", + "RECORDING_FORM_TIMEZONE": "Timezone (IANA)", + "RECORDING_FORM_PRE_ROLL": "Pre-roll (sec)", + "RECORDING_FORM_ENABLED": "Enabled", + "RECORDING_FORM_DISABLED": "Disabled", + "RECORDING_FORM_POST_ROLL": "Post-roll (sec)", + "RECORDING_FORM_SAVE": "Save", + "RECORDING_FORM_CANCEL": "Cancel", + "RECORDING_FORM_DELETE_CONFIRM": "Delete this recording?", + "RECORDING_FORM_RULE_DELETE_CONFIRM": "Delete this rule?", + "RECURSION": "Recursion", "RECURSIVE": "Recursive", "REDIRECT": "Redirect", "REFERER_HEADER": "Remove referer header", @@ -1374,6 +1457,7 @@ "SCHEDULE": "Schedule", "SCHEDULES": "Schedules", "SCHEDULES_CONFIG": "Schedules", + "SCHEDULED_INTERVAL": "Scheduled interval", "SCRIPT": "Script", "SEARCH": "Search", "SEASON": "Season", @@ -1436,6 +1520,7 @@ "VOD_SOURCE": "VOD Source", "SERIES_SOURCE": "Series Source", "START": "Start", + "START_TIME": "Start time", "STAR_ON_GITHUB": "If you like this project, star ⭐ us on GitHub", "STATS": "Stats", "STATUS": "Status", @@ -1449,6 +1534,7 @@ "HEALTH_PROVIDERS": "Provider capacity", "HEALTH_CONNECTED": "Connected", "HEALTH_DISCONNECTED": "Disconnected", + "IDLE": "Idle", "HEALTH_PROVIDERS_OK": "All healthy", "HEALTH_PROVIDERS_NONE": "No active providers", "STREAM": "Stream", @@ -1535,6 +1621,7 @@ "UPDATE_GEOIP_DB": "Update Geo-IP db", "UPDATE_LOCAL_LIBRARY": "Update Local Library", "UPDATE_ON_BOOT": "Update on boot", + "UPTIME": "Uptime", "URL": "Url", "URL_IPV4": "Url IPv4", "URL_IPV6": "Url IPv6", @@ -1620,17 +1707,18 @@ "LANDING_PAGE": "Landing Page", "VIEW_TYPE_DASHBOARD": "Dashboard", "VIEW_TYPE_STATS": "Stats", - "VIEW_TYPE_STREAMS": "Streams", + "VIEW_TYPE_STREAMS": "Streams", "VIEW_TYPE_STREAM_HISTORY": "Stream History", - "VIEW_TYPE_DOWNLOADS": "Downloads", + "VIEW_TYPE_DOWNLOADS": "Downloads", "VIEW_TYPE_USERS": "Api Users", - "VIEW_TYPE_CONFIG": "Config", + "VIEW_TYPE_CONFIG": "Config", "VIEW_TYPE_SOURCE_EDITOR": "Sources", - "VIEW_TYPE_PLAYLIST_UPDATE": "Playlist Update", + "VIEW_TYPE_PLAYLIST_UPDATE": "Playlist Update", "VIEW_TYPE_PLAYLIST_SETTINGS": "Playlist Settings", - "VIEW_TYPE_PLAYLIST_EXPLORER": "Playlist Explorer", + "VIEW_TYPE_PLAYLIST_EXPLORER": "Playlist Explorer", "VIEW_TYPE_PLAYLIST_EPG": "Playlist EPG", - "VIEW_TYPE_RBAC": "WebUI Users", + "VIEW_TYPE_RBAC": "WebUI Users", + "VISIBILITY": "Visibility", "ADMISSION_STRATEGIES": "Admission Strategies", "RUNTIME_CONFIG_REPORT": "Runtime Config Report", "RUNTIME_CONFIG_REPORT_FORMAT": "Runtime Config Report Format", @@ -1652,10 +1740,35 @@ "PREVIOUS_PAGE": "Previous Page", "PAGE": "Page", "PAGES": "Pages", - "NO_CONTENT": "No content" + "NO_CONTENT": "No content", + "TASK_STATUS_SCHEDULED": "Scheduled", + "TASK_STATUS_QUEUED": "Queued", + "TASK_STATUS_WAITING_FOR_CAPACITY": "Waiting for capacity", + "TASK_STATUS_RETRY_WAITING": "Retrying", + "TASK_STATUS_RUNNING": "Recording", + "TASK_STATUS_PAUSED": "Paused", + "TASK_STATUS_COMPLETED": "Completed", + "TASK_STATUS_FAILED": "Failed", + "TASK_STATUS_CANCELLED": "Cancelled", + "CONFLICT_NO_KNOWN_CONFLICT": "No conflict", + "CONFLICT_POSSIBLE_CAPACITY_WAIT": "May wait for a free slot", + "CONFLICT_LIKELY_MISSED_WINDOW": "Likely to miss the programme", + "CONFLICT_CHECKING": "Checking for conflicts…", + "CONFLICT_OVERLAP_COUNT": "{count} overlapping recording(s)", + "RECORDING_COLUMN_PROGRESS": "Progress", + "RECORDING_VISIBILITY_PRIVATE": "Private", + "RECORDING_VISIBILITY_SHARED": "Shared", + "RECORDING_EDIT_CLOSE": "Close", + "RECORDING_FORM_START": "Start", + "RECORDING_FORM_END": "End", + "RECORDING_RULE_DELETE_RETAIN": "Delete rule, keep upcoming recordings", + "RECORDING_RULE_DELETE_CANCEL": "Delete rule and cancel upcoming recordings", + "RECORDING_RULE_ACTION_ENABLE": "Enable", + "RECORDING_RULE_ACTION_DISABLE": "Disable" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Clipboard not supported.\nYour browser or current context does not allow clipboard access.\nPlease use HTTPS or localhost.", + "COPIED_TO_CLIPBOARD": "Copied to clipboard.", "CONFIG_CHANGED": "Configuration on server changed.", "CONFIRM_DELETE": "You really want to delete it?", "CONFIRM_SOURCES_SAVE": "Do you really want to save?", @@ -1698,7 +1811,8 @@ "SUCCESS": "Successfully started library update!" }, "LOGIN": { - "MESSAGE": "Enter Your Credentials" + "MESSAGE": "Enter Your Credentials", + "FAILED": "Failed to login" }, "SESSION": { "EXPIRED": "Your session has expired. Please log in again." @@ -1712,7 +1826,9 @@ "SCHEDULES_TITLE": "No schedules", "SCHEDULES_HINT": "Add a schedule to run playlist updates automatically at the times you choose.", "API_PROXY_SERVER_TITLE": "No server endpoints", - "API_PROXY_SERVER_HINT": "Add a server entry to define the host and ports your clients use to reach the playlists." + "API_PROXY_SERVER_HINT": "Add a server entry to define the host and ports your clients use to reach the playlists.", + "API_PROXY_PLANS_TITLE": "No plans", + "API_PROXY_PLANS_HINT": "Add a plan to define reusable capability tiers (clusters, connection limits, content filters) for your users." }, "PLAYLIST": { "WEBPLAYER_URL_COPY_TO_CLIPBOARD": "The copied WebPlayer url is valid for 30 seconds." @@ -1725,8 +1841,46 @@ "SELECT_AN_EPG_TO_VIEW_CONTENT": "Select an epg to view its content.", "SELECT_AN_EPG_HINT": "Choose an EPG source above to preview its programme guide." }, + "RECORDING": { + "NO_TARGET": "Select a target playlist before recording.", + "NO_REQUEST": "Complete the recording form before submitting.", + "NO_TASK_SELECTED": "Select a recording to edit from the library.", + "QUEUED": "Recording queued.", + "PARTIAL_OPERATION": "Recording rule was only partially applied.", + "TASK_NOT_FOUND": "Recording not found. It may have been deleted.", + "NO_INPUT": "Select an input before saving.", + "ERROR": { + "TOKEN_REFRESH_REQUIRED": "Your session is out of date. Reload the page to continue.", + "INVALID_SOURCE": "This channel cannot be recorded: its playlist or input is no longer configured.", + "SHARED_NOT_ADMINISTRATOR": "Only an administrator can create a shared recording.", + "FORBIDDEN": "You do not have permission to do that.", + "INVALID_PATH": "The recording file path is invalid.", + "INVALID_STATE": "This recording can no longer be changed in its current state.", + "INVALID_INTERVAL": "The end time must be after the start time.", + "PADDING_LIMIT_EXCEEDED": "Pre-roll or post-roll exceeds the configured maximum.", + "PROVENANCE_IMMUTABLE": "A recording created by a rule keeps its rule link; it cannot be cleared.", + "UNKNOWN": "Recording not found. It may have been deleted.", + "DUPLICATE": "This programme is already scheduled to record.", + "PATH_RESERVATION_FAILED": "Could not reserve a filename for this recording.", + "QUOTA_EXCEEDED": "This recording would exceed your storage quota.", + "NOT_TERMINAL": "Only a finished recording can be deleted. Cancel it first.", + "IO_ERROR": "The recording file could not be written or removed.", + "PERSISTENCE_FAILED": "The change could not be saved. Try again.", + "NETWORK": "The server could not be reached. Check your connection.", + "OTHER": "The recording request failed.", + "DISABLED": "Recording is switched off on this server." + }, + "TASK_CANCELLED": "Recording cancelled.", + "TASK_DELETED": "Recording deleted.", + "TASK_UPDATED": "Recording updated.", + "EMPTY_LIBRARY": "No recordings yet. Record a programme from the playlist or the EPG.", + "EMPTY_RULES": "No recurring rules yet. Create one to record a series automatically.", + "RULE_DELETED": "Recurring rule deleted.", + "RULE_UPDATED": "Recurring rule updated.", + "RULE_SAVED": "Recurring rule saved." + }, "RBAC": { - "ADMIN_HINT": "User is Admin \u2014 additional groups have no effect.", + "ADMIN_HINT": "User is Admin — additional groups have no effect.", "GROUP_CREATED": "Group created successfully", "GROUP_DELETED": "Group deleted successfully", "GROUP_NAME_REQUIRED": "Group name is required", @@ -1741,6 +1895,7 @@ }, "PLAYLIST_UPDATE": { "FAIL": "Playlist update failed!", + "NO_TARGETS": "No playlists configured. Create sources and targets in the source editor first.", "FAIL_FINISH": "Playlist update failed!", "PARTIAL_FINISH": "Playlist update is incomplete and will continue from its saved checkpoint.", "SUCCESS": "Successfully started playlist update!", @@ -1752,6 +1907,7 @@ "API_PROXY_CONFIG": { "FAIL": "Failed to save the ApiProxy config!", "NON_UNIQUE_SERVER_NAME": "Server info name \"{name}\" already exists.", + "NON_UNIQUE_PLAN_NAME": "User plan name \"{name}\" already exists.", "SUCCESS": "ApiProxy config saved!" }, "BOUQUET": { @@ -1797,11 +1953,11 @@ "URL_USERNAME_AND_PASSWORD_MANDATORY": "Url, Username and Password are mandatory!" }, "TARGET_NOT_EXISTS": "Target does not exist", - "USER_DELETED": "User successfully deleted" - , + "USER_DELETED": "User successfully deleted", "VALIDATION": { "NETWORK_ACCESS_COUNTRIES": "Enter one 2-letter ISO country code per entry, for example DE.", - "NETWORK_ACCESS_NETWORKS": "Enter one CIDR per entry, for example 192.168.0.0/16 or 2001:db8::/32." + "NETWORK_ACCESS_NETWORKS": "Enter one CIDR per entry, for example 192.168.0.0/16 or 2001:db8::/32.", + "REQUIRED": "This field is required." } }, "SETUP": { diff --git a/frontend/public/assets/i18n/ru.json b/frontend/public/assets/i18n/ru.json index 26e3b72a6..d9cca3263 100644 --- a/frontend/public/assets/i18n/ru.json +++ b/frontend/public/assets/i18n/ru.json @@ -477,8 +477,7 @@ }, "STAGED_CLUSTERS": "Кластеры, которые этот промежуточный ввод переопределяет для подключенного ввода провайдера.", "STAGED_PERSIST": "Необязательный путь к файлу для сохранения или повторного использования загруженного промежуточного плейлиста.", - "URL": "URL источника для этого ввода." - , + "URL": "URL источника для этого ввода.", "SERIES_SOURCE": "Селектор источника для кластера Series, когда включен промежуточный ввод (`default`, `staged`, `input`, `skip`).", "VOD_SOURCE": "Селектор источника для кластера VOD, когда включен промежуточный ввод (`default`, `staged`, `input`, `skip`)." }, @@ -627,6 +626,8 @@ "PASSWORD": "Пароль доступа для плейлиста и потоков этого пользователя.", "NETWORK_ACCESS_COUNTRIES": "Добавьте один код страны ISO 3166-1 alpha-2 на запись, например `NL`, `FR` или `IT`.\n\nЕсли задано, запросы из других стран отклоняются, если они также не соответствуют разрешенной сети.", "NETWORK_ACCESS_NETWORKS": "Добавьте одну разрешенную клиентскую сеть на запись в нотации CIDR. Поддерживаются как IPv4, так и IPv6, например `192.168.1.5/32`, `192.168.0.0/16`, `10.0.0.0/8` или `2001:db8::/32`.\n\nСети проверяются перед правилами страны GeoIP.", + "PLAN": "Необязательный тарифный уровень. Незаданные значения (кластеры, лимиты соединений) наследуются от тарифа; фильтр контента тарифа применяется всегда.", + "FILTER": "Необязательное выражение фильтра DSL, ограничивающее видимый контент. Объединяется с фильтром тарифа через AND.", "PLAYLIST": "Плейлист, специально назначенный контексту прокси пользователя.\n\nПереключатели `L`, `V` и `S` управляют тем, какие выходные кластеры должен получать этот пользователь от этой цели:\n`L` = Live, `V` = VOD, `S` = Series.\n\nВыберите хотя бы один кластер для активации фильтра. Если ни один кластер не выбран, фильтр кластеров неактивен, и все кластеры доставляются для назначенной цели.", "PROXY": "Определения прокси-доступа или роли для указанного пользователя.", "SERVER": "Адрес или определение целевого прокси-сервера.", @@ -635,6 +636,15 @@ "UI_ENABLED": "Если true, этот пользователь может войти в упрощенный редактор букетов WebUI (по умолчанию true).", "PRIORITY": "Приоритет соединения, назначенный этому контексту прокси пользователя. По умолчанию = 0, самый низкий приоритет = 127, самый высокий приоритет = -128." }, + "USER_PLAN": { + "NAME": "Уникальное имя тарифа. Пользователи ссылаются на него через поле Plan для наследования этих настроек.", + "PROXY": "Режим прокси (Redirect или Reverse), наследуемый участниками тарифа.", + "MAX_CONNECTIONS": "Лимит одновременных потоков, наследуемый участниками тарифа при 0/не задано. 0 = без ограничений. Требует user_access_control.", + "SOFT_CONNECTIONS": "Дополнительные слоты провайдера, наследуемые участниками тарифа при 0/не задано.", + "FILTER": "Выражение фильтра DSL, ограничивающее видимый контент для участников тарифа. Объединяется с фильтром пользователя через AND.", + "TRIAL": "Пробный период с единицей времени (например, 24h, 7d, 30d).", + "COMMENT": "Необязательное описание тарифа." + }, "RATE_LIMIT_CONFIG": { "BURST_SIZE": "Определяет начальное количество доступных соединений до применения ограничения (например, 10).", "ENABLED": "Включает ограничение скорости по IP для подключений обратного прокси.", @@ -880,6 +890,13 @@ "ALIASES": "Псевдонимы", "ALIAS_NAME": "Имя псевдонима", "ALL": "Все", + "DESELECTED": "Не выбрано", + "MOVE_UP": "Переместить вверх", + "MOVE_DOWN": "Переместить вниз", + "NOT_AVAILABLE": "н/д", + "ADD_KEY": "Добавить ключ", + "ADD_VALUE": "Добавить значение", + "AUTOMATIC_CODEC_TRIGGER_POLICY": "политика автоматического запуска кодека", "ALLOWED_COUNTRIES": "Разрешенные страны", "ALLOWED_NETWORKS": "Разрешенные сети", "API": "API", @@ -1007,6 +1024,7 @@ "DOWNLOAD_STATE_SCHEDULED": "Запланировано", "DOWNLOAD_STATE_WAITING_FOR_CAPACITY": "Ожидание свободных ресурсов", "DURATION": "Длительность", + "DURATION_MINUTES": "Длительность (мин.)", "EDIT": "Редактировать", "EMPTY": "", "ENABLED": "Включено", @@ -1091,6 +1109,17 @@ "LIVE_STREAM_WITHOUT_EXTENSION": "Без расширения", "DISABLE_HLS_STREAMING": "Принудительный MPEG-TS", "LOG": "Лог", + "LOGS": "Логи", + "LIVE_LOGS": "Живые логи", + "AUTO_SCROLL": "Автопрокрутка", + "CLEAR_LOGS": "Очистить", + "COPY_LOGS": "Копировать", + "PAUSE_SCROLL": "Приостановить прокрутку", + "RESUME_SCROLL": "Возобновить прокрутку", + "SEARCH_LOGS": "Фильтр логов...", + "NO_LOGS": "Записи логов не найдены", + "LOGS_COPIED": "Логи скопированы в буфер обмена", + "LOADING": "Загрузка…", "LOGIN": "Вход", "LOGOUT": "Выход", "LOGO_OVERRIDE": "Замена логотипа", @@ -1210,6 +1239,10 @@ "PERIOD_MILLIS": "Период (мс)", "PERSIST": "Сохранять", "PLAYER_SERVER": "Сервер плеера", + "PLAN": "Тариф", + "PLANS": "Тарифы", + "TRIAL": "Пробный период", + "ADD_PLAN": "Добавить тариф", "PLAYLIST": "Плейлист", "PLAYLISTS": "Плейлисты", "PLAYLIST_BROWSER": "Браузер плейлистов", @@ -1340,6 +1373,7 @@ "VOD_SOURCE": "Источник VOD", "SERIES_SOURCE": "Источник сериалов", "START": "Старт", + "START_TIME": "Время начала", "STAR_ON_GITHUB": "Если вам нравится этот проект, поставьте нам звезду ⭐ на GitHub", "STATS": "Статистика", "STATUS": "Статус", @@ -1353,6 +1387,7 @@ "HEALTH_PROVIDERS": "Состояние провайдеров", "HEALTH_CONNECTED": "Подключено", "HEALTH_DISCONNECTED": "Отключено", + "IDLE": "Ожидание", "HEALTH_PROVIDERS_OK": "Все провайдеры работают", "HEALTH_PROVIDERS_NONE": "Нет активных провайдеров", "STREAM": "Поток", @@ -1495,6 +1530,7 @@ "YES": "Да", "FORCE": "Принудительно", "ACTIVE_STREAMS": "Активные потоки", + "UPTIME": "Время работы", "PLAYER": "Плеер", "TITLE": "Заголовок", "TOGGLE_SIDEBAR": "Переключить боковую панель", @@ -1508,17 +1544,17 @@ "LANDING_PAGE": "Лендинг", "VIEW_TYPE_DASHBOARD": "Дашборд", "VIEW_TYPE_STATS": "Статистика", - "VIEW_TYPE_STREAMS": "Потоки", + "VIEW_TYPE_STREAMS": "Потоки", "VIEW_TYPE_STREAM_HISTORY": "История потоков", - "VIEW_TYPE_DOWNLOADS": "Загрузки", + "VIEW_TYPE_DOWNLOADS": "Загрузки", "VIEW_TYPE_USERS": "Пользователи API", - "VIEW_TYPE_CONFIG": "Конфигурация", + "VIEW_TYPE_CONFIG": "Конфигурация", "VIEW_TYPE_SOURCE_EDITOR": "Источники", - "VIEW_TYPE_PLAYLIST_UPDATE": "Обновление плейлиста", + "VIEW_TYPE_PLAYLIST_UPDATE": "Обновление плейлиста", "VIEW_TYPE_PLAYLIST_SETTINGS": "Настройки плейлиста", - "VIEW_TYPE_PLAYLIST_EXPLORER": "Браузер плейлистов", + "VIEW_TYPE_PLAYLIST_EXPLORER": "Браузер плейлистов", "VIEW_TYPE_PLAYLIST_EPG": "EPG плейлиста", - "VIEW_TYPE_RBAC": "Пользователи WebUI", + "VIEW_TYPE_RBAC": "Пользователи WebUI", "ADMISSION_STRATEGIES": "Стратегии доступа", "RUNTIME_CONFIG_REPORT": "Отчет о конфигурации выполнения", "RUNTIME_CONFIG_REPORT_FORMAT": "Формат отчета о конфигурации выполнения", @@ -1540,10 +1576,78 @@ "PREVIOUS_PAGE": "Предыдущая страница", "PAGE": "Страница", "PAGES": "Страницы", - "NO_CONTENT": "Нет контента" + "NO_CONTENT": "Нет контента", + "RECORDING": "Запись", + "RECORDING_ACTION_CANCEL": "Отмена", + "RECORDING_ACTION_DELETE": "Удалить", + "RECORDING_ACTION_EDIT": "Редактировать", + "RECORDING_COLUMN_ACTIONS": "Действия", + "RECORDING_COLUMN_CHANNEL": "Канал", + "RECORDING_COLUMN_SCHEDULE": "Расписание", + "RECORDING_COLUMN_STATUS": "Статус", + "RECORDING_COLUMN_TITLE": "Название", + "RECORDING_COLUMN_VISIBILITY": "Видимость", + "RECORDING_EDIT_TITLE": "Редактировать запись", + "RECORDING_LIBRARY": "Записи", + "RECORDING_QUOTA_PRIVATE": "Личное хранилище", + "RECORDING_QUOTA_SHARED": "Общее хранилище", + "RECORDING_RULE_COLUMN_ENABLED": "Включено", + "RECORDING_RULE_COLUMN_SCHEDULE": "Расписание", + "RECORDING_RULE_COLUMN_TARGET": "Цель", + "RECORDING_RULE_COLUMN_VISIBILITY": "Видимость", + "RECORDING_RULE_EXCLUDE_REPEAT": "Пропускать повторы", + "RECORDING_RULE_EXCLUDE_REPEAT_OFF": "Пропускать повторы (выкл)", + "RECORDING_RULE_EXCLUDE_REPEAT_ON": "Пропускать повторы (вкл)", + "RECORDING_RULE_KIND": "Тип правила", + "RECORDING_RULE_KIND_NEW_EPISODE": "Новые серии", + "RECORDING_RULE_KIND_WEEKLY": "Еженедельное время", + "RECORDING_RULE_SERIES_ID": "ID серии (EPG)", + "RECORDING_RULE_TITLE_PATTERN": "Шаблон названия (необязательно)", + "RECORDING_RULES": "Повторяющиеся правила", + "RECORDING_FORM_TARGET_ID": "ID цели", + "RECORDING_FORM_VIRTUAL_ID": "Виртуальный ID", + "RECORDING_FORM_INPUT_NAME": "Имя входа", + "RECORDING_FORM_CHANNEL_ID": "ID канала (необязательно)", + "RECORDING_FORM_WEEKDAY": "День недели (1=Пн … 7=Вс)", + "RECORDING_FORM_START_TIME": "Время начала (ЧЧ:ММ)", + "RECORDING_FORM_DURATION": "Длительность (секунды)", + "RECORDING_FORM_TIMEZONE": "Часовой пояс (IANA)", + "RECORDING_FORM_PRE_ROLL": "Предварительный откат (сек)", + "RECORDING_FORM_ENABLED": "Включено", + "RECORDING_FORM_DISABLED": "Отключено", + "RECORDING_FORM_POST_ROLL": "Завершающий откат (сек)", + "RECORDING_FORM_SAVE": "Сохранить", + "RECORDING_FORM_CANCEL": "Отмена", + "RECORDING_FORM_DELETE_CONFIRM": "Удалить эту запись?", + "RECORDING_FORM_RULE_DELETE_CONFIRM": "Удалить это правило?", + "TASK_STATUS_SCHEDULED": "Запланировано", + "TASK_STATUS_QUEUED": "В очереди", + "TASK_STATUS_WAITING_FOR_CAPACITY": "Ожидание ресурсов", + "TASK_STATUS_RETRY_WAITING": "Повторная попытка", + "TASK_STATUS_RUNNING": "Идёт запись", + "TASK_STATUS_PAUSED": "Приостановлено", + "TASK_STATUS_COMPLETED": "Завершено", + "TASK_STATUS_FAILED": "Ошибка", + "TASK_STATUS_CANCELLED": "Отменено", + "CONFLICT_NO_KNOWN_CONFLICT": "Конфликтов нет", + "CONFLICT_POSSIBLE_CAPACITY_WAIT": "Возможно ожидание свободного слота", + "CONFLICT_LIKELY_MISSED_WINDOW": "Скорее всего, программа будет пропущена", + "CONFLICT_CHECKING": "Проверка конфликтов…", + "CONFLICT_OVERLAP_COUNT": "Перекрывающихся записей: {count}", + "RECORDING_COLUMN_PROGRESS": "Прогресс", + "RECORDING_VISIBILITY_PRIVATE": "Личное", + "RECORDING_VISIBILITY_SHARED": "Общее", + "RECORDING_EDIT_CLOSE": "Закрыть", + "RECORDING_FORM_START": "Начало", + "RECORDING_FORM_END": "Конец", + "RECORDING_RULE_DELETE_RETAIN": "Удалить правило, сохранить будущие записи", + "RECORDING_RULE_DELETE_CANCEL": "Удалить правило и отменить будущие записи", + "RECORDING_RULE_ACTION_ENABLE": "Включить", + "RECORDING_RULE_ACTION_DISABLE": "Отключить" }, "MESSAGES": { "CLIPBOARD_NOT_SUPPORTED": "Буфер обмена не поддерживается.\nВаш браузер или текущий контекст не позволяют доступ к буферу обмена.\nПожалуйста, используйте HTTPS или localhost.", + "COPIED_TO_CLIPBOARD": "Скопировано в буфер обмена.", "CONFIG_CHANGED": "Конфигурация на сервере изменилась.", "CONFIRM_DELETE": "Вы действительно хотите удалить это?", "CONFIRM_SOURCES_SAVE": "Вы действительно хотите сохранить?", @@ -1586,7 +1690,8 @@ "SUCCESS": "Обновление библиотеки успешно запущено!" }, "LOGIN": { - "MESSAGE": "Введите свои учетные данные" + "MESSAGE": "Введите свои учетные данные", + "FAILED": "Не удалось войти" }, "NO_ACCESS_MESSAGE": "Вашей учетной записи не назначены разрешения. Свяжитесь с администратором.", "NO_ACCESS_TITLE": "Нет доступа", @@ -1600,6 +1705,44 @@ "EPG": { "SELECT_AN_EPG_TO_VIEW_CONTENT": "Выберите EPG для просмотра содержимого." }, + "RECORDING": { + "NO_TARGET": "Выберите целевой плейлист перед записью.", + "NO_REQUEST": "Заполните форму записи перед отправкой.", + "QUEUED": "Запись поставлена в очередь.", + "PARTIAL_OPERATION": "Правило записи применено частично.", + "NO_TASK_SELECTED": "Выберите запись для редактирования из библиотеки.", + "TASK_NOT_FOUND": "Запись не найдена. Возможно, она была удалена.", + "NO_INPUT": "Выберите источник перед сохранением.", + "ERROR": { + "TOKEN_REFRESH_REQUIRED": "Ваша сессия устарела. Перезагрузите страницу, чтобы продолжить.", + "INVALID_SOURCE": "Этот канал нельзя записать: его плейлист или источник больше не настроен.", + "SHARED_NOT_ADMINISTRATOR": "Только администратор может создать общую запись.", + "FORBIDDEN": "У вас нет прав для этого действия.", + "INVALID_PATH": "Недопустимый путь к файлу записи.", + "INVALID_STATE": "Эту запись больше нельзя изменить в её текущем состоянии.", + "INVALID_INTERVAL": "Время окончания должно быть позже времени начала.", + "PADDING_LIMIT_EXCEEDED": "Предзапись или послезапись превышает настроенный максимум.", + "PROVENANCE_IMMUTABLE": "Запись, созданная правилом, сохраняет связь с правилом; её нельзя удалить.", + "UNKNOWN": "Запись не найдена. Возможно, она была удалена.", + "DUPLICATE": "Эта программа уже поставлена на запись.", + "PATH_RESERVATION_FAILED": "Не удалось зарезервировать имя файла для этой записи.", + "QUOTA_EXCEEDED": "Эта запись превысит вашу квоту хранения.", + "NOT_TERMINAL": "Удалить можно только завершённую запись. Сначала отмените её.", + "IO_ERROR": "Не удалось записать или удалить файл записи.", + "PERSISTENCE_FAILED": "Не удалось сохранить изменение. Попробуйте снова.", + "NETWORK": "Не удалось связаться с сервером. Проверьте подключение.", + "OTHER": "Не удалось выполнить запрос записи.", + "DISABLED": "Запись отключена на этом сервере." + }, + "TASK_CANCELLED": "Запись отменена.", + "TASK_DELETED": "Запись удалена.", + "TASK_UPDATED": "Запись обновлена.", + "EMPTY_LIBRARY": "Пока нет записей. Запишите программу из плейлиста или телегида.", + "EMPTY_RULES": "Пока нет повторяющихся правил. Создайте правило для автоматической записи сериала.", + "RULE_DELETED": "Повторяющееся правило удалено.", + "RULE_UPDATED": "Повторяющееся правило обновлено.", + "RULE_SAVED": "Повторяющееся правило сохранено." + }, "RBAC": { "ADMIN_HINT": "Пользователь является администратором — дополнительные группы не имеют эффекта.", "GROUP_CREATED": "Группа успешно создана", @@ -1616,6 +1759,7 @@ }, "PLAYLIST_UPDATE": { "FAIL": "Обновление плейлиста не удалось!", + "NO_TARGETS": "Плейлисты не настроены. Сначала создайте источники и цели в редакторе источников.", "FAIL_FINISH": "Обновление плейлиста не удалось!", "PARTIAL_FINISH": "Обновление плейлиста не завершено и продолжится с сохранённой контрольной точки.", "SUCCESS": "Обновление плейлиста успешно запущено!", @@ -1627,6 +1771,7 @@ "API_PROXY_CONFIG": { "FAIL": "Не удалось сохранить конфигурацию ApiProxy!", "NON_UNIQUE_SERVER_NAME": "Имя информации о сервере \"{name}\" уже существует.", + "NON_UNIQUE_PLAN_NAME": "Имя тарифа \"{name}\" уже существует.", "SUCCESS": "Конфигурация ApiProxy сохранена!" }, "BOUQUET": { @@ -1672,10 +1817,10 @@ "URL_USERNAME_AND_PASSWORD_MANDATORY": "URL, имя пользователя и пароль обязательны!" }, "TARGET_NOT_EXISTS": "Цель не существует", - "USER_DELETED": "Пользователь успешно удален" - , + "USER_DELETED": "Пользователь успешно удален", "VALIDATION": { "NETWORK_ACCESS_COUNTRIES": "Введите один 2-буквенный ISO код страны на запись, например DE.", + "REQUIRED": "Это поле обязательно.", "NETWORK_ACCESS_NETWORKS": "Введите один CIDR на запись, например 192.168.0.0/16 или 2001:db8::/32." } }, diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index fa7f88a3d..86a4ac176 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -97,7 +97,7 @@ }, { "keys": [ - "PlayArrowOutline", "Play" + "PlayArrowOutline", "Play", "AutoPlay" ], "path": "M 20.805539,10.144378 5.5830037,2.2804593 c -0.7254261,-0.3744562 -1.6646379,-0.3734349 -2.3874011,0 C 2.4583841,2.6603621 2,3.3701266 2,4.1323151 V 19.861175 c 0,0.762529 0.4580037,1.472293 1.1910377,1.850153 C 3.5535606,21.900259 3.967818,22 4.3889227,22 4.8088862,22 5.2227632,21.9006 5.5837645,21.71303 L 20.806299,13.84843 C 21.542757,13.467506 22,12.758082 22,11.996915 c -3.8e-4,-0.760146 -0.457243,-1.46957 -1.194461,-1.852537 z m -1.141205,1.935939 -15.2236767,7.86494 c -0.031193,0.016 -0.068472,0.0177 -0.106132,-0.0017 -0.031954,-0.01668 -0.052115,-0.04834 -0.052115,-0.08238 V 4.1319747 c 0,-0.034041 0.019781,-0.06536 0.053256,-0.082721 0.015977,-0.00851 0.034236,-0.012936 0.052876,-0.012936 0.0194,0 0.03766,0.00443 0.054017,0.012936 L 19.662432,11.912152 c 0.03386,0.0177 0.05516,0.05004 0.05516,0.08476 -3.81e-4,0.03506 -0.02016,0.06604 -0.05326,0.0834 z" }, @@ -494,6 +494,10 @@ "keys": ["Clipboard"], "path": "M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2m-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1m7 18H5V4h2v3h10V4h2z" }, + { + "keys": ["Copy", "ContentCopy"], + "path": "M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" + }, { "keys": ["Save"], "path": "M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7zm2 16H5V5h11.17L19 7.83zm-7-7c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3M6 6h9v4H6z" diff --git a/frontend/scss/app/_component.scss b/frontend/scss/app/_component.scss index 95fb5ca2a..e5e703b1e 100644 --- a/frontend/scss/app/_component.scss +++ b/frontend/scss/app/_component.scss @@ -6,6 +6,7 @@ @forward "components/text_button"; @forward "components/toggle_switch"; @forward "components/table"; +@forward "components/task_status"; @forward "components/panel"; @forward "components/card"; @forward "components/collapse_panel"; @@ -18,6 +19,7 @@ @forward "components/dashboard/sparkline"; @forward "components/dashboard/dashboard_view"; @forward "components/dashboard/stats_view"; +@forward "components/dashboard/log_console"; @forward "components/dashboard/streams_view"; @forward "components/dashboard/stream_history_view"; @forward "components/list_view"; @@ -98,3 +100,4 @@ @forward "components/field_explanation"; @forward "components/rbac/user_management"; @forward "components/country"; +@forward "components/recording"; diff --git a/frontend/scss/app/components/_custom_dialog.scss b/frontend/scss/app/components/_custom_dialog.scss index a353f1d2b..befc735de 100644 --- a/frontend/scss/app/components/_custom_dialog.scss +++ b/frontend/scss/app/components/_custom_dialog.scss @@ -14,7 +14,6 @@ } .tp__custom-dialog-modal { - padding-top: 15vh; padding-top: clamp(0vh, 15vh, 30vh); } diff --git a/frontend/scss/app/components/_downloads.scss b/frontend/scss/app/components/_downloads.scss index 904f0f20e..4bfafafe6 100644 --- a/frontend/scss/app/components/_downloads.scss +++ b/frontend/scss/app/components/_downloads.scss @@ -21,6 +21,19 @@ transform: scale(1.3); } } + + &__progress { + display: inline-flex; + flex-flow: column; + gap: 0.15rem; + } + + &__progress-bar { + width: 100%; + min-width: 6rem; + height: 0.4rem; + accent-color: var(--primary-color, currentColor); + } } .tp__record-dialog { diff --git a/frontend/scss/app/components/_input.scss b/frontend/scss/app/components/_input.scss index b984b1fee..a39412995 100644 --- a/frontend/scss/app/components/_input.scss +++ b/frontend/scss/app/components/_input.scss @@ -42,6 +42,21 @@ font-weight: bold; font-size: var(--font-size-norm); } + + &--required label::after { + content: " *"; + color: var(--error-color, #d9534f); + } + + &--error .tp__input-wrapper { + border-color: var(--error-color, #d9534f); + } +} + +.tp__input-error { + color: var(--error-color, #d9534f); + font-size: var(--font-size-norm); + padding-left: 2px; } textarea, diff --git a/frontend/scss/app/components/_recording.scss b/frontend/scss/app/components/_recording.scss new file mode 100644 index 000000000..15ef8d8f7 --- /dev/null +++ b/frontend/scss/app/components/_recording.scss @@ -0,0 +1,272 @@ +.tp__recording-form { + display: flex; + flex-flow: column; + gap: var(--gap-default); + padding: var(--padding-default); + min-width: 18rem; + + &__program { + display: flex; + flex-flow: column; + gap: var(--gap-small); + } + + &__row { + display: flex; + flex-flow: row; + gap: var(--gap-default); + align-items: center; + + } + + .tp__input { + flex-flow: row !important; + } + + &__visibility { + display: flex; + flex-flow: row; + gap: var(--gap-default); + align-items: center; + } + + &__filename { + display: flex; + flex-flow: row; + gap: var(--gap-default); + align-items: center; + } + + &__heading { + font-weight: bold; + font-size: var(--font-size-lg); + } + + &__error { + color: var(--error-color); + font-size: var(--font-size-sm); + } + + .tp__label { + font-weight: bold; + color: var(--text-color); + } + + .tp__value { + color: var(--modest-text-color); + } +} + +.tp__recording-library-view { + display: flex; + flex-flow: column; + gap: var(--gap-default); + padding: var(--padding-default); +} + +.tp__recording-quota { + display: flex; + flex-flow: row; + gap: var(--gap-default); + font-size: var(--font-size-sm); + color: var(--modest-text-color); + + span { + padding: var(--gap-small) var(--gap-default); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + } +} + +.tp__recording-list__empty { + padding: var(--padding-default); + color: var(--modest-text-color); + text-align: center; +} + +.tp__recording-row-actions { + display: flex; + flex-flow: row; + gap: var(--gap-small); + flex-wrap: wrap; +} + +.tp__task-edit-form { + display: flex; + flex-flow: column; + gap: var(--gap-default); + padding: var(--padding-default); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + margin: var(--gap-default) 0; + background-color: var(--surface-color, transparent); + + &__row { + display: grid; + grid-template-columns: 12rem 1fr; + gap: var(--gap-default); + align-items: center; + } + + &__actions { + display: flex; + flex-flow: row; + gap: var(--gap-small); + justify-content: flex-end; + } +} + +.tp__rule-form { + display: flex; + flex-flow: column; + gap: var(--gap-default); + padding: var(--padding-default); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + margin: var(--gap-default) 0; + background-color: var(--surface-color, transparent); + + &__row { + display: grid; + grid-template-columns: 12rem 1fr; + gap: var(--gap-default); + align-items: center; + + label { + font-weight: bold; + color: var(--text-color); + } + + input { + min-width: 12rem; + } + } + + &__actions { + display: flex; + flex-flow: row; + gap: var(--gap-small); + justify-content: flex-end; + margin-top: var(--gap-default); + } +} + +// Rule-delete dialog: the destructive choice (also cancel upcoming +// recordings) is an explicit opt-in, with the default spelled out below +// it so neither outcome is a surprise. +.tp__recording-rule-delete { + display: flex; + flex-flow: column; + gap: var(--gap-default); + padding: var(--padding-default); + + &__option { + display: flex; + flex-flow: row; + align-items: center; + gap: var(--gap-default); + cursor: pointer; + } + + &__hint { + margin: 0; + font-size: var(--font-size-sm); + color: var(--modest-text-color); + } +} + +.tp__recording-rule-enabled { + display: inline-flex; + align-items: center; +} + +.tp__recording-rule-row-actions { + display: flex; + flex-flow: row; + gap: var(--gap-small); + flex-wrap: wrap; +} + +.tp__recording-rules-view { + display: flex; + flex-flow: column; + gap: var(--gap-default); + padding: var(--padding-default); +} + +.tp__recording-rules__list { + list-style: none; + padding: 0; + margin: var(--gap-default) 0; + display: flex; + flex-flow: column; + gap: var(--gap-small); +} + +.tp__recording-rules__item { + display: flex; + flex-flow: row; + align-items: center; + justify-content: space-between; + gap: var(--gap-default); + padding: var(--gap-small) var(--gap-default); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + + span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--modest-text-color); + } +} + +// Advisory conflict badge in the recording form. Colours mirror the task +// status families: a conflict is a warning about the same lifecycle the +// status pill reports on, so it should not read as a new vocabulary. +.tp__conflict-badge { + display: inline-flex; + align-items: center; + white-space: nowrap; + padding: var(--padding-micro) var(--padding-mini); + border-radius: var(--tag-border-radius); + border: 1px solid var(--border-color); + color: var(--modest-text-color); + font-size: var(--font-size-sm); + + &--pending { + font-style: italic; + } + + &--ok { + color: var(--success-color); + background-color: var(--success-background-color); + border-color: var(--ok-color); + } + + &--warn { + color: var(--warn-color); + background-color: var(--warn-background-color); + border-color: var(--warn-color); + } + + &--danger { + color: var(--error-color); + background-color: var(--error-background-color); + border-color: var(--error-color); + } +} + +// The library could not be served — a stale token, or the DVR switched +// off server-side. Styled as an alert rather than as the empty state: +// the two mean different things and the user acts differently on each. +.tp__recording-list__unavailable { + padding: var(--padding-default); + margin: var(--gap-default); + text-align: center; + color: var(--error-color); + background-color: var(--error-background-color); + border: 1px solid var(--error-color); + border-radius: var(--border-radius); +} diff --git a/frontend/scss/app/components/_table.scss b/frontend/scss/app/components/_table.scss index e4e904f7e..8a76c5c83 100644 --- a/frontend/scss/app/components/_table.scss +++ b/frontend/scss/app/components/_table.scss @@ -58,6 +58,13 @@ align-items: center; justify-content: center; cursor: pointer; + width: 100%; + background: transparent; + border: 0; + margin: 0; + padding: 0; + color: inherit; + font: inherit; svg, .svg-icon { display: block; diff --git a/frontend/scss/app/components/_task_status.scss b/frontend/scss/app/components/_task_status.scss new file mode 100644 index 000000000..87be5ba74 --- /dev/null +++ b/frontend/scss/app/components/_task_status.scss @@ -0,0 +1,65 @@ +// Task-state pill, shared by the downloads view and the DVR library. +// +// The four families map to what the state means to the user rather than +// giving every variant its own hue: pending, in flight, finished, and +// gone. Colours come from existing theme tokens, so a theme that +// redefines them redefines these too, and every one keeps a border as +// well as a fill so the state survives a colour-blind palette. +.tp__task-status { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + padding: var(--padding-micro) var(--padding-mini); + border-radius: var(--tag-border-radius); + border: 1px solid var(--border-color); + color: var(--modest-text-color); + background-color: transparent; + font-size: var(--font-size-sm); + + &--pending { + color: var(--tag-status-pending-color); + background-color: var(--tag-status-pending-background-color); + border-color: var(--tag-status-pending-border-color); + } + + &--waiting { + color: var(--warn-color); + background-color: var(--warn-background-color); + border-color: var(--warn-color); + } + + // In flight. Given the strongest contrast: it is the state an + // operator scans a long list for. + &--active { + color: var(--active-color); + background-color: var(--success-background-color); + border-color: var(--active-color); + font-weight: bold; + } + + &--paused { + color: var(--modest-text-color); + border-color: var(--modest-text-color); + } + + &--done { + color: var(--success-color); + background-color: var(--success-background-color); + border-color: var(--ok-color); + } + + &--failed { + color: var(--error-color); + background-color: var(--error-background-color); + border-color: var(--error-color); + } + + &--cancelled { + color: var(--modest-text-color); + border-color: var(--border-color); + // Struck through so a cancelled task reads as gone even where the + // colour difference from `--paused` is subtle. + text-decoration: line-through; + } +} diff --git a/frontend/scss/app/components/dashboard/_log_console.scss b/frontend/scss/app/components/dashboard/_log_console.scss new file mode 100644 index 000000000..7af5ff11b --- /dev/null +++ b/frontend/scss/app/components/dashboard/_log_console.scss @@ -0,0 +1,275 @@ +@use "../../../size"; + +.tp__log-console { + display: flex; + flex-direction: column; + width: 100%; + background-color: var(--secondary-background-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + overflow: hidden; + box-sizing: border-box; + + &__toolbar { + display: flex; + flex-flow: row wrap; + align-items: center; + justify-content: space-between; + gap: var(--gap-default); + padding: var(--padding-small) var(--padding-default); + background-color: var(--tertiary-background-color); + border-bottom: 1px solid var(--border-color); + } + + &__filters { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--gap-mini); + } + + &__filter-btn { + padding: 0.2rem 0.55rem; + font-size: var(--font-size-xs); + font-weight: 600; + text-transform: uppercase; + border-radius: var(--border-radius-small); + border: 1px solid transparent; + cursor: pointer; + background-color: var(--secondary-background-color); + color: var(--modest-text-color); + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; + + &:hover { + color: var(--text-color); + border-color: var(--border-color); + } + + &--active { + color: var(--button-primary-color); + border-color: var(--accent-color); + background-color: var(--primary-background-color); + } + + &--trace.tp__log-console__filter-btn--active { + color: #9e9e9e; + border-color: #9e9e9e; + } + + &--debug.tp__log-console__filter-btn--active { + color: #9c27b0; + border-color: #9c27b0; + } + + &--info.tp__log-console__filter-btn--active { + color: #03a9f4; + border-color: #03a9f4; + } + + &--warn.tp__log-console__filter-btn--active { + color: #ff9800; + border-color: #ff9800; + } + + &--error.tp__log-console__filter-btn--active { + color: #f44336; + border-color: #f44336; + } + } + + &__controls { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: var(--gap-default); + } + + &__search { + display: flex; + align-items: center; + position: relative; + + input { + padding: 0.25rem 0.5rem; + font-size: var(--font-size-xs); + border-radius: var(--border-radius-small); + border: 1px solid var(--border-color); + background-color: var(--primary-background-color); + color: var(--text-color); + min-width: 140px; + outline: none; + + &:focus { + border-color: var(--accent-color); + } + } + } + + &__actions { + display: flex; + align-items: center; + gap: var(--gap-mini); + } + + &__action-btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.2rem 0.5rem; + font-size: var(--font-size-xs); + border-radius: var(--border-radius-small); + border: 1px solid var(--border-color); + background-color: var(--secondary-background-color); + color: var(--text-color); + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--primary-background-color); + } + + &--active { + background-color: var(--accent-color); + color: #ffffff; + border-color: var(--accent-color); + } + + .svg-icon { + width: 14px; + height: 14px; + } + } + + &__status { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: var(--font-size-xs); + color: var(--modest-text-color); + + &-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #9e9e9e; + + &--connected { + background-color: #4caf50; + box-shadow: 0 0 4px #4caf50; + } + + &--disconnected { + background-color: #f44336; + } + } + } + + &__output { + height: 670px; + max-height: 800px; + overflow-y: auto; + overflow-x: auto; + background-color: #121212; + color: #e0e0e0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 0.78rem; + line-height: 1.4; + padding: var(--padding-small); + display: flex; + flex-direction: column; + gap: 2px; + scrollbar-width: thin; + user-select: text; + } + + &__row { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + gap: 0.5rem; + white-space: pre-wrap; + word-break: break-word; + padding: 1px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + + &:hover { + background-color: rgba(255, 255, 255, 0.04); + } + } + + &__timestamp { + color: #757575; + flex-shrink: 0; + font-size: 0.72rem; + } + + &__level { + flex-shrink: 0; + padding: 0 0.35rem; + border-radius: 3px; + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + text-align: center; + min-width: 44px; + + &--trace { + background-color: rgba(158, 158, 158, 0.2); + color: #bdbdbd; + } + + &--debug { + background-color: rgba(156, 39, 176, 0.2); + color: #ce93d8; + } + + &--info { + background-color: rgba(3, 169, 244, 0.2); + color: #4fc3f7; + } + + &--warn { + background-color: rgba(255, 152, 0, 0.2); + color: #ffb74d; + } + + &--error { + background-color: rgba(244, 67, 54, 0.25); + color: #ef5350; + } + } + + &__target { + color: #81c784; + flex-shrink: 0; + font-weight: 600; + + &::after { + content: ":"; + color: #757575; + } + } + + &__message { + color: #f5f5f5; + flex: 1 1 auto; + + &--error { + color: #ef9a9a; + } + + &--warn { + color: #ffe082; + } + } + + &__empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #757575; + font-style: italic; + font-size: var(--font-size-sm); + } +} diff --git a/frontend/scss/app/components/playlist/_epg_view.scss b/frontend/scss/app/components/playlist/_epg_view.scss index 5ed0db18c..d5313a559 100644 --- a/frontend/scss/app/components/playlist/_epg_view.scss +++ b/frontend/scss/app/components/playlist/_epg_view.scss @@ -26,6 +26,8 @@ $epg-border-size: 4px; box-sizing: border-box; width: 100%; justify-content: flex-end; + align-items: center; + gap: var(--gap-default); } } @@ -203,6 +205,30 @@ $epg-border-size: 4px; } } + &-menu.tp__icon-button { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + min-width: 18px; + min-height: 18px; + padding: 0; + background-color: var(--background-color); + border-radius: var(--border-radius); + color: var(--text-color); + opacity: 0.85; + + &:hover, &:focus { + opacity: 1; + } + + .svg-icon { + width: 16px; + height: 16px; + } + } + } &__now-line { diff --git a/frontend/src/app/components/api_user/target_playlist.rs b/frontend/src/app/components/api_user/target_playlist.rs index 089a7ee9a..72cf2e96e 100644 --- a/frontend/src/app/components/api_user/target_playlist.rs +++ b/frontend/src/app/components/api_user/target_playlist.rs @@ -409,6 +409,7 @@ pub fn UserTargetPlaylist(props: &UserTargetPlaylistProps) -> Html { } })} options={Rc::new([FilterState::All, FilterState::Selected, FilterState::Deselected].iter().map(|s| s.to_string()).collect::>())} + labels={Rc::new(vec![translate.t("LABEL.ALL"), translate.t("LABEL.SELECTED"), translate.t("LABEL.DESELECTED")])} selected={filter_state_selections} /> diff --git a/frontend/src/app/components/authentication.rs b/frontend/src/app/components/authentication.rs index 9fcb2c57a..4a3f48d6e 100644 --- a/frontend/src/app/components/authentication.rs +++ b/frontend/src/app/components/authentication.rs @@ -1,5 +1,8 @@ use crate::{ - app::{components::login::Login, AppRoute}, + app::{ + components::{login::Login, LoadingScreen}, + AppRoute, + }, hooks::use_service_context, i18n::use_translation, }; @@ -107,7 +110,7 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { } if *loading { - html! {} + html! { } } else if *authenticated { html! { { for props.children.iter() } diff --git a/frontend/src/app/components/config/config_view.rs b/frontend/src/app/components/config/config_view.rs index b9b5668c3..1aa397224 100644 --- a/frontend/src/app/components/config/config_view.rs +++ b/frontend/src/app/components/config/config_view.rs @@ -19,7 +19,7 @@ use crate::{ }, ConfigContext, }, - hooks::use_service_context, + hooks::{use_key_down, use_service_context}, html_if, i18n::use_translation, services::{get_base_href, SetupCompleteRequestDto, SetupWebUserCredentialDto}, @@ -476,6 +476,23 @@ pub fn ConfigView() -> Html { }); } + // Save shortcut (Ctrl/Cmd+S) while editing + { + let handle_save_config = handle_save_config.clone(); + let can_save = (*edit_mode || setup_mode) + && services_ctx.auth.has_any_permissions(Permission::ConfigWrite | Permission::SourceWrite); + use_key_down(can_save, move |event: &web_sys::KeyboardEvent| { + if can_save + && !event.repeat() + && event.key().eq_ignore_ascii_case("s") + && (event.ctrl_key() || event.meta_key()) + { + event.prevent_default(); + handle_save_config.emit(String::new()); + } + }); + } + let context = ConfigViewContext { edit_mode: edit_mode.clone(), show_restart_notice: !setup_mode, diff --git a/frontend/src/app/components/config/hdhomerun_device_view.rs b/frontend/src/app/components/config/hdhomerun_device_view.rs index 8df4c322b..96f3385c4 100644 --- a/frontend/src/app/components/config/hdhomerun_device_view.rs +++ b/frontend/src/app/components/config/hdhomerun_device_view.rs @@ -2,9 +2,11 @@ use crate::{ app::components::{config::use_emit_reducer_state, Card, CollapsePanel, TextButton}, config_field, edit_field_number_u16, edit_field_number_u8, edit_field_text, generate_form_reducer, html_if, i18n::use_translation, + model::DialogResult, + services::DialogService, }; use shared::model::HdHomeRunDeviceConfigDto; -use yew::prelude::*; +use yew::{platform::spawn_local, prelude::*}; generate_form_reducer!( state: HdHomeRunDeviceConfigFormState { form: Box }, @@ -37,6 +39,7 @@ pub struct HdHomerunDeviceViewProps { #[component] pub fn HdHomerunDeviceView(props: &HdHomerunDeviceViewProps) -> Html { let translate = use_translation(); + let dialog = use_context::().expect("Dialog service not found"); let form_state: UseReducerHandle = use_reducer(|| HdHomeRunDeviceConfigFormState { form: Box::new(props.device.clone()), modified: false }); @@ -51,8 +54,17 @@ pub fn HdHomerunDeviceView(props: &HdHomerunDeviceViewProps) -> Html { let handle_remove_device = { let device_id = props.device_id; let onremove = props.on_remove.clone(); + let dialog = dialog.clone(); + let translator = translate.clone(); Callback::from(move |_| { - onremove.emit(device_id); + let dialog = dialog.clone(); + let translator = translator.clone(); + let onremove = onremove.clone(); + spawn_local(async move { + if dialog.confirm(&translator.t("MESSAGES.CONFIRM_DELETE")).await == DialogResult::Ok { + onremove.emit(device_id); + } + }); }) }; diff --git a/frontend/src/app/components/config/library_config_view.rs b/frontend/src/app/components/config/library_config_view.rs index a7cf08635..f7d09ee30 100644 --- a/frontend/src/app/components/config/library_config_view.rs +++ b/frontend/src/app/components/config/library_config_view.rs @@ -15,13 +15,15 @@ use crate::{ config_field, config_field_bool, config_field_child, edit_field_bool, edit_field_list, edit_field_number_u32, edit_field_text, generate_form_reducer, i18n::use_translation, + model::DialogResult, + services::DialogService, }; use shared::model::{ LibraryConfigDto, LibraryContentType, LibraryMetadataConfigDto, LibraryMetadataFormat, LibraryMetadataReadConfigDto, LibraryPlaylistConfigDto, LibraryScanDirectoryDto, ThumbnailConfigDto, }; use std::rc::Rc; -use yew::prelude::*; +use yew::{platform::spawn_local, prelude::*}; const LABEL_ENABLED: &str = "LABEL.ENABLED"; const LABEL_SCAN_DIRECTORIES: &str = "LABEL.SCAN_DIRECTORIES"; @@ -105,6 +107,7 @@ pub fn LibraryConfigView() -> Html { let translate = use_translation(); let config_ctx = use_context::().expect("ConfigContext not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); + let dialog = use_context::().expect("Dialog service not found"); let form_state: UseReducerHandle = use_reducer(|| LibraryConfigFormState { form: LibraryConfigDto::default(), modified: false }); @@ -272,13 +275,23 @@ pub fn LibraryConfigView() -> Html { let handle_remove_directory = { let form_state = form_state.clone(); + let dialog = dialog.clone(); + let translator = translate.clone(); Callback::from(move |idx: usize| { - let mut current_list = form_state.form.scan_directories.clone(); - if idx >= current_list.len() { - return; - } - current_list.remove(idx); - form_state.dispatch(LibraryConfigFormAction::ScanDirectories(current_list)); + let form_state = form_state.clone(); + let dialog = dialog.clone(); + let translator = translator.clone(); + spawn_local(async move { + if dialog.confirm(&translator.t("MESSAGES.CONFIRM_DELETE")).await != DialogResult::Ok { + return; + } + let mut current_list = form_state.form.scan_directories.clone(); + if idx >= current_list.len() { + return; + } + current_list.remove(idx); + form_state.dispatch(LibraryConfigFormAction::ScanDirectories(current_list)); + }); }) }; @@ -321,6 +334,7 @@ pub fn LibraryConfigView() -> Html { LibraryContentType::Auto => translate.t(LABEL_AUTO), LibraryContentType::Movie => translate.t(LABEL_MOVIE), LibraryContentType::Series => translate.t(LABEL_SERIES), + LibraryContentType::Recording => "recording".to_string(), })} } diff --git a/frontend/src/app/components/config/mod.rs b/frontend/src/app/components/config/mod.rs index 79ae9de8f..0304a6297 100644 --- a/frontend/src/app/components/config/mod.rs +++ b/frontend/src/app/components/config/mod.rs @@ -16,6 +16,7 @@ mod main_config_view; mod messaging_config_view; mod metadata_update_config_view; mod panel_config_view; +mod plans_view; mod proxy_config_view; mod reverse_proxy_config_view; mod schedules_config_view; @@ -37,6 +38,7 @@ pub use main_config_view::*; pub use messaging_config_view::*; pub use metadata_update_config_view::*; pub use panel_config_view::*; +pub use plans_view::*; pub use proxy_config_view::*; pub use reverse_proxy_config_view::*; pub use schedules_config_view::*; diff --git a/frontend/src/app/components/config/plans_view.rs b/frontend/src/app/components/config/plans_view.rs new file mode 100644 index 000000000..7f1aff04c --- /dev/null +++ b/frontend/src/app/components/config/plans_view.rs @@ -0,0 +1,508 @@ +use crate::{ + app::components::{ + input::Input, + menu_item::MenuItem, + popup_menu::PopupMenu, + userlist::{ProxyTypeInput, ProxyTypeView}, + AppIcon, Breadcrumbs, Card, CustomDialog, FieldWrapper, NoContent, Table, TableDefinition, TextButton, + }, + hooks::use_service_context, + i18n::use_translation, +}; +use shared::{ + concat_string, + error::TuliproxError, + model::{ClusterFlags, PlansConfigDto, ProxyType, SortOrder, UserPlanDto, UserPlanTrialDto}, +}; +use std::{fmt::Display, rc::Rc, str::FromStr}; +use web_sys::MouseEvent; +use yew::{platform::spawn_local, prelude::*}; + +const PLAN_HEADERS: [&str; 9] = + ["EMPTY", "NAME", "CLUSTER", "PROXY", "MAX_CONNECTIONS", "SOFT_CONNECTIONS", "FILTER", "TRIAL", "COMMENT"]; +const MSG_NON_UNIQUE_PLAN_NAME: &str = "MESSAGES.SAVE.API_PROXY_CONFIG.NON_UNIQUE_PLAN_NAME"; + +#[derive(Clone, Copy, PartialEq)] +enum PlanDialogMode { + Add, + Edit(usize), +} + +enum PlanTableAction { + Delete, + Edit, +} +impl Display for PlanTableAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self::Delete => "Delete", + Self::Edit => "Edit", + } + ) + } +} +impl FromStr for PlanTableAction { + type Err = TuliproxError; + fn from_str(s: &str) -> Result { + match s { + "Delete" => Ok(Self::Delete), + "Edit" => Ok(Self::Edit), + _ => Err(TuliproxError::Config(format!("Unknown Plan Action: {s}"))), + } + } +} + +fn build_default_plan(existing_plans: &[UserPlanDto]) -> UserPlanDto { + let mut index = existing_plans.len() + 1; + loop { + let name = format!("plan_{index}"); + if !existing_plans.iter().any(|plan| plan.name == name) { + return UserPlanDto { + name, + output_clusters: None, + proxy: None, + max_connections: 1, + soft_connections: 0, + filter: None, + trial: None, + comment: None, + }; + } + index += 1; + } +} + +fn plan_name_exists(plans: &[UserPlanDto], plan_name: &str, ignore_index: Option) -> bool { + plans + .iter() + .enumerate() + .any(|(idx, plan)| ignore_index.is_none_or(|ignore_idx| idx != ignore_idx) && plan.name == plan_name) +} + +fn cluster_flags_label(flags: Option) -> String { + flags.map_or_else(String::new, |f| { + let mut parts = Vec::new(); + if f.contains(ClusterFlags::Live) { + parts.push("L"); + } + if f.contains(ClusterFlags::Vod) { + parts.push("V"); + } + if f.contains(ClusterFlags::Series) { + parts.push("S"); + } + parts.join(" ") + }) +} + +fn make_field_handler(dialog_form: &UseStateHandle, updater: F) -> Callback +where + F: Fn(&mut UserPlanDto, String) + 'static, +{ + let dialog_form = dialog_form.clone(); + Callback::from(move |value: String| { + let mut form = (*dialog_form).clone(); + updater(&mut form, value); + dialog_form.set(form); + }) +} + +#[component] +pub fn PlansView() -> Html { + let translate = use_translation(); + let services = use_service_context(); + + let plans = use_state(Vec::::new); + let dialog_mode = use_state(|| None::); + let dialog_form = use_state(UserPlanDto::default); + let dialog_error = use_state(|| None::); + let popup_is_open = use_state(|| false); + let popup_anchor_ref = use_state(|| None::); + let selected_index = use_state(|| None::); + let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLANS")])); + + // Load plans from plans.yml on mount. + { + let plans = plans.clone(); + let services = services.clone(); + use_effect_with((), move |()| { + spawn_local(async move { + if let Some(cfg) = services.config.get_plans_config().await { + plans.set(cfg.plans.clone()); + } + }); + || () + }); + } + + // Persist the full plan list to plans.yml immediately after any mutation. + let persist_plans = { + let services = services.clone(); + let translate = translate.clone(); + Callback::from(move |list: Vec| { + let services = services.clone(); + let translate = translate.clone(); + let plans_dto = PlansConfigDto { plans: list }; + spawn_local(async move { + match services.config.save_plans_config(plans_dto).await { + Ok(()) => services.toastr.success(translate.t("MESSAGES.SAVE.API_PROXY_CONFIG.SUCCESS")), + Err(_) => services.toastr.error(translate.t("MESSAGES.SAVE.API_PROXY_CONFIG.FAIL")), + } + }); + }) + }; + + let handle_popup_onclick = { + let selected_index = selected_index.clone(); + let popup_anchor_ref = popup_anchor_ref.clone(); + let popup_is_open = popup_is_open.clone(); + Callback::from(move |(row, event): (usize, MouseEvent)| { + if let Some(target) = event.target_dyn_into::() { + selected_index.set(Some(row)); + popup_anchor_ref.set(Some(target)); + popup_is_open.set(true); + } + }) + }; + + let handle_popup_close = { + let popup_is_open = popup_is_open.clone(); + Callback::from(move |()| popup_is_open.set(false)) + }; + + let handle_menu_click = { + let popup_is_open = popup_is_open.clone(); + let selected_index = selected_index.clone(); + let plans = plans.clone(); + let dialog_mode = dialog_mode.clone(); + let dialog_form = dialog_form.clone(); + let dialog_error = dialog_error.clone(); + let persist_plans = persist_plans.clone(); + Callback::from(move |(name, _): (String, MouseEvent)| { + if let (Ok(action), Some(index)) = (PlanTableAction::from_str(&name), *selected_index) { + match action { + PlanTableAction::Delete => { + let mut list = (*plans).clone(); + if index < list.len() { + list.remove(index); + plans.set(list.clone()); + persist_plans.emit(list); + } + } + PlanTableAction::Edit => { + if let Some(plan) = plans.get(index) { + dialog_form.set(plan.clone()); + dialog_error.set(None); + dialog_mode.set(Some(PlanDialogMode::Edit(index))); + } + } + } + } + popup_is_open.set(false); + }) + }; + + let handle_add_plan = { + let plans = plans.clone(); + let dialog_mode = dialog_mode.clone(); + let dialog_form = dialog_form.clone(); + let dialog_error = dialog_error.clone(); + Callback::from(move |_| { + dialog_form.set(build_default_plan(&plans)); + dialog_error.set(None); + dialog_mode.set(Some(PlanDialogMode::Add)); + }) + }; + + let handle_dialog_close = { + let dialog_mode = dialog_mode.clone(); + let dialog_error = dialog_error.clone(); + Callback::from(move |()| { + dialog_error.set(None); + dialog_mode.set(None); + }) + }; + let handle_dialog_cancel = { + let handle_dialog_close = handle_dialog_close.clone(); + Callback::from(move |_| handle_dialog_close.emit(())) + }; + + let handle_name_change = make_field_handler(&dialog_form, |form, value| form.name = value); + let handle_max_connections_change = make_field_handler(&dialog_form, |form, value| { + form.max_connections = value.trim().parse::().unwrap_or(0); + }); + let handle_soft_connections_change = make_field_handler(&dialog_form, |form, value| { + form.soft_connections = value.trim().parse::().unwrap_or(0); + }); + let handle_filter_change = make_field_handler(&dialog_form, |form, value| { + form.filter = if value.trim().is_empty() { None } else { Some(value) }; + }); + let handle_trial_change = make_field_handler(&dialog_form, |form, value| { + form.trial = + if value.trim().is_empty() { None } else { Some(UserPlanTrialDto { duration: value.trim().to_string() }) }; + }); + let handle_comment_change = make_field_handler(&dialog_form, |form, value| { + form.comment = if value.trim().is_empty() { None } else { Some(value) }; + }); + let handle_proxy_change = { + let dialog_form = dialog_form.clone(); + Callback::from(move |proxy: ProxyType| { + let mut form = (*dialog_form).clone(); + form.proxy = Some(proxy); + dialog_form.set(form); + }) + }; + + let handle_dialog_save = { + let dialog_mode = dialog_mode.clone(); + let dialog_form = dialog_form.clone(); + let dialog_error = dialog_error.clone(); + let plans = plans.clone(); + let translate = translate.clone(); + let persist_plans = persist_plans.clone(); + Callback::from(move |_| { + let Some(mode) = *dialog_mode else { return }; + let mut plan = (*dialog_form).clone(); + if let Err(err) = plan.prepare() { + dialog_error.set(Some(err.to_string())); + return; + } + let ignore_index = match mode { + PlanDialogMode::Add => None, + PlanDialogMode::Edit(index) => Some(index), + }; + if plan_name_exists(&plans, &plan.name, ignore_index) { + let message = translate.t(MSG_NON_UNIQUE_PLAN_NAME).replace("{name}", &plan.name); + dialog_error.set(Some(message)); + return; + } + let mut list = (*plans).clone(); + match mode { + PlanDialogMode::Add => list.push(plan), + PlanDialogMode::Edit(index) => { + if let Some(existing) = list.get_mut(index) { + *existing = plan; + } + } + } + plans.set(list.clone()); + persist_plans.emit(list); + dialog_error.set(None); + dialog_mode.set(None); + }) + }; + + let render_header_cell = { + let translate = translate.clone(); + Callback::::from(move |col: usize| { + if col == 0 || col >= PLAN_HEADERS.len() { + html! {} + } else { + html! { {translate.t(&concat_string!("LABEL.", PLAN_HEADERS[col]))} } + } + }) + }; + + let render_data_cell = { + let popup_onclick = handle_popup_onclick.clone(); + Callback::<(usize, usize, Rc), Html>::from( + move |(row, col, dto): (usize, usize, Rc)| match PLAN_HEADERS[col] { + "EMPTY" => { + let popup_onclick = popup_onclick.clone(); + html! { + + } + } + "NAME" => html! {&dto.name}, + "CLUSTER" => html! {cluster_flags_label(dto.output_clusters)}, + "PROXY" => { + dto.proxy.as_ref().map_or_else(|| html! {}, |proxy| html! {}) + } + "MAX_CONNECTIONS" => html! {dto.max_connections.to_string()}, + "SOFT_CONNECTIONS" => html! {dto.soft_connections.to_string()}, + "FILTER" => html! {dto.filter.clone().unwrap_or_default()}, + "TRIAL" => html! {dto.trial.as_ref().map_or_else(String::new, |t| t.duration.clone())}, + "COMMENT" => html! {dto.comment.clone().unwrap_or_default()}, + _ => html! {""}, + }, + ) + }; + + let table_definition = { + let is_sortable = Callback::::from(move |_col| false); + let on_sort = Callback::, ()>::from(move |_args| {}); + let num_cols = PLAN_HEADERS.len(); + let items = (*plans).clone(); + use_memo(items, move |items| TableDefinition:: { + items: if items.is_empty() { + None + } else { + Some(Rc::new(items.iter().map(|plan| Rc::new(plan.clone())).collect())) + }, + num_cols, + is_sortable, + on_sort, + render_header_cell: render_header_cell.clone(), + render_data_cell: render_data_cell.clone(), + }) + }; + + let dialog_html = if let Some(mode) = *dialog_mode { + let title = match mode { + PlanDialogMode::Add => translate.t("LABEL.ADD_PLAN"), + PlanDialogMode::Edit(_) => format!("{} {}", translate.t("LABEL.EDIT"), translate.t("LABEL.PLAN")), + }; + html! { + +

{title}

+
+
+ + + + + + + + +
+ +
+
+ { + if let Some(error) = (*dialog_error).as_ref() { + html! { +
+ {error.clone()} +
+ } + } else { + html! {} + } + } +
+
+ + +
+
+ } + } else { + html! {} + }; + + html! { +
+ +
+ +
+
{translate.t("LABEL.PLANS")}
+
+ +
+
+
+ { + if plans.is_empty() { + html! { + + } + } else { + html! { definition={table_definition.clone()} /> } + } + } + + + + +
+
+
+ {dialog_html} +
+ } +} diff --git a/frontend/src/app/components/config/reverse_proxy_config_view.rs b/frontend/src/app/components/config/reverse_proxy_config_view.rs index 166332190..d36d120e9 100644 --- a/frontend/src/app/components/config/reverse_proxy_config_view.rs +++ b/frontend/src/app/components/config/reverse_proxy_config_view.rs @@ -1444,7 +1444,7 @@ pub fn ReverseProxyConfigView() -> Html { Html { Html { let services_ctx = use_service_context(); let config_ctx = use_context::().expect("Config context not found"); let config_view_ctx = use_context::().expect("ConfigViewContext not found"); + let dialog = use_context::().expect("Dialog service not found"); let selected_targets = use_state(|| None::>); let selected_schedule = use_state(|| None::); let selected_type = use_state(|| ScheduleTaskType::PlaylistUpdate); @@ -203,20 +206,30 @@ pub fn SchedulesConfigView() -> Html { let handle_remove = { let form_state = form_state.clone(); + let dialog = dialog.clone(); + let translator = translate.clone(); Callback::from(move |target_index: usize| { - if let Some(schedules) = form_state.data().schedules.as_ref() { - let new_schedules: Vec = schedules - .iter() - .enumerate() - .filter(|(idx, _)| *idx != target_index) - .map(|(_, schedule)| schedule.clone()) - .collect(); - if new_schedules.is_empty() { - form_state.dispatch(SchedulesConfigFormAction::Schedules(None)); - } else { - form_state.dispatch(SchedulesConfigFormAction::Schedules(Some(new_schedules))); + let form_state = form_state.clone(); + let dialog = dialog.clone(); + let translator = translator.clone(); + spawn_local(async move { + if dialog.confirm(&translator.t("MESSAGES.CONFIRM_DELETE")).await != DialogResult::Ok { + return; } - } + if let Some(schedules) = form_state.data().schedules.as_ref() { + let new_schedules: Vec = schedules + .iter() + .enumerate() + .filter(|(idx, _)| *idx != target_index) + .map(|(_, schedule)| schedule.clone()) + .collect(); + if new_schedules.is_empty() { + form_state.dispatch(SchedulesConfigFormAction::Schedules(None)); + } else { + form_state.dispatch(SchedulesConfigFormAction::Schedules(Some(new_schedules))); + } + } + }); }) }; diff --git a/frontend/src/app/components/dashboard/log_console.rs b/frontend/src/app/components/dashboard/log_console.rs new file mode 100644 index 000000000..75d42d101 --- /dev/null +++ b/frontend/src/app/components/dashboard/log_console.rs @@ -0,0 +1,268 @@ +use crate::{ + app::components::AppIcon, + hooks::{use_clipboard_copy, use_log_stream, UseLogStreamOptions}, + i18n::use_translation, +}; +use shared::model::{LogEntry, LogLevel}; +use wasm_bindgen::JsCast; +use web_sys::{HtmlElement, HtmlInputElement}; +use yew::prelude::*; + +#[derive(Properties, Clone, PartialEq, Debug)] +pub struct LogConsoleProps { + #[prop_or(true)] + pub active: bool, +} + +#[component] +pub fn LogConsole(props: &LogConsoleProps) -> Html { + let translate = use_translation(); + let copy_clipboard = use_clipboard_copy(); + + let auto_scroll = use_state(|| true); + let search_query = use_state(String::new); + let selected_level = use_state(|| None::); + + let log_stream = use_log_stream(UseLogStreamOptions { active: props.active, max_lines: 2000 }); + + let console_ref = use_node_ref(); + + // Auto-scroll effect + { + let console_ref = console_ref.clone(); + let auto_scroll = *auto_scroll; + let logs_count = log_stream.logs.len(); + + use_effect_with((logs_count, auto_scroll), move |(_, auto_scroll)| { + if *auto_scroll { + if let Some(el) = console_ref.cast::() { + el.set_scroll_top(el.scroll_height()); + } + } + || () + }); + } + + // Scroll listener to detect if user manually scrolled up + let on_scroll = { + let console_ref = console_ref.clone(); + let auto_scroll = auto_scroll.clone(); + Callback::from(move |_| { + if let Some(el) = console_ref.cast::() { + let scroll_bottom = el.scroll_top() + el.client_height(); + let threshold = 35; + let at_bottom = scroll_bottom >= el.scroll_height() - threshold; + if at_bottom != *auto_scroll { + auto_scroll.set(at_bottom); + } + } + }) + }; + + // Filter logs based on search query and selected log level + let filtered_logs: Vec<&LogEntry> = { + let query = search_query.to_lowercase(); + let level_filter = *selected_level; + + log_stream + .logs + .iter() + .filter(|entry| { + if let Some(lvl) = level_filter { + if entry.level != lvl { + return false; + } + } + if !query.is_empty() { + let matches_msg = entry.message.to_lowercase().contains(&query); + let matches_target = entry.target.to_lowercase().contains(&query); + let matches_ts = entry.timestamp.to_lowercase().contains(&query); + let matches_lvl = entry.level.as_str().contains(&query); + if !matches_msg && !matches_target && !matches_ts && !matches_lvl { + return false; + } + } + true + }) + .collect() + }; + + // Level selector callback + let on_select_level = |level: Option| { + let selected_level = selected_level.clone(); + Callback::from(move |_| { + selected_level.set(level); + }) + }; + + // Search input callback + let on_search_input = { + let search_query = search_query.clone(); + Callback::from(move |e: InputEvent| { + if let Some(input) = e.target().and_then(|t| t.dyn_into::().ok()) { + search_query.set(input.value()); + } + }) + }; + + // Auto-scroll toggle callback + let on_toggle_auto_scroll = { + let auto_scroll = auto_scroll.clone(); + let console_ref = console_ref.clone(); + Callback::from(move |_| { + let next = !*auto_scroll; + auto_scroll.set(next); + if next { + if let Some(el) = console_ref.cast::() { + el.set_scroll_top(el.scroll_height()); + } + } + }) + }; + + // Clear logs callback + let on_clear = { + let clear = log_stream.clear.clone(); + Callback::from(move |_| { + clear.emit(()); + }) + }; + + // Copy logs callback + let on_copy = { + let copy_clipboard = copy_clipboard.clone(); + let formatted_text: String = filtered_logs + .iter() + .map(|e| format!("{} [{:>5}] [{}] {}", e.timestamp, e.level.as_str().to_uppercase(), e.target, e.message)) + .collect::>() + .join("\n"); + + Callback::from(move |_| { + copy_clipboard.emit(formatted_text.clone()); + }) + }; + + let levels = [ + (None, "ALL", "tp__log-console__filter-btn--all"), + (Some(LogLevel::Error), "ERROR", "tp__log-console__filter-btn--error"), + (Some(LogLevel::Warn), "WARN", "tp__log-console__filter-btn--warn"), + (Some(LogLevel::Info), "INFO", "tp__log-console__filter-btn--info"), + (Some(LogLevel::Debug), "DEBUG", "tp__log-console__filter-btn--debug"), + (Some(LogLevel::Trace), "TRACE", "tp__log-console__filter-btn--trace"), + ]; + + html! { +
+
+
+ { + for levels.iter().map(|(lvl, label, cls)| { + let is_active = *selected_level == *lvl; + let active_cls = if is_active { "tp__log-console__filter-btn--active" } else { "" }; + html! { + + } + }) + } +
+ +
+ + +
+ + + + + +
+ +
+ +
+
+
+ +
+ { + if filtered_logs.is_empty() { + html! { +
+ { translate.t("LABEL.NO_LOGS") } +
+ } + } else { + html! { + <> + { + for filtered_logs.iter().enumerate().map(|(idx, entry)| { + let level_str = entry.level.as_str(); + let level_cls = format!("tp__log-console__level--{}", level_str); + let msg_cls = match entry.level { + LogLevel::Error => "tp__log-console__message--error", + LogLevel::Warn => "tp__log-console__message--warn", + _ => "", + }; + + html! { +
+ { &entry.timestamp } + + { level_str } + + { &entry.target } + + { &entry.message } + +
+ } + }) + } + + } + } + } +
+
+ } +} diff --git a/frontend/src/app/components/dashboard/metrics_history.rs b/frontend/src/app/components/dashboard/metrics_history.rs index 21eaf14cf..e86cad57a 100644 --- a/frontend/src/app/components/dashboard/metrics_history.rs +++ b/frontend/src/app/components/dashboard/metrics_history.rs @@ -111,6 +111,8 @@ mod tests { memory_total: 1_000, net_rx_bytes_per_sec, net_tx_bytes_per_sec, + net_rx_bytes_total: 0, + net_tx_bytes_total: 0, disk_total_bytes: 0, disk_free_bytes: 0, } @@ -140,6 +142,8 @@ mod tests { memory_total: 0, net_rx_bytes_per_sec: 0.0, net_tx_bytes_per_sec: 0.0, + net_rx_bytes_total: 0, + net_tx_bytes_total: 0, disk_total_bytes: 1_000, disk_free_bytes: 250, }; diff --git a/frontend/src/app/components/dashboard/mod.rs b/frontend/src/app/components/dashboard/mod.rs index d0026c3b0..5e787e40f 100644 --- a/frontend/src/app/components/dashboard/mod.rs +++ b/frontend/src/app/components/dashboard/mod.rs @@ -4,6 +4,7 @@ mod discord_action_card; mod documentation_action_card; mod github_action_card; mod ipinfo_action_card; +mod log_console; mod metrics_history; mod playlist_progress_status_card; mod sparkline; @@ -18,7 +19,7 @@ mod streams_view; pub use self::{ action_card::*, dashboard_view::*, discord_action_card::*, documentation_action_card::*, github_action_card::*, - ipinfo_action_card::*, metrics_history::*, playlist_progress_status_card::*, sparkline::*, stats_view::*, - status_card::*, stream_display::*, stream_history_view::*, streams_view::*, user_action_card::*, + ipinfo_action_card::*, log_console::*, metrics_history::*, playlist_progress_status_card::*, sparkline::*, + stats_view::*, status_card::*, stream_display::*, stream_history_view::*, streams_view::*, user_action_card::*, version_action_card::*, }; diff --git a/frontend/src/app/components/dashboard/playlist_progress_status_card.rs b/frontend/src/app/components/dashboard/playlist_progress_status_card.rs index fdf78bd71..a384c5405 100644 --- a/frontend/src/app/components/dashboard/playlist_progress_status_card.rs +++ b/frontend/src/app/components/dashboard/playlist_progress_status_card.rs @@ -1,11 +1,17 @@ -use crate::{app::components::StatusCard, hooks::use_service_context, i18n::use_translation, model::EventMessage}; +use crate::{ + app::components::StatusCard, + hooks::{use_service_context, use_websocket_status}, + i18n::use_translation, + model::EventMessage, +}; use yew::{component, html, use_effect_with, use_state, Html}; #[component] pub fn PlaylistProgressStatusCard() -> Html { let services = use_service_context(); let translate = use_translation(); - let data = use_state(|| "-".to_owned()); + let data = use_state(|| None::); + let ws_connected = use_websocket_status(); { let services_ctx = services.clone(); @@ -15,21 +21,32 @@ pub fn PlaylistProgressStatusCard() -> Html { let data_clone = data_clone.clone(); let subid = services_ctx.event.subscribe(move |msg| { if let EventMessage::PlaylistUpdateProgress(progress) = msg { - data_clone.set(format!( + data_clone.set(Some(format!( "[{}] {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S"), progress.message - )); + ))); } }); move || services_ctx.event.unsubscribe(subid) }); } + // Distinguish "no updates yet" from "realtime connection lost" + let display_data = (*data).clone().unwrap_or_else(|| { + if *ws_connected { + translate.t("LABEL.IDLE") + } else { + translate.t("LABEL.HEALTH_DISCONNECTED") + } + }); + let footer = if *ws_connected { String::new() } else { translate.t("LABEL.HEALTH_DISCONNECTED") }; + html! { } } diff --git a/frontend/src/app/components/dashboard/stats_view.rs b/frontend/src/app/components/dashboard/stats_view.rs index 78491cf23..5d0d86ee2 100644 --- a/frontend/src/app/components/dashboard/stats_view.rs +++ b/frontend/src/app/components/dashboard/stats_view.rs @@ -1,11 +1,12 @@ use crate::{ app::components::{ - use_metrics_history, Card, CollapsePanel, MetricsHistory, PlaylistProgressStatusCard, Sparkline, + use_metrics_history, Card, CollapsePanel, LogConsole, MetricsHistory, PlaylistProgressStatusCard, Sparkline, SparklineFormat, SparklineSeries, StatusCard, StatusContext, StreamsView, }, i18n::use_translation, + utils::format_uptime, }; -use shared::utils::human_readable_byte_size; +use shared::{model::XtreamCluster, utils::human_readable_byte_size}; use std::rc::Rc; use yew::prelude::*; @@ -27,7 +28,11 @@ pub struct StatsViewProps { #[component] pub fn StatsView(props: &StatsViewProps) -> Html { let translate = use_translation(); - let status_ctx = use_context::().expect("Status context not found"); + // Render a fallback instead of panicking when the provider is missing + let Some(status_ctx) = use_context::() else { + log::error!("StatsView rendered without StatusContext provider"); + return html! {}; + }; let history = use_metrics_history(); let sparkline_data = use_memo(history.clone(), |history| StatsSparklineData { memory: Rc::from([SparklineSeries::new(MetricsHistory::as_vec(&history.memory))]), @@ -44,8 +49,17 @@ pub fn StatsView(props: &StatsViewProps) -> Html { connections: Rc::from([SparklineSeries::new(MetricsHistory::as_vec(&history.connections))]), }); - let (mem, cpu, net, disk) = status_ctx.system_info.as_ref().map_or_else( - || ("n/a".to_string(), "n/a".to_string(), "n/a".to_string(), "n/a".to_string()), + let logs_expanded = use_state(|| false); + let on_logs_toggle = { + let logs_expanded = logs_expanded.clone(); + Callback::from(move |expanded: bool| { + logs_expanded.set(expanded); + }) + }; + + let loading_label = translate.t("LABEL.LOADING"); + let (mem, cpu, net, disk, net_total) = status_ctx.system_info.as_ref().map_or_else( + || (loading_label.clone(), loading_label.clone(), loading_label.clone(), loading_label.clone(), String::new()), |system| { let disk = if system.disk_total_bytes > 0 { format!( @@ -69,9 +83,16 @@ pub fn StatsView(props: &StatsViewProps) -> Html { human_readable_byte_size(system.net_tx_bytes_per_sec as u64), ), disk, + format!( + "\u{2211} \u{2193} {} \u{2191} {}", + human_readable_byte_size(system.net_rx_bytes_total), + human_readable_byte_size(system.net_tx_bytes_total), + ), ) }, ); + let uptime = + status_ctx.status.as_ref().map_or_else(|| loading_label.clone(), |status| format_uptime(status.uptime_secs)); let render_system_stats = |cache| { html! { @@ -83,17 +104,19 @@ pub fn StatsView(props: &StatsViewProps) -> Html { chart={Some(html! { })} /> })} /> + } }; let render_streams_embedded = || { let cache = status_ctx.status.as_ref().map_or_else( - || "n/a".to_string(), + || loading_label.clone(), |status| status.cache.as_ref().map_or_else(|| "n/a".to_string(), |c| c.clone()), ); @@ -122,6 +145,15 @@ pub fn StatsView(props: &StatsViewProps) -> Html { + +

{ translate.t("LABEL.LOGS")}

+ + })}> +
+ +
+
} }; @@ -171,7 +203,7 @@ pub fn StatsView(props: &StatsViewProps) -> Html { }; let (cache, users, connections) = status_ctx.status.as_ref().map_or_else( - || ("n/a".to_string(), "n/a".to_string(), "n/a".to_string()), + || (loading_label.clone(), loading_label.clone(), loading_label.clone()), |status| { ( status.cache.as_ref().map_or_else(|| "n/a".to_string(), |c| c.clone()), @@ -181,6 +213,29 @@ pub fn StatsView(props: &StatsViewProps) -> Html { }, ); + let (stream_count, stream_footer) = status_ctx.status.as_ref().map_or_else( + || (loading_label.clone(), String::new()), + |status| { + let (live, video, series) = status.active_user_streams.iter().fold( + (0_usize, 0_usize, 0_usize), + |(l, v, s), stream| match stream.channel.cluster { + XtreamCluster::Live => (l + 1, v, s), + XtreamCluster::Video => (l, v + 1, s), + XtreamCluster::Series => (l, v, s + 1), + }, + ); + ( + status.active_user_streams.len().to_string(), + format!( + "{} {live} \u{b7} {} {video} \u{b7} {} {series}", + translate.t("LABEL.LIVE"), + translate.t("LABEL.VOD"), + translate.t("LABEL.SERIES"), + ), + ) + }, + ); + html! {
@@ -198,8 +253,19 @@ pub fn StatsView(props: &StatsViewProps) -> Html { })} /> + { render_active_provider_connections() }
+ +

{ translate.t("LABEL.LOGS")}

+
+ })}> +
+ +
+ } diff --git a/frontend/src/app/components/dashboard/stream_display/mod.rs b/frontend/src/app/components/dashboard/stream_display/mod.rs index d66e38a44..2813153bf 100644 --- a/frontend/src/app/components/dashboard/stream_display/mod.rs +++ b/frontend/src/app/components/dashboard/stream_display/mod.rs @@ -15,10 +15,9 @@ use crate::{ components::{menu_item::MenuItem, popup_menu::PopupMenu, NoContent}, ConfigContext, }, - hooks::use_service_context, + hooks::{use_clipboard_copy, use_service_context}, i18n::use_translation, model::EventMessage, - services::DialogService, }; use gloo_timers::callback::Interval; pub use helpers::get_stream_info_config; @@ -32,7 +31,6 @@ use shared::{ }; use std::{collections::HashMap, fmt::Display, rc::Rc, str::FromStr}; use yew::{platform::spawn_local, prelude::*}; -use yew_hooks::use_clipboard; const KICK: &str = "kick"; const COPY_LINK_TULIPROX_VIRTUAL_ID: &str = "copy_link_tuliprox_virtual_id"; @@ -90,8 +88,7 @@ where pub fn StreamDisplay(props: &StreamDisplayProps) -> Html { let translate = use_translation(); let service_ctx = use_service_context(); - let dialog = use_context::().expect("Dialog service not found"); - let clipboard = use_clipboard(); + let copy_to_clipboard = use_clipboard_copy(); let config_ctx = use_context::().expect("Config context not found"); let popup_anchor_ref = use_state(|| None::); let popup_is_open = use_state(|| false); @@ -209,23 +206,6 @@ pub fn StreamDisplay(props: &StreamDisplayProps) -> Html { }) }; - let copy_to_clipboard: Callback = { - let clipboard = clipboard.clone(); - let dialog = dialog.clone(); - Callback::from(move |text: String| { - if *clipboard.is_supported { - clipboard.write_text(text); - } else { - let dlg = dialog.clone(); - spawn_local(async move { - let _ = dlg - .content(html! {}, None, false) - .await; - }); - } - }) - }; - let handle_menu_click = { let popup_is_open_state = popup_is_open.clone(); let translate = translate.clone(); diff --git a/frontend/src/app/components/dashboard/stream_history_view.rs b/frontend/src/app/components/dashboard/stream_history_view.rs index ba66b2708..23044728e 100644 --- a/frontend/src/app/components/dashboard/stream_history_view.rs +++ b/frontend/src/app/components/dashboard/stream_history_view.rs @@ -15,7 +15,7 @@ use shared::{ defaults::default_page_size, model::{ PagedResponseDto, QosSnapshotRecordDto, QosSnapshotWindowDto, SearchRequest, StreamHistoryEventType, - StreamHistoryPageRequestDto, StreamHistoryProviderSummaryDto, StreamHistoryRecordDto, + StreamHistoryPageRequestDto, StreamHistoryProviderSummaryDto, StreamHistoryRecordDto, StreamHistorySearchField, }, }; use std::rc::Rc; @@ -225,28 +225,29 @@ pub fn StreamHistoryView() -> Html { let search_options: Rc> = { let translate = translate.clone(); use_memo(translate, move |translate| { - vec![ - DropDownOption::new("event_ts_utc", html! { translate.t("LABEL.STREAM_HISTORY_TIME") }, false), - DropDownOption::new("event_type", html! { translate.t("LABEL.STREAM_HISTORY_EVENT") }, false), - DropDownOption::new("title", html! { translate.t("LABEL.TITLE") }, false), - DropDownOption::new("group", html! { translate.t("LABEL.GROUP") }, false), - DropDownOption::new("api_username", html! { translate.t("LABEL.USERNAME") }, false), - DropDownOption::new("provider_name", html! { translate.t("LABEL.PROVIDER") }, false), - DropDownOption::new("provider_id", html! { translate.t("LABEL.PROVIDER_ID") }, false), - DropDownOption::new("bytes_sent", html! { translate.t("LABEL.STREAM_HISTORY_BYTES") }, false), - DropDownOption::new( - "first_byte_latency_ms", - html! { translate.t("LABEL.STREAM_HISTORY_FIRST_BYTE") }, - false, - ), - DropDownOption::new("user_agent", html! { translate.t("LABEL.USER_AGENT") }, false), - DropDownOption::new("item_type", html! { translate.t("LABEL.TYPE") }, false), - DropDownOption::new("container", html! { translate.t("LABEL.CONTAINER") }, false), - DropDownOption::new("disconnect_reason", html! { translate.t("LABEL.STREAM_HISTORY_REASON") }, false), - DropDownOption::new("source_addr", html! { translate.t("LABEL.STREAM_HISTORY_IP") }, false), - DropDownOption::new("country", html! { translate.t("LABEL.COUNTRY") }, false), - DropDownOption::new("cluster", html! { translate.t("LABEL.CLUSTER") }, false), - ] + // Ids come from the shared enum so backend parsing can never drift. + let label_key = |field: StreamHistorySearchField| match field { + StreamHistorySearchField::EventTsUtc => "LABEL.STREAM_HISTORY_TIME", + StreamHistorySearchField::EventType => "LABEL.STREAM_HISTORY_EVENT", + StreamHistorySearchField::Title => "LABEL.TITLE", + StreamHistorySearchField::Group => "LABEL.GROUP", + StreamHistorySearchField::ApiUsername => "LABEL.USERNAME", + StreamHistorySearchField::ProviderName => "LABEL.PROVIDER", + StreamHistorySearchField::ProviderId => "LABEL.PROVIDER_ID", + StreamHistorySearchField::BytesSent => "LABEL.STREAM_HISTORY_BYTES", + StreamHistorySearchField::FirstByteLatencyMs => "LABEL.STREAM_HISTORY_FIRST_BYTE", + StreamHistorySearchField::UserAgent => "LABEL.USER_AGENT", + StreamHistorySearchField::ItemType => "LABEL.TYPE", + StreamHistorySearchField::Container => "LABEL.CONTAINER", + StreamHistorySearchField::DisconnectReason => "LABEL.STREAM_HISTORY_REASON", + StreamHistorySearchField::SourceAddr => "LABEL.STREAM_HISTORY_IP", + StreamHistorySearchField::Country => "LABEL.COUNTRY", + StreamHistorySearchField::Cluster => "LABEL.CLUSTER", + }; + use strum::IntoEnumIterator; + StreamHistorySearchField::iter() + .map(|field| DropDownOption::new(field.as_ref(), html! { translate.t(label_key(field)) }, false)) + .collect::>() }) }; diff --git a/frontend/src/app/components/dashboard/streams_view.rs b/frontend/src/app/components/dashboard/streams_view.rs index 6bd920799..39b1270a8 100644 --- a/frontend/src/app/components/dashboard/streams_view.rs +++ b/frontend/src/app/components/dashboard/streams_view.rs @@ -67,8 +67,8 @@ pub fn StreamsView(props: &StreamsViewProps) -> Html { })}
- - + + Html {
- - + +
diff --git a/frontend/src/app/components/datetime_input.rs b/frontend/src/app/components/datetime_input.rs new file mode 100644 index 000000000..052cdc460 --- /dev/null +++ b/frontend/src/app/components/datetime_input.rs @@ -0,0 +1,189 @@ +use crate::app::components::FieldLabel; +use chrono::TimeZone; +use web_sys::HtmlInputElement; +use yew::{classes, component, html, use_effect_with, Callback, Html, NodeRef, Properties, TargetCast}; + +pub(crate) fn format_datetime_input_value(value: Option) -> String { + value + .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)) + .map_or_else(String::new, |dt| dt.with_timezone(&chrono::Local).format("%Y-%m-%dT%H:%M").to_string()) +} + +pub(crate) fn parse_datetime_input_change(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M") + .ok() + .and_then(|naive| chrono::Local.from_local_datetime(&naive).latest()) + .map(|local| local.timestamp()) +} + +#[derive(Properties, Clone, PartialEq, Debug)] +pub struct DateTimeInputProps { + #[prop_or_default] + pub name: String, + #[prop_or_default] + pub field_id: Option, + #[prop_or_default] + pub label: Option, + #[prop_or_default] + pub input_ref: Option, + #[prop_or_default] + pub value: Option, // Unix Timestamp + #[prop_or_default] + pub on_change: Option>>, // None or Some(timestamp) +} + +#[derive(Properties, Clone, PartialEq, Debug)] +pub(crate) struct DateTimeInputBaseProps { + #[prop_or_default] + pub name: String, + #[prop_or_default] + pub field_id: Option, + #[prop_or_default] + pub label: Option, + #[prop_or_default] + pub input_ref: Option, + #[prop_or_default] + pub value: Option, + #[prop_or_default] + pub on_change: Option>>, + #[prop_or_default] + pub tools: Html, + #[prop_or_default] + pub extra_class: Option<&'static str>, +} + +#[component] +pub(crate) fn DateTimeInputBase(props: &DateTimeInputBaseProps) -> Html { + let local_ref = props.input_ref.clone().unwrap_or_default(); + + { + let local_ref = local_ref.clone(); + let value = props.value; + use_effect_with(value, move |val| { + if let Some(input) = local_ref.cast::() { + input.set_value(&format_datetime_input_value(*val)); + } + || () + }); + } + + let handle_change = { + let onchange_cb = props.on_change.clone(); + let current_value = props.value; + Callback::from(move |event: yew::events::Event| { + if let Some(input) = event.target_dyn_into::() { + match parse_datetime_input_change(&input.value()) { + Some(ts) => { + if let Some(cb) = onchange_cb.as_ref() { + cb.emit(Some(ts)); + } + } + None => { + // Re-display the last valid value so the user sees + // the input reset on invalid or empty entries. + if trimmed_is_empty(&input.value()) { + if let Some(cb) = onchange_cb.as_ref() { + cb.emit(None); + } + } else { + input.set_value(&format_datetime_input_value(current_value)); + } + } + } + } + }) + }; + + html! { +
+ { if let Some(label) = &props.label { + html! { + + } + } else { html!{} } } +
+ + {props.tools.clone()} +
+
+ } +} + +fn trimmed_is_empty(value: &str) -> bool { value.trim().is_empty() } + +#[component] +pub fn DateTimeInput(props: &DateTimeInputProps) -> Html { + html! { + + } +} + +#[cfg(test)] +mod tests { + use super::{format_datetime_input_value, parse_datetime_input_change}; + use chrono::TimeZone; + + #[test] + fn format_datetime_input_value_returns_empty_for_none() { + assert_eq!(format_datetime_input_value(None), String::new()); + } + + #[test] + fn format_datetime_input_value_formats_utc_timestamp_in_local_time() { + // Pick a known local datetime, derive the UTC timestamp that + // corresponds to it, and assert the formatter renders the same + // local wall-clock value. Stable across any timezone the test + // happens to run in. + let local_naive = chrono::NaiveDate::from_ymd_opt(2026, 8, 6).unwrap().and_hms_opt(14, 30, 0).unwrap(); + let local = chrono::Local.from_local_datetime(&local_naive).single().unwrap(); + let utc = local.timestamp(); + let expected = local.format("%Y-%m-%dT%H:%M").to_string(); + assert_eq!(format_datetime_input_value(Some(utc)), expected); + } + + #[test] + fn parse_datetime_input_change_round_trips_known_local_datetime() { + let local_naive = chrono::NaiveDate::from_ymd_opt(2026, 8, 6).unwrap().and_hms_opt(14, 30, 0).unwrap(); + let local = chrono::Local.from_local_datetime(&local_naive).single().unwrap(); + let formatted = local.format("%Y-%m-%dT%H:%M").to_string(); + let parsed = parse_datetime_input_change(&formatted).expect("parses"); + assert_eq!(parsed, local.timestamp()); + } + + #[test] + fn parse_datetime_input_change_returns_none_for_empty() { + assert!(parse_datetime_input_change("").is_none()); + assert!(parse_datetime_input_change(" ").is_none()); + } + + #[test] + fn parse_datetime_input_change_returns_none_for_partial_or_invalid() { + assert!(parse_datetime_input_change("2026-08-06T25:00").is_none()); // invalid hour + assert!(parse_datetime_input_change("01.08.2026 14:30").is_none()); // wrong format + } +} diff --git a/frontend/src/app/components/downloads.rs b/frontend/src/app/components/downloads.rs index 20a6ec330..abbdcd5f0 100644 --- a/frontend/src/app/components/downloads.rs +++ b/frontend/src/app/components/downloads.rs @@ -1,5 +1,5 @@ use crate::{ - app::components::{IconButton, Table, TableDefinition, TextButton}, + app::components::{IconButton, LoadingIndicator, Table, TableDefinition, TaskStatusBadge, TextButton}, hooks::use_service_context, i18n::use_translation, model::{DialogResult, EventMessage}, @@ -100,20 +100,6 @@ fn format_download_kind(translate: &crate::i18n::YewI18n, kind: &TaskKindDto) -> } } -fn format_download_state(translate: &crate::i18n::YewI18n, state: &TransferStatusDto) -> String { - match state { - TransferStatusDto::Queued => translate.t("LABEL.DOWNLOAD_STATE_QUEUED"), - TransferStatusDto::Scheduled => translate.t("LABEL.DOWNLOAD_STATE_SCHEDULED"), - TransferStatusDto::Running => translate.t("LABEL.DOWNLOAD_STATE_DOWNLOADING"), - TransferStatusDto::Paused => translate.t("LABEL.DOWNLOAD_STATE_PAUSED"), - TransferStatusDto::Completed => translate.t("LABEL.DOWNLOAD_STATE_COMPLETED"), - TransferStatusDto::Failed => translate.t("LABEL.DOWNLOAD_STATE_FAILED"), - TransferStatusDto::Cancelled => translate.t("LABEL.DOWNLOAD_CANCEL"), - TransferStatusDto::WaitingForCapacity => translate.t("LABEL.DOWNLOAD_STATE_WAITING_FOR_CAPACITY"), - TransferStatusDto::RetryWaiting => translate.t("LABEL.DOWNLOAD_STATE_RETRY_WAITING"), - } -} - fn format_download_progress(download: &FileDownloadDto) -> String { if let Some(total) = download.total_bytes { if total > 0 { @@ -124,6 +110,24 @@ fn format_download_progress(download: &FileDownloadDto) -> String { format_bytes(download.downloaded_bytes) } +fn render_download_progress(download: &FileDownloadDto) -> Html { + let text = format_download_progress(download); + let bar = download.total_bytes.filter(|total| *total > 0).map(|total| { + html! { + + } + }); + html! { + + { bar } + { text } + + } +} + fn format_download_start(download: &FileDownloadDto) -> String { download.scheduled_start_at.and_then(unix_ts_to_str).unwrap_or_default() } @@ -293,6 +297,8 @@ pub fn downloads_view() -> Html { let active_download = use_state(|| Rc::new(Vec::>::new())); let table_items = use_state(|| None::>>>); let sort_state = use_state(|| None::<(usize, SortOrder)>); + // Distinguishes "still waiting for the first snapshot" from "queue is empty" + let initial_loaded = use_state(|| false); let request_downloads = { let services = services.clone(); @@ -307,13 +313,16 @@ pub fn downloads_view() -> Html { let active_download = active_download.clone(); let services = services.clone(); let request_downloads_effect = request_downloads.clone(); + let initial_loaded = initial_loaded.clone(); use_effect_with((), move |_| { request_downloads_effect.emit(()); let sub_id = services.event.subscribe(move |msg| match msg { crate::model::EventMessage::DownloadsUpdate(snapshot) => { + initial_loaded.set(true); apply_download_snapshot(&snapshot, &queue_state, &finished_state, &active_download); } crate::model::EventMessage::DownloadsDeltaUpdate(delta) => { + initial_loaded.set(true); apply_download_delta(&delta, &queue_state, &finished_state, &active_download); } crate::model::EventMessage::WebSocketStatus(true) => { @@ -537,8 +546,10 @@ pub fn downloads_view() -> Html { } 1 => html! { {dto.title.clone()} }, 2 => html! { format_download_kind(&translate, &dto.kind) }, - 3 => html! { format_download_state(&translate, &dto.status) }, - 4 => html! { {format_download_progress(&dto)} }, + 3 => { + html! { } + } + 4 => render_download_progress(&dto), 5 => { html! { {dto.total_bytes.map_or_else(String::new, format_bytes)} } } @@ -601,7 +612,11 @@ pub fn downloads_view() -> Html {
- definition={table_definition} /> + if *initial_loaded { + definition={table_definition} /> + } else { + + }
@@ -632,6 +647,7 @@ mod tests { scheduled_start_at: None, duration_secs: None, error: None, + recording: None, } } @@ -815,6 +831,7 @@ mod tests { scheduled_start_at: None, duration_secs: Some(60), error: Some("Cancelled by user".to_string()), + recording: None, }; let can_retry = dto.kind == TaskKindDto::Download @@ -838,6 +855,7 @@ mod tests { scheduled_start_at: None, duration_secs: Some(60), error: Some("Cancelled by user".to_string()), + recording: None, }; let actions = download_action_availability(true, &dto); diff --git a/frontend/src/app/components/drop_down_icon_button.rs b/frontend/src/app/components/drop_down_icon_button.rs index 1c42dba5b..c84eb4184 100644 --- a/frontend/src/app/components/drop_down_icon_button.rs +++ b/frontend/src/app/components/drop_down_icon_button.rs @@ -37,6 +37,14 @@ pub struct DropDownIconButtonProps { pub multi_select: bool, #[prop_or_default] pub button_ref: Option, + #[prop_or_default] + pub aria_label: Option, + #[prop_or_default] + pub aria_required: Option, + #[prop_or_default] + pub aria_invalid: Option, + #[prop_or_default] + pub aria_describedby: Option, } #[component] @@ -80,18 +88,20 @@ pub fn DropDownIconButton(props: &DropDownIconButtonProps) -> Html { let onselect = props.on_select.clone(); let set_is_open = popup_is_open.clone(); Callback::from(move |(id, _event): (String, MouseEvent)| { - if selections.current().contains(&id) { - selections.remove(&id); - } else { - selections.insert(id.clone()); - } let selected_options = if multi_select { + if selections.current().contains(&id) { + selections.remove(&id); + } else { + selections.insert(id.clone()); + } if selections.current().is_empty() { DropDownSelection::Empty } else { DropDownSelection::Multi(selections.current().iter().map(Clone::clone).collect::>()) } } else { + // Single-select replaces the previous selection instead of toggling + selections.set(HashSet::from([id.clone()])); DropDownSelection::Single(id.clone()) }; onselect.emit((name.clone(), selected_options)); @@ -103,8 +113,15 @@ pub fn DropDownIconButton(props: &DropDownIconButtonProps) -> Html { html! { <> - - + + { for props.options.iter().map(|o| { let checkbox_id = o.id.clone(); @@ -114,7 +131,7 @@ pub fn DropDownIconButton(props: &DropDownIconButtonProps) -> Html { move |event| checkbox_handler.emit((id.clone(), event)) }); html! { -
+
{ html_if!( props.multi_select, diff --git a/frontend/src/app/components/field_wrapper.rs b/frontend/src/app/components/field_wrapper.rs index 4a0978b9b..1b7a4fb2c 100644 --- a/frontend/src/app/components/field_wrapper.rs +++ b/frontend/src/app/components/field_wrapper.rs @@ -12,6 +12,10 @@ pub struct FieldWrapperProps { pub class: Classes, #[prop_or(true)] pub link_label: bool, + #[prop_or_default] + pub required: bool, + #[prop_or_default] + pub error: Option, pub children: Children, } @@ -20,7 +24,11 @@ pub fn FieldWrapper(props: &FieldWrapperProps) -> Html { let for_id = props.link_label.then(|| props.field_id.clone()); html! { -
+
{ props.label.as_ref().map_or_else(Html::default, |label| html! { Html {
{ for props.children.iter() }
+ { props.error.as_ref().map_or_else(Html::default, |error| html! { + { error.clone() } + }) }
} } diff --git a/frontend/src/app/components/home.rs b/frontend/src/app/components/home.rs index da71c5848..d00f63c80 100644 --- a/frontend/src/app/components/home.rs +++ b/frontend/src/app/components/home.rs @@ -1,10 +1,14 @@ use crate::{ app::{ components::{ - config::ConfigView, loading_indicator::BusyIndicator, map_sources_to_playlist_rows, theme::Theme, AppIcon, - DashboardView, DownloadsView, EpgView, ErrorBoundary, HealthBanner, IconButton, LanguagePicker, NoAccess, - Panel, ParticleFlowBackground, PlaylistExplorerView, PlaylistSettingsView, PlaylistUpdateView, RbacView, - Setup, Sidebar, SourceEditor, StatsView, StreamHistoryView, StreamsView, ThemePicker, ToastrView, + config::{ConfigView, PlansView}, + loading_indicator::BusyIndicator, + map_sources_to_playlist_rows, + recording::{RecordingLibraryView, RecordingRulesView}, + theme::Theme, + AppIcon, DashboardView, DownloadsView, EpgView, ErrorBoundary, HealthBanner, IconButton, LanguagePicker, + NoAccess, Panel, ParticleFlowBackground, PlaylistExplorerView, PlaylistSettingsView, PlaylistUpdateView, + RbacView, Setup, Sidebar, SourceEditor, StatsView, StreamHistoryView, StreamsView, ThemePicker, ToastrView, UserlistView, WebsocketStatus, }, context::{ConfigContext, PlaylistContext, StatusContext}, @@ -48,6 +52,7 @@ struct HomeViewAccess { can_read_playlist: bool, can_read_epg: bool, can_read_downloads: bool, + can_read_recordings: bool, is_admin: bool, } @@ -82,12 +87,16 @@ fn is_allowed_home_view(view: ViewType, access: HomeViewAccess) -> bool { ViewType::Streams => access.show_streams_page && access.can_read_system_status, ViewType::Downloads => access.can_read_downloads, ViewType::Users => access.can_read_users, + ViewType::Plans => access.can_read_config, ViewType::Config => access.can_read_config, ViewType::SourceEditor => access.can_read_sources, ViewType::PlaylistUpdate => access.can_write_playlist, ViewType::PlaylistSettings | ViewType::PlaylistExplorer => access.can_read_playlist, ViewType::PlaylistEpg => access.can_read_epg, ViewType::Rbac => access.is_admin, + ViewType::RecordingLibrary | ViewType::RecordingRules | ViewType::RecordingRuleForm => { + access.can_read_recordings + } } } @@ -100,12 +109,15 @@ fn first_allowed_home_view(access: HomeViewAccess) -> ViewType { ViewType::Downloads, ViewType::Config, ViewType::Users, + ViewType::Plans, ViewType::SourceEditor, ViewType::PlaylistUpdate, ViewType::PlaylistSettings, ViewType::PlaylistExplorer, ViewType::PlaylistEpg, ViewType::Rbac, + ViewType::RecordingLibrary, + ViewType::RecordingRules, ] .into_iter() .map(|view| normalize_requested_home_view(view, access)) @@ -236,6 +248,7 @@ pub fn Home() -> Html { let can_read_playlist = services.auth.has_permission(Permission::PlaylistRead); let can_read_epg = services.auth.has_permission(Permission::EpgRead); let can_read_downloads = services.auth.has_permission(Permission::DownloadRead); + let can_read_recordings = services.auth.has_permission(Permission::RecordingRead); let is_admin = services.auth.is_admin(); let _ = use_server_status(status.clone(), system_info.clone(), !setup_mode && can_read_system_status); @@ -301,6 +314,7 @@ pub fn Home() -> Html { can_read_playlist, can_read_epg, can_read_downloads, + can_read_recordings, is_admin, }; let configured_landing_page = config_context.config.as_ref().map(|app_cfg| { @@ -582,6 +596,13 @@ pub fn Home() -> Html { })} + { html_if!(can_read_config, { + + + + + + })} { html_if!(can_read_sources, { @@ -618,12 +639,26 @@ pub fn Home() -> Html { })} { html_if!(is_admin, { - + })} + { html_if!(can_read_recordings, { + + + + + + })} + { html_if!(can_read_recordings, { + + + + + + })} } } @@ -655,6 +690,7 @@ mod tests { can_read_playlist: true, can_read_epg: true, can_read_downloads: true, + can_read_recordings: true, is_admin: true, } } diff --git a/frontend/src/app/components/icon_button.rs b/frontend/src/app/components/icon_button.rs index 329252657..2d584d444 100644 --- a/frontend/src/app/components/icon_button.rs +++ b/frontend/src/app/components/icon_button.rs @@ -13,10 +13,21 @@ pub struct IconButtonProps { pub hint: String, #[prop_or_default] pub button_ref: Option, + /// ARIA role override; `aria_required`/`aria_invalid` are only valid with e.g. `combobox`. + #[prop_or_default] + pub role: Option, + #[prop_or_default] + pub aria_haspopup: Option, #[prop_or_default] pub aria_expanded: Option, #[prop_or_default] pub aria_label: Option, + #[prop_or_default] + pub aria_required: Option, + #[prop_or_default] + pub aria_invalid: Option, + #[prop_or_default] + pub aria_describedby: Option, } #[component] @@ -31,8 +42,13 @@ pub fn IconButton(props: &IconButtonProps) -> Html { html! { } diff --git a/frontend/src/app/components/input.rs b/frontend/src/app/components/input.rs index da588c3a2..f40c2b77c 100644 --- a/frontend/src/app/components/input.rs +++ b/frontend/src/app/components/input.rs @@ -33,6 +33,10 @@ pub struct InputProps { pub hint_key: Option, #[prop_or_default] pub aria_label: Option, + #[prop_or_default] + pub required: bool, + #[prop_or_default] + pub error: Option, } #[component] @@ -77,7 +81,8 @@ pub fn Input(props: &InputProps) -> Html { if props.label.is_some() { None } else { props.aria_label.clone().or_else(|| props.placeholder.clone()) }; html! { - + { html_if!(props.icon.is_some(), { })} @@ -91,6 +96,8 @@ pub fn Input(props: &InputProps) -> Html { oninput={handle_oninput} placeholder={props.placeholder.clone()} aria-label={aria_label} + aria-required={props.required.then(|| "true".to_string())} + aria-invalid={props.error.as_ref().map(|_| "true".to_string())} /> { html_if!(props.hidden, { diff --git a/frontend/src/app/components/key_value_editor.rs b/frontend/src/app/components/key_value_editor.rs index df05d2042..1ea636632 100644 --- a/frontend/src/app/components/key_value_editor.rs +++ b/frontend/src/app/components/key_value_editor.rs @@ -1,4 +1,7 @@ -use crate::app::components::{chip::Chip, IconButton}; +use crate::{ + app::components::{chip::Chip, IconButton}, + i18n::use_translation, +}; use std::{collections::HashMap, rc::Rc}; use web_sys::HtmlInputElement; use yew::prelude::*; @@ -18,15 +21,20 @@ pub struct KeyValueEditorProps { pub on_change: Callback>, #[prop_or(true)] pub readonly: bool, - #[prop_or_else(|| "Add key".to_string())] + #[prop_or_default] pub key_placeholder: String, - #[prop_or_else(|| "Add value".to_string())] + #[prop_or_default] pub value_placeholder: String, } #[component] pub fn KeyValueEditor(props: &KeyValueEditorProps) -> Html { let KeyValueEditorProps { label, entries, on_change, readonly, key_placeholder, value_placeholder } = props.clone(); + let translate = use_translation(); + // Empty placeholder props fall back to localized defaults + let key_placeholder = if key_placeholder.is_empty() { translate.t("LABEL.ADD_KEY") } else { key_placeholder }; + let value_placeholder = + if value_placeholder.is_empty() { translate.t("LABEL.ADD_VALUE") } else { value_placeholder }; // local state for editing let entry_state = use_state(|| { diff --git a/frontend/src/app/components/login.rs b/frontend/src/app/components/login.rs index efa4cc059..b9762fb1a 100644 --- a/frontend/src/app/components/login.rs +++ b/frontend/src/app/components/login.rs @@ -35,10 +35,8 @@ pub fn Login() -> Html { let u_ref = username_ref.clone(); let p_ref = password_ref.clone(); use_async(async move { - let username_input: HtmlInputElement = u_ref.cast::().unwrap(); - let password_input: HtmlInputElement = p_ref.cast::().unwrap(); - let username = username_input.value(); - let password = password_input.value(); + let username = u_ref.cast::().map(|input| input.value()).unwrap_or_default(); + let password = p_ref.cast::().map(|input| input.value()).unwrap_or_default(); let result = services_ctx.auth.authenticate(username, password).await; match &result { Ok(_token) => authorized_state.set(true), @@ -53,7 +51,9 @@ pub fn Login() -> Html { let handle_login = { let authenticator = authenticate.clone(); Callback::from(move |_: String| { - authenticator.run(); + if !authenticator.loading { + authenticator.run(); + } }) }; @@ -63,7 +63,9 @@ pub fn Login() -> Html { if e.key() == "Enter" { e.prevent_default(); e.stop_propagation(); - authenticator.run(); + if !authenticator.loading { + authenticator.run(); + } } }) }; @@ -83,7 +85,7 @@ pub fn Login() -> Html { let input_ref = username_ref.clone(); use_effect(move || { if let Some(input) = input_ref.cast::() { - input.focus().unwrap(); + let _ = input.focus(); } }); } @@ -103,11 +105,11 @@ pub fn Login() -> Html {
diff --git a/frontend/src/app/components/mod.rs b/frontend/src/app/components/mod.rs index 84c7083dc..dac934004 100644 --- a/frontend/src/app/components/mod.rs +++ b/frontend/src/app/components/mod.rs @@ -16,6 +16,7 @@ mod custom_dialog; mod dashboard; mod date_input; mod date_input_action; +mod datetime_input; mod downloads; mod drop_down_icon_button; mod error_boundary; @@ -48,6 +49,7 @@ mod svg_icon; mod table; mod tabset; mod tag_list; +mod task_status_badge; mod text_button; mod textarea; mod theme; @@ -64,6 +66,7 @@ mod field_id; mod field_wrapper; mod filter; mod particle_flow_background; +mod recording; mod setup; mod source_editor; mod title_card; @@ -76,12 +79,12 @@ mod title_card; pub(crate) use self::{ accordion::*, accordion_panel::*, authentication::*, breadcrumbs::*, card::*, cell_value::*, chip::*, cluster_flags_input::*, collapse_panel::*, country::*, csv_table::*, custom_dialog::*, dashboard::*, date_input::*, - date_input_action::*, downloads::DownloadsView, drop_down_icon_button::*, error_boundary::*, field_explanation::*, - field_id::*, field_wrapper::*, filter::*, health_banner::*, hide_content::*, home::*, icon_button::*, - key_value_editor::*, language_picker::*, loading_screen::*, login::*, no_access::*, no_content::*, panel::*, - particle_flow_background::*, playlist::*, radio_button_group::*, rbac::*, reveal_content::*, role_based_content::*, - search::*, select::*, select_helpers::*, setup::*, sidebar::*, source_editor::*, svg_icon::*, table::*, tabset::*, - tag_list::*, text_button::*, textarea::*, theme_picker::*, title_card::*, toastr::*, toggle_switch::*, userlist::*, - websocket_status::*, + date_input_action::*, datetime_input::*, downloads::DownloadsView, drop_down_icon_button::*, error_boundary::*, + field_explanation::*, field_id::*, field_wrapper::*, filter::*, health_banner::*, hide_content::*, home::*, + icon_button::*, key_value_editor::*, language_picker::*, loading_indicator::*, loading_screen::*, login::*, + no_access::*, no_content::*, panel::*, particle_flow_background::*, playlist::*, radio_button_group::*, rbac::*, + reveal_content::*, role_based_content::*, search::*, select::*, select_helpers::*, setup::*, sidebar::*, + source_editor::*, svg_icon::*, table::*, tabset::*, tag_list::*, task_status_badge::*, text_button::*, textarea::*, + theme_picker::*, title_card::*, toastr::*, toggle_switch::*, userlist::*, websocket_status::*, }; pub use self::{confirm_dialog::*, content_dialog::*}; diff --git a/frontend/src/app/components/playlist/epg_view.rs b/frontend/src/app/components/playlist/epg_view.rs index 270386f2f..af57b3389 100644 --- a/frontend/src/app/components/playlist/epg_view.rs +++ b/frontend/src/app/components/playlist/epg_view.rs @@ -1,22 +1,31 @@ use crate::{ - app::components::{EpgSourceSelector, NoContent, Search}, + app::{ + components::{ + recording::{ + epg_programme_to_prefill, target_name_for_id, EpgProgrammePrefillInput, PaddingBounds, RecordingForm, + }, + EpgSourceSelector, IconButton, NoContent, Search, + }, + context::ConfigContext, + }, hooks::use_service_context, i18n::use_translation, - model::{BusyStatus, EventMessage}, + model::{BusyStatus, DialogAction, DialogActions, DialogResult, EventMessage}, + services::{CreateRecordingTaskRequest, DialogService, RecordingService, RecordingSourceInput}, utils::set_timeout, }; use chrono::{Datelike, Local, TimeZone, Utc}; use gloo_timers::callback::{Interval, Timeout}; use shared::{ concat_string, - model::{EpgTv, PlaylistEpgRequest, SearchRequest}, + model::{EpgTv, Permission, PlaylistEpgRequest, SearchRequest, XtreamCluster}, }; use std::{cell::RefCell, rc::Rc}; use wasm_bindgen::{prelude::Closure, JsCast}; use web_sys::{window, HtmlElement, MouseEvent, TouchEvent, WheelEvent}; use yew::{ - classes, component, html, platform::spawn_local, use_effect_with, use_memo, use_mut_ref, use_node_ref, use_state, - Callback, Html, + classes, component, html, platform::spawn_local, use_context, use_effect_with, use_memo, use_mut_ref, use_node_ref, + use_state, Callback, Html, }; const TIME_BLOCK_MINS: i64 = 30; @@ -165,6 +174,15 @@ struct TimelinePanState { scroll_left: i32, } +#[derive(Clone)] +struct PendingProgram { + channel_id: String, + channel_name: Option, + title: String, + start: i64, + stop: i64, +} + fn update_now_line( container_ref: &yew::NodeRef, now_line_ref: &yew::NodeRef, @@ -196,6 +214,8 @@ fn update_now_line( #[component] pub fn EpgView() -> Html { let services = use_service_context(); + let config_ctx = use_context::().expect("ConfigContext not found"); + let dialog = use_context::().expect("Dialog service not found"); let translate = use_translation(); let epg = use_state::, _>(|| None); let container_ref = use_node_ref(); @@ -214,10 +234,28 @@ pub fn EpgView() -> Html { let timeline_pan_state = use_mut_ref(|| None::); let is_program_panning = use_state(|| false); let is_timeline_panning = use_state(|| false); + let selected_epg_source = use_state(|| None::); + let can_write_recordings = services.auth.has_permission(Permission::RecordingWrite); + let is_admin_role = services.auth.is_admin(); + let recording_padding = { + let rec = config_ctx + .config + .as_ref() + .and_then(|cfg| cfg.config.video.as_ref()) + .and_then(|video| video.download.as_ref()) + .and_then(|video| video.recording.as_ref()); + Rc::new(PaddingBounds { + default_pre_roll_secs: rec.and_then(|c| c.default_pre_roll_secs).unwrap_or(0), + max_pre_roll_secs: rec.map(|c| c.max_pre_roll_secs).unwrap_or(900), + default_post_roll_secs: rec.and_then(|c| c.default_post_roll_secs).unwrap_or(0), + max_post_roll_secs: rec.map(|c| c.max_post_roll_secs).unwrap_or(1800), + }) + }; // State to keep track of visible channel range let visible_range = use_state(|| (0, 20)); // (start_index, end_index) let search_filter = use_state::(|| SearchRequest::Clear); + let is_hosted_epg = matches!(*selected_epg_source, Some(PlaylistEpgRequest::Target(_))); let handle_search = { let search_filter = search_filter.clone(); @@ -244,7 +282,9 @@ pub fn EpgView() -> Html { let raf_id = raf_id.clone(); let raf_closure = raf_closure.clone(); let epg_request_token = epg_request_token.clone(); + let selected_epg_source = selected_epg_source.clone(); Callback::from(move |req: PlaylistEpgRequest| { + selected_epg_source.set(Some(req.clone())); epg_set.set(None); search_filter.set(SearchRequest::Clear); visible_range.set((0, 20)); @@ -448,6 +488,8 @@ pub fn EpgView() -> Html { let apply_timeline_zoom = apply_timeline_zoom.clone(); let pixels_per_min = pixels_per_min.clone(); Callback::from(move |e: WheelEvent| { + // The time header is the only place wheel zooms; elsewhere we let + // the container scroll so the EPG can stream-load more rows. e.prevent_default(); e.stop_propagation(); if let Some(timeline) = timeline_ref.cast::() { @@ -761,11 +803,13 @@ pub fn EpgView() -> Html { }; let row_height = use_memo((), move |_| { - let doc = window().unwrap().document().unwrap(); - let root = doc.document_element().unwrap(); // - let style = window().unwrap().get_computed_style(&root).unwrap().unwrap(); - - let row_height = style.get_property_value("--epg-row-height").unwrap_or_else(|_| String::new()); // fallback if not set + let row_height = window() + .and_then(|win| { + let root = win.document()?.document_element()?; + win.get_computed_style(&root).ok().flatten() + }) + .and_then(|style| style.get_property_value("--epg-row-height").ok()) + .unwrap_or_default(); row_height.trim_end_matches("px").parse::().unwrap_or(60).max(1) }); @@ -778,6 +822,7 @@ pub fn EpgView() -> Html { use_effect_with((), move |_| { let debounce_handle: Rc>> = Rc::new(RefCell::new(None)); let onscroll_handle: OnScrollHandle = Rc::new(RefCell::new(None)); + let cleanup_container_ref = container_ref.clone(); if let Some(div) = container_ref.cast::() { let visible_range = visible_range.clone(); // Store debounce timer in Rc @@ -807,7 +852,9 @@ pub fn EpgView() -> Html { *debounce_handle_clone.borrow_mut() = Some(handle); }) as Box); - div.add_event_listener_with_callback("scroll", onscroll.as_ref().unchecked_ref()).unwrap(); + if let Err(err) = div.add_event_listener_with_callback("scroll", onscroll.as_ref().unchecked_ref()) { + log::error!("Failed to register EPG scroll listener: {err:?}"); + } *onscroll_handle_clone.borrow_mut() = Some(onscroll); } move || { @@ -815,12 +862,105 @@ pub fn EpgView() -> Html { prev.cancel(); } if let Some(onscroll) = onscroll_handle.borrow_mut().take() { + // Detach before dropping the closure so a live element cannot invoke a destroyed callback + if let Some(div) = cleanup_container_ref.cast::() { + let _ = div.remove_event_listener_with_callback("scroll", onscroll.as_ref().unchecked_ref()); + } drop(onscroll); } } }); } + let handle_record_click = { + let selected_epg_source = selected_epg_source.clone(); + let recording_padding = recording_padding.clone(); + let dialog = dialog.clone(); + let services = services.clone(); + let translate = translate.clone(); + let config = config_ctx.config.clone(); + Callback::from(move |(program, _event): (PendingProgram, MouseEvent)| { + let Some(PlaylistEpgRequest::Target(target_id)) = (*selected_epg_source).clone() else { + services.toastr.error(translate.t("MESSAGES.RECORDING.NO_TARGET")); + return; + }; + let Some(target_name) = + config.as_ref().and_then(|app_config| target_name_for_id(&app_config.sources, target_id, None)) + else { + services.toastr.error(translate.t("MESSAGES.RECORDING.NO_TARGET")); + return; + }; + let dialog = dialog.clone(); + let services = services.clone(); + let translate = translate.clone(); + let padding: PaddingBounds = (*recording_padding).clone(); + spawn_local(async move { + let source = RecordingSourceInput { + target_id: target_name, + virtual_id: program.channel_id.clone(), + cluster: XtreamCluster::Live, + input_name: String::new(), + }; + let mut prefill = epg_programme_to_prefill(EpgProgrammePrefillInput { + source, + channel_id: Some(program.channel_id.clone()), + channel_name: program.channel_name.clone(), + programme_title: program.title, + programme_start: program.start, + programme_end: program.stop, + padding: padding.clone(), + episode: None, + }); + if let Some(name) = program.channel_name.clone() { + prefill = prefill.with_channel_name(name); + } + let request_slot: Rc>> = Rc::new(RefCell::new(None)); + let on_submit = { + let request_slot = Rc::clone(&request_slot); + Callback::from(move |request: CreateRecordingTaskRequest| { + *request_slot.borrow_mut() = Some(request); + }) + }; + let body = html! { + + }; + let actions = DialogActions { + left: Some(vec![DialogAction::new( + "cancel", + "LABEL.CANCEL", + DialogResult::Cancel, + Some("Close".to_owned()), + None, + )]), + right: vec![DialogAction::new_focused( + "record", + "LABEL.RECORD", + DialogResult::Ok, + Some("Record".to_owned()), + Some("primary".to_string()), + )], + }; + if dialog.content(body, Some(actions), false).await != DialogResult::Ok { + return; + } + let Some(request) = request_slot.borrow_mut().take() else { + services.toastr.error(translate.t("MESSAGES.RECORDING.NO_REQUEST")); + return; + }; + match RecordingService::new().create_task(request).await { + Ok(_) => services.toastr.success(translate.t("MESSAGES.RECORDING.QUEUED")), + Err(err) => services.toastr.error(err.to_string()), + } + }); + }) + }; + html! {
@@ -918,6 +1058,7 @@ pub fn EpgView() -> Html {
{ for ch.programmes.iter().map(|p| { let is_active = now >= p.start && now < p.stop; + let is_past = now >= p.stop; let left = get_pos(p.start, *start_window, pixels_per_min.value()); let right = get_pos(p.stop, *start_window, pixels_per_min.value()); let width = (right - left).max(0); @@ -930,13 +1071,41 @@ pub fn EpgView() -> Html { let pstart = pstart_time_local.format("%H:%M").to_string(); let pend = pend_time_local.format("%H:%M").to_string(); let program_style = format!("left:{left}px; width:{width}px; min-width:{width}px; max-width:{width}px"); + let pending = PendingProgram { + channel_id: ch.id.to_string(), + channel_name: ch.title.as_ref().map(ToString::to_string), + title: p.title.as_ref().map(ToString::to_string).unwrap_or_default(), + start: p.start, + stop: p.stop, + }; + let program_record_click = { + let handle_record_click = handle_record_click.clone(); + let pending = pending.clone(); + Callback::from(move |(_name, event): (String, MouseEvent)| { + handle_record_click.emit((pending.clone(), event)); + }) + }; html! { -
+
{ &pstart } {"-"} { &pend }
{ p.title.as_ref().map(ToString::to_string).unwrap_or_default() }
+ { + if can_write_recordings && !is_past && is_hosted_epg { + html! { + + } + } else { + html! {} + } + }
} } else { diff --git a/frontend/src/app/components/playlist/filter_view.rs b/frontend/src/app/components/playlist/filter_view.rs index 6f626a373..060655b1d 100644 --- a/frontend/src/app/components/playlist/filter_view.rs +++ b/frontend/src/app/components/playlist/filter_view.rs @@ -90,6 +90,37 @@ fn render_filter(filter: &Filter, pretty: bool, level: usize, do_indent: bool, p }, + Filter::StringComparison(field, op, value) => html! { + <> + { indent(level, do_indent && pretty) } + + {format!("{}", field)} + {format!(" {} ", op)} + {format!("\"{}\"", value)} + + + }, + Filter::NumericComparison(field, op, value) => html! { + <> + { indent(level, do_indent && pretty) } + + {format!("{}", field)} + {format!(" {} ", op)} + {value.to_string()} + + + }, + Filter::SetComparison(field, values) => html! { + <> + { indent(level, do_indent && pretty) } + + {format!("{}", field)} + {" IN ["} + {values.iter().map(|v| format!("\"{v}\"")).collect::>().join(", ")} + {"]"} + + + }, Filter::UnaryExpression(op, inner) => { html! { <> diff --git a/frontend/src/app/components/playlist/playlist_explorer.rs b/frontend/src/app/components/playlist/playlist_explorer.rs index 266a90197..13d4e2f9f 100644 --- a/frontend/src/app/components/playlist/playlist_explorer.rs +++ b/frontend/src/app/components/playlist/playlist_explorer.rs @@ -1,13 +1,18 @@ use crate::{ app::{ - components::{menu_item::MenuItem, popup_menu::PopupMenu, AppIcon, Chip, IconButton, NoContent, Panel, Search}, + components::{ + menu_item::MenuItem, + popup_menu::PopupMenu, + recording::{target_name_for_id, PaddingBounds, RecordingForm, RecordingFormPrefill}, + AppIcon, Chip, DropDownOption, IconButton, NoContent, Panel, Search, + }, context::{ConfigContext, PlaylistExplorerContext}, }, - hooks::use_service_context, + hooks::{use_clipboard_copy, use_service_context}, html_if, i18n::use_translation, model::{BusyStatus, DialogAction, DialogActions, DialogResult, EventMessage}, - services::DialogService, + services::{CreateRecordingTaskRequest, DialogService, RecordingService, RecordingSourceInput}, }; use shared::{ error::TuliproxError, @@ -21,13 +26,13 @@ use std::{cell::RefCell, collections::HashMap, fmt::Display, rc::Rc, str::FromSt use wasm_bindgen::JsCast; use web_sys::HtmlInputElement; use yew::{platform::spawn_local, prelude::*}; -use yew_hooks::use_clipboard; const COPY_LINK_TULIPROX_VIRTUAL_ID: &str = "copy_link_tuliprox_virtual_id"; const COPY_LINK_TULIPROX_WEBPLAYER_URL: &str = "copy_link_tuliprox_webplayer_url"; const COPY_LINK_PROVIDER_URL: &str = "copy_link_provider_url"; const DOWNLOAD_ITEM: &str = "download_item"; const RECORD_ITEM: &str = "record_item"; +const TP_EXPLORER_SEARCH_FIELDS_KEY: &str = "tp-explorer-search-fields"; #[derive(Clone)] struct ChannelSelection { @@ -111,38 +116,6 @@ fn build_download_filename(title: &str, url: &str) -> String { } } -fn default_record_start_value() -> String { chrono::Local::now().format("%Y-%m-%dT%H:%M").to_string() } - -fn parse_record_start_value(start_value: &str) -> Option { - use chrono::TimeZone; - - let start_value = start_value.trim(); - let naive = ["%Y-%m-%dT%H:%M", "%Y-%m-%dT%H:%M:%S"] - .into_iter() - .find_map(|format| chrono::NaiveDateTime::parse_from_str(start_value, format).ok())?; - chrono::Local.from_local_datetime(&naive).earliest().map(|dt| dt.timestamp()) -} - -fn parse_record_duration_minutes(duration_value: &str) -> Option { - let minutes = duration_value.trim().parse::().ok()?; - (minutes > 0).then_some(minutes) -} - -fn build_record_filename(title: &str, start_at: &str) -> String { - let sanitized = title - .chars() - .map(|c| match c { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => c, - _ => '_', - }) - .collect::() - .trim_matches('_') - .to_string(); - let base = if sanitized.is_empty() { "recording".to_string() } else { sanitized }; - let time_part = start_at.replace([':', 'T'], "-"); - format!("{base}_{time_part}.ts") -} - fn parse_optional_priority_input(priority_value: Option) -> Result, String> { let Some(raw) = priority_value.as_deref() else { return Ok(None); @@ -163,8 +136,8 @@ fn can_show_download_action(can_write_downloads: bool, selected_channel: Option< can_write_downloads && selected_channel.is_some_and(|item| item.cluster != XtreamCluster::Live && item.downloadable) } -fn can_show_record_action(can_write_downloads: bool, selected_channel: Option<&ChannelSelection>) -> bool { - can_write_downloads && selected_channel.is_some_and(|item| item.cluster == XtreamCluster::Live) +fn can_show_record_action(can_write_recordings: bool, selected_channel: Option<&ChannelSelection>) -> bool { + can_write_recordings && selected_channel.is_some_and(|item| item.cluster == XtreamCluster::Live) } enum ExplorerLevel { @@ -173,126 +146,80 @@ enum ExplorerLevel { SeriesInfo(Rc, Rc, Option>), } -#[derive(Properties, Clone, PartialEq)] -struct RecordDialogContentProps { - start_value: Rc>, - duration_value: Rc>, - priority_value: Rc>, - start_label: String, - duration_label: String, - priority_label: String, - selected_title: String, -} - -#[component] -fn RecordDialogContent(props: &RecordDialogContentProps) -> Html { - let start_state = use_state(|| props.start_value.borrow().clone()); - let duration_state = use_state(|| props.duration_value.borrow().clone()); - let priority_state = use_state(|| props.priority_value.borrow().clone()); - - let on_start_input = { - let start_state = start_state.clone(); - let start_value = Rc::clone(&props.start_value); - Callback::from(move |event: InputEvent| { - let input: HtmlInputElement = event.target_unchecked_into(); - let value = input.value(); - *start_value.borrow_mut() = value.clone(); - start_state.set(value); - }) - }; - - let on_duration_input = { - let duration_state = duration_state.clone(); - let duration_value = Rc::clone(&props.duration_value); - Callback::from(move |event: InputEvent| { - let input: HtmlInputElement = event.target_unchecked_into(); - let value = input.value(); - *duration_value.borrow_mut() = value.clone(); - duration_state.set(value); - }) - }; - - let on_priority_input = { - let priority_state = priority_state.clone(); - let priority_value = Rc::clone(&props.priority_value); - Callback::from(move |event: InputEvent| { - let input: HtmlInputElement = event.target_unchecked_into(); - let value = input.value(); - *priority_value.borrow_mut() = value.clone(); - priority_state.set(value); - }) - }; - - html! { -
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- {props.selected_title.clone()} -
-
- } -} - #[component] pub fn PlaylistExplorer() -> Html { - let context = use_context::().expect("PlaylistExplorer context not found"); - let config_ctx = use_context::().expect("ConfigContext not found"); - let dialog = use_context::().expect("Dialog service not found"); + let explorer_ctx = use_context::(); + let cfg_ctx = use_context::(); + let dialog_ctx = use_context::(); let translate = use_translation(); + // Render a fallback instead of panicking when a provider is missing + let (Some(context), Some(config_ctx), Some(dialog)) = (explorer_ctx, cfg_ctx, dialog_ctx) else { + log::error!("PlaylistExplorer rendered without required context providers"); + return html! { }; + }; let service_ctx = use_service_context(); let can_write_downloads = service_ctx.auth.has_permission(Permission::DownloadWrite); + let can_write_recordings = service_ctx.auth.has_permission(Permission::RecordingWrite); + let is_admin_role = service_ctx.auth.is_admin(); let default_download_priority = config_ctx .config .as_ref() .and_then(|cfg| cfg.config.video.as_ref()) .and_then(|video| video.download.as_ref()) .map(|download| download.download_priority); - let default_recording_priority = config_ctx - .config - .as_ref() - .and_then(|cfg| cfg.config.video.as_ref()) - .and_then(|video| video.download.as_ref()) - .map(|download| download.recording_priority); + let recording_padding = { + let rec = config_ctx + .config + .as_ref() + .and_then(|cfg| cfg.config.video.as_ref()) + .and_then(|video| video.download.as_ref()) + .and_then(|video| video.recording.as_ref()); + PaddingBounds { + default_pre_roll_secs: rec.and_then(|c| c.default_pre_roll_secs).unwrap_or(0), + max_pre_roll_secs: rec.map(|c| c.max_pre_roll_secs).unwrap_or(900), + default_post_roll_secs: rec.and_then(|c| c.default_post_roll_secs).unwrap_or(0), + max_post_roll_secs: rec.map(|c| c.max_post_roll_secs).unwrap_or(1800), + } + }; + let recording_padding = Rc::new(recording_padding); let current_item = use_state(|| ExplorerLevel::Categories); let playlist = use_state(|| (*context.playlist).clone()); + let search_fields = use_memo((), |_| { + let persisted: Vec = crate::utils::get_local_storage_item(TP_EXPLORER_SEARCH_FIELDS_KEY) + .map(|value| value.split(',').filter(|id| !id.is_empty()).map(str::to_string).collect()) + .unwrap_or_default(); + let is_selected = |id: &str| persisted.iter().any(|p| p == id); + vec![ + DropDownOption::new( + shared::model::SEARCH_FIELD_GROUP, + html! { translate.t("LABEL.GROUP") }, + is_selected(shared::model::SEARCH_FIELD_GROUP), + ), + DropDownOption::new( + shared::model::SEARCH_FIELD_TITLE, + html! { translate.t("LABEL.TITLE") }, + is_selected(shared::model::SEARCH_FIELD_TITLE), + ), + DropDownOption::new( + shared::model::SEARCH_FIELD_NAME, + html! { translate.t("LABEL.NAME") }, + is_selected(shared::model::SEARCH_FIELD_NAME), + ), + DropDownOption::new( + shared::model::SEARCH_FIELD_URL, + html! { translate.t("LABEL.URL") }, + is_selected(shared::model::SEARCH_FIELD_URL), + ), + ] + }); + let handle_search_fields_change = Callback::from(move |fields: Option>>| { + let value = fields.as_ref().map(|f| f.join(",")).unwrap_or_default(); + crate::utils::set_local_storage_item(TP_EXPLORER_SEARCH_FIELDS_KEY, &value); + }); let selected_channel = use_state(|| None::); let popup_anchor_ref = use_state(|| None::); let popup_is_open = use_state(|| false); - let clipboard = use_clipboard(); + let copy_to_clipboard = use_clipboard_copy(); let cluster_visible = use_state(|| XtreamCluster::Live); let handle_cluster_change = { @@ -407,23 +334,6 @@ pub fn PlaylistExplorer() -> Html { }); } - let copy_to_clipboard: Callback = { - let clipboard = clipboard.clone(); - let dialog = dialog.clone(); - Callback::from(move |text: String| { - if *clipboard.is_supported { - clipboard.write_text(text); - } else { - let dlg = dialog.clone(); - spawn_local(async move { - let _result = dlg - .content(html! {}, None, false) - .await; - }); - } - }) - }; - let handle_menu_click = { let services = service_ctx.clone(); let dialog = dialog.clone(); @@ -433,6 +343,7 @@ pub fn PlaylistExplorer() -> Html { let translate_clone = translate.clone(); let can_queue_downloads = can_write_downloads; let copy_to_clipboard = copy_to_clipboard.clone(); + let config = config_ctx.config.clone(); Callback::from(move |(name, _): (String, _)| { if let Ok(action) = ExplorerAction::from_str(&name) { match action { @@ -672,7 +583,7 @@ pub fn PlaylistExplorer() -> Html { } } ExplorerAction::Record => { - if !can_queue_downloads { + if !can_write_recordings { popup_is_open_state.set(false); return; } @@ -681,16 +592,56 @@ pub fn PlaylistExplorer() -> Html { let services = services.clone(); let translate_clone = translate_clone.clone(); let playlist_request = (*playlist_ctx.playlist_request).clone(); - let default_recording_priority = default_recording_priority; let selected = dto.clone(); + let padding = Rc::clone(&recording_padding); + let target_name = match playlist_request.as_ref() { + Some(PlaylistRequest::Target(target_id)) => config.as_ref().and_then(|app_config| { + target_name_for_id(&app_config.sources, *target_id, Some(&selected.input_name)) + }), + _ => None, + }; spawn_local(async move { - let default_start_value = default_record_start_value(); - let start_value = Rc::new(RefCell::new(default_start_value.clone())); - let duration_value = Rc::new(RefCell::new("90".to_string())); - let priority_value = Rc::new(RefCell::new( - default_recording_priority - .map_or_else(String::new, |priority| priority.to_string()), - )); + let target_name = match target_name { + Some(name) => name, + None => { + services.toastr.error(translate_clone.t("MESSAGES.RECORDING.NO_TARGET")); + return; + } + }; + let source = RecordingSourceInput { + target_id: target_name, + virtual_id: selected.virtual_id.to_string(), + cluster: selected.cluster, + input_name: selected.input_name.clone(), + }; + let now = chrono::Utc::now().timestamp(); + let program_end = now + 90 * 60; + let prefill = RecordingFormPrefill::new( + source, + selected.title.clone(), + now, + program_end, + (*padding).clone(), + ) + .with_channel_name(selected.title.clone()); + let request_slot: Rc>> = + Rc::new(RefCell::new(None)); + let on_submit = { + let request_slot = Rc::clone(&request_slot); + Callback::from(move |request: CreateRecordingTaskRequest| { + *request_slot.borrow_mut() = Some(request); + }) + }; + let on_cancel = Callback::from(|_| {}); + let body = html! { + + }; let actions = DialogActions { left: Some(vec![DialogAction::new( "cancel", @@ -707,84 +658,24 @@ pub fn PlaylistExplorer() -> Html { Some("primary".to_string()), )], }; - let result = dialog - .content( - html! { - - }, - Some(actions), - false, - ) - .await; - - if result == DialogResult::Ok { - let start_value = start_value.borrow().clone(); - let duration_value = duration_value.borrow().clone(); - let priority = - match parse_optional_priority_input(Some(priority_value.borrow().clone())) { - Ok(priority) => priority, - Err(err) => { - services.toastr.error(err); - return; - } - }; - let start_ts = parse_record_start_value(&start_value); - let duration_mins = parse_record_duration_minutes(&duration_value); - - match (start_ts, duration_mins) { - (Some(start_at), Some(minutes)) => { - let filename = build_record_filename(&selected.title, &start_value); - let input_name = normalize_input_name(&selected.input_name); - let resolved_url = if let Some(playlist_request) = playlist_request.clone() - { - let request = PlaylistUrlResolveRequest::Provider { - playlist_request, - url: selected.url.clone(), - }; - services - .playlist - .resolve_url(request) - .await - .unwrap_or(selected.url.clone()) - } else { - selected.url.clone() - }; - match services - .downloads - .queue_recording( - resolved_url, - filename, - start_at, - minutes.saturating_mul(60), - input_name, - priority, - ) - .await - { - Ok(_) => { - services.toastr.success( - translate_clone.t("MESSAGES.DOWNLOAD.RECORDING_QUEUED"), - ); - } - Err(_) => { - services.toastr.error(translate_clone.t("MESSAGES.DOWNLOAD.FAIL")); - } - } - } - (None, _) => services - .toastr - .error(translate_clone.t("MESSAGES.DOWNLOAD.INVALID_RECORD_START")), - (_, None) => services - .toastr - .error(translate_clone.t("MESSAGES.DOWNLOAD.INVALID_RECORD_DURATION")), + let result = dialog.content(body, Some(actions), false).await; + if result != DialogResult::Ok { + return; + } + let request = match request_slot.borrow_mut().take() { + Some(r) => r, + None => { + services.toastr.error(translate_clone.t("MESSAGES.RECORDING.NO_REQUEST")); + return; + } + }; + let recording_svc = RecordingService::new(); + match recording_svc.create_task(request).await { + Ok(_) => { + services.toastr.success(translate_clone.t("MESSAGES.RECORDING.QUEUED")); + } + Err(err) => { + services.toastr.error(err.to_string()); } } }); @@ -860,6 +751,11 @@ pub fn PlaylistExplorer() -> Html { .collect::() }; + let render_cluster_panel = |cluster: XtreamCluster, list: Option<&Vec>>| match list { + Some(list) if !list.is_empty() => render_cluster(cluster, list), + _ => html! { }, + }; + let render_categories = || { if playlist.is_none() { html! { @@ -880,29 +776,17 @@ pub fn PlaylistExplorer() -> Html {
- { playlist.as_ref() - .and_then(|response| response.live.as_ref()) - .map(|list| render_cluster(XtreamCluster::Live, list)) - .unwrap_or_default() - } + { render_cluster_panel(XtreamCluster::Live, playlist.as_ref().and_then(|response| response.live.as_ref())) }
- { playlist.as_ref() - .and_then(|response| response.vod.as_ref()) - .map(|list| render_cluster(XtreamCluster::Video, list)) - .unwrap_or_default() - } + { render_cluster_panel(XtreamCluster::Video, playlist.as_ref().and_then(|response| response.vod.as_ref())) }
- { playlist.as_ref() - .and_then(|response| response.series.as_ref()) - .map(|list| render_cluster(XtreamCluster::Series, list)) - .unwrap_or_default() - } + { render_cluster_panel(XtreamCluster::Series, playlist.as_ref().and_then(|response| response.series.as_ref())) }
@@ -994,7 +878,8 @@ pub fn PlaylistExplorer() -> Html { virtual_id: chan.id, cluster: XtreamCluster::Series, downloadable: true, - url: String::new(), // TODO provider url + // Falls back to the episode fetch path in the menu handler when empty + url: chan.direct_source.to_string(), title: chan.title.to_string(), input_name: String::new(), }; @@ -1029,7 +914,11 @@ pub fn PlaylistExplorer() -> Html {
{ - group.channels.iter().map(render_channel).collect::() + if group.channels.is_empty() { + html! { } + } else { + group.channels.iter().map(render_channel).collect::() + } }
@@ -1148,7 +1037,7 @@ pub fn PlaylistExplorer() -> Html { }
@@ -1172,7 +1061,7 @@ pub fn PlaylistExplorer() -> Html { } { html_if!( - can_show_record_action(can_write_downloads, selected_channel.as_ref()), + can_show_record_action(can_write_recordings, selected_channel.as_ref()), { })} @@ -1190,7 +1079,7 @@ pub fn PlaylistExplorer() -> Html { mod tests { use super::{ build_download_filename, can_show_download_action, can_show_record_action, normalize_input_name, - parse_optional_priority_input, parse_record_duration_minutes, parse_record_start_value, ChannelSelection, + parse_optional_priority_input, ChannelSelection, }; use shared::model::{VirtualId, XtreamCluster}; @@ -1265,21 +1154,6 @@ mod tests { assert!(!can_show_record_action(true, Some(&vod))); } - #[test] - fn parse_record_duration_minutes_rejects_zero_and_invalid_values() { - assert_eq!(parse_record_duration_minutes("0"), None); - assert_eq!(parse_record_duration_minutes("abc"), None); - assert_eq!(parse_record_duration_minutes("15"), Some(15)); - } - - #[test] - fn parse_record_start_value_accepts_default_format() { - let default_value = super::default_record_start_value(); - assert!(parse_record_start_value(&default_value).is_some()); - assert!(parse_record_start_value("2026-04-03T12:34:00").is_some()); - assert!(parse_record_start_value("not-a-date").is_none()); - } - #[test] fn build_download_filename_keeps_url_extension() { let filename = build_download_filename("My Movie", "https://example.com/video.mkv?token=1"); diff --git a/frontend/src/app/components/playlist/playlist_update_view.rs b/frontend/src/app/components/playlist/playlist_update_view.rs index b39e669f3..e51f83c87 100644 --- a/frontend/src/app/components/playlist/playlist_update_view.rs +++ b/frontend/src/app/components/playlist/playlist_update_view.rs @@ -1,6 +1,6 @@ use crate::{ app::{ - components::{Breadcrumbs, Card, PlaylistContext, TextButton}, + components::{Breadcrumbs, Card, NoContent, PlaylistContext, TextButton}, ConfigContext, }, hooks::use_service_context, @@ -74,6 +74,8 @@ pub fn PlaylistUpdateView() -> Html { let can_write_library = services_ctx.auth.has_permission(Permission::LibraryWrite); let breadcrumbs = use_state(|| Rc::new(vec![translate.t("LABEL.PLAYLISTS"), translate.t("LABEL.UPDATE")])); let selected_targets = use_list::>(vec![]); + let updating = use_state(|| false); + let library_updating = use_state(|| false); let log_lines = use_reducer(|| LogLinesState { lines: Vec::new() }); let log_container_ref = use_node_ref(); @@ -139,14 +141,17 @@ pub fn PlaylistUpdateView() -> Html { let services = services_ctx.clone(); let selected_targets = selected_targets.clone(); let log_lines = log_lines.clone(); + let updating = updating.clone(); Callback::from(move |_| { - if !can_write_playlist { + if !can_write_playlist || *updating { return; } + updating.set(true); log_lines.dispatch(LogLinesAction::Clear); let selected_targets = selected_targets.clone(); let services = services.clone(); let translate = translate.clone(); + let updating = updating.clone(); spawn_local(async move { let target_names = { let targets = selected_targets.current(); @@ -158,6 +163,7 @@ pub fn PlaylistUpdateView() -> Html { } else { services.toastr.error(translate.t("MESSAGES.PLAYLIST_UPDATE.FAIL")); } + updating.set(false); }); }) }; @@ -166,13 +172,15 @@ pub fn PlaylistUpdateView() -> Html { let services = services_ctx.clone(); let translate = translate.clone(); let log_lines = log_lines.clone(); + let library_updating = library_updating.clone(); Callback::from(move |name: String| { - if !can_write_library { + if !can_write_library || *library_updating { return; } let services = services.clone(); let translate = translate.clone(); let log_lines = log_lines.clone(); + let library_updating = library_updating.clone(); wasm_bindgen_futures::spawn_local(async move { let mode = match name.as_str() { ACTION_UPDATE_LIBRARY => 1, @@ -180,11 +188,13 @@ pub fn PlaylistUpdateView() -> Html { _ => 0, }; if mode > 0 { + library_updating.set(true); log_lines.dispatch(LogLinesAction::Clear); match services.config.update_library(mode == 2).await { Ok(()) => services.toastr.success(translate.t("MESSAGES.LIBRARY_UPDATE.SUCCESS")), Err(_err) => services.toastr.error(translate.t("MESSAGES.LIBRARY_UPDATE.FAIL")), } + library_updating.set(false); } }); }) @@ -211,10 +221,12 @@ pub fn PlaylistUpdateView() -> Html {
})} @@ -222,19 +234,22 @@ pub fn PlaylistUpdateView() -> Html { { html_if!(can_write_playlist, { })}
- - { - if let Some(data) = playlist_ctx.sources.as_ref() { - data.iter().flat_map(|(_inputs, targets)| targets) + if let Some(data) = playlist_ctx.sources.as_ref().as_ref().filter(|data| data.iter().any(|(_, targets)| !targets.is_empty())) { + html! { + <> + + { + data.iter().flat_map(|(_inputs, targets)| targets) .map(Rc::clone) .map(|target| { let handle_click = handle_target_select.clone(); @@ -245,9 +260,12 @@ pub fn PlaylistUpdateView() -> Html { name={target_name.clone()} title={target_name} icon={"UpdateChecked"} onclick={move |_| handle_click.emit(target.clone())}/> } - }).collect::() + }).collect::() + } + + } } else { - html! {<>} + html! { } } }
diff --git a/frontend/src/app/components/playlist/target_table.rs b/frontend/src/app/components/playlist/target_table.rs index 9a0bd8efc..570d4177d 100644 --- a/frontend/src/app/components/playlist/target_table.rs +++ b/frontend/src/app/components/playlist/target_table.rs @@ -1,8 +1,11 @@ use crate::{ - app::components::{ - convert_bool_to_chip_style, make_translated_header_callback, menu_item::MenuItem, popup_menu::PopupMenu, - AppIcon, Chip, FilterView, PlaylistMappings, PlaylistProcessing, RevealContent, Table, TableDefinition, - TargetOptions, TargetOutput, TargetRename, TargetSort, TargetWatch, ToggleSwitch, + app::{ + components::{ + convert_bool_to_chip_style, make_translated_header_callback, menu_item::MenuItem, popup_menu::PopupMenu, + AppIcon, Chip, FilterView, PlaylistMappings, PlaylistProcessing, RevealContent, Table, TableDefinition, + TargetOptions, TargetOutput, TargetRename, TargetSort, TargetWatch, ToggleSwitch, + }, + ConfigContext, }, hooks::use_service_context, html_if, @@ -42,6 +45,7 @@ pub fn TargetTable(props: &TargetTableProps) -> Html { let translate = use_translation(); let services = use_service_context(); let dialog = use_context::().expect("Dialog service not found"); + let config_ctx = use_context::().expect("Config context not found"); let popup_anchor_ref = use_state(|| None::); let popup_is_open = use_state(|| false); let selected_dto = use_state(|| None::>); @@ -138,6 +142,7 @@ pub fn TargetTable(props: &TargetTableProps) -> Html { let translate = translate.clone(); let services_ctx = services.clone(); let selected_dto = selected_dto.clone(); + let config_ctx = config_ctx.clone(); Callback::from(move |(name, _): (String, _)| { if let Ok(action) = TargetTableAction::from_str(&name) { match action { @@ -158,10 +163,27 @@ pub fn TargetTable(props: &TargetTableProps) -> Html { TargetTableAction::Delete => { let confirm = confirm.clone(); let translator = translate.clone(); + let services_ctx = services_ctx.clone(); + let config_ctx = config_ctx.clone(); + let target_name = selected_dto.as_ref().map_or_else(String::new, |d| d.name.to_string()); spawn_local(async move { let result = confirm.confirm(&translator.t("MESSAGES.CONFIRM_DELETE")).await; - if result == DialogResult::Ok { - // TODO edit + if result != DialogResult::Ok { + return; + } + let Some(app_config) = config_ctx.config.as_ref() else { + return; + }; + let mut sources = app_config.sources.clone(); + for source in sources.sources.iter_mut() { + source.targets.retain(|t| t.name != target_name); + } + match services_ctx.config.save_sources(sources).await { + Ok(()) => { + services_ctx.toastr.success(translator.t("MESSAGES.SAVE.SOURCES_CONFIG.SUCCESS")); + let _ = services_ctx.config.get_server_config().await; + } + Err(err) => services_ctx.toastr.error(err.to_string()), } }); } diff --git a/frontend/src/app/components/popup_menu.rs b/frontend/src/app/components/popup_menu.rs index 42e99f064..f7c68a946 100644 --- a/frontend/src/app/components/popup_menu.rs +++ b/frontend/src/app/components/popup_menu.rs @@ -9,6 +9,9 @@ pub struct PopupMenuProps { pub anchor_ref: Option, #[prop_or_default] pub on_close: Callback<()>, + /// ARIA role of the option list, e.g. `listbox` for select-style popups. + #[prop_or_else(|| "menu".to_string())] + pub list_role: String, pub children: Children, } @@ -67,6 +70,13 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html { let _ = popup.style().set_property("--popup-top", &format!("{top}px")); let _ = popup.style().set_property("--popup-left", &format!("{left}px")); + + // Move focus into the menu so keyboard users can navigate immediately + if let Ok(Some(first)) = popup.query_selector("button") { + if let Ok(button) = first.dyn_into::() { + let _ = button.focus(); + } + } }); } @@ -79,7 +89,8 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html { let handler = if *is_open { let handler = Closure::wrap(Box::new(move |event: MouseEvent| { if let Some(popup) = popup_ref.cast::() { - if let Some(target) = event.target().and_then(|t| t.dyn_into::().ok()) { + // Cast to Node so clicks on SVG elements outside the popup also close it + if let Some(target) = event.target().and_then(|t| t.dyn_into::().ok()) { if !popup.contains(Some(&target)) { on_close.emit(()); } @@ -109,17 +120,55 @@ pub fn PopupMenu(props: &PopupMenuProps) -> Html { { let is_open = props.is_open; let on_close = props.on_close.clone(); + let popup_ref = popup_ref.clone(); use_key_down((is_open, on_close.clone()), move |event: &KeyboardEvent| { - if is_open && event.key() == "Escape" { + if !is_open { + return; + } + let key = event.key(); + if key == "Escape" { on_close.emit(()); + return; + } + let Some(popup) = popup_ref.cast::() else { + return; + }; + let Ok(items) = popup.query_selector_all("button") else { + return; + }; + let count = items.length(); + if count == 0 { + return; + } + let active = window().and_then(|w| w.document()).and_then(|d| d.active_element()); + let current = active.and_then(|active| { + (0..count).find(|i| { + items + .item(*i) + .and_then(|node| node.dyn_into::().ok()) + .is_some_and(|el| el == active) + }) + }); + let next = match key.as_str() { + "ArrowDown" => Some(current.map_or(0, |c| (c + 1) % count)), + "ArrowUp" => Some(current.map_or(count - 1, |c| (c + count - 1) % count)), + "Home" => Some(0), + "End" => Some(count - 1), + _ => None, + }; + if let Some(idx) = next { + event.prevent_default(); + if let Some(item) = items.item(idx).and_then(|node| node.dyn_into::().ok()) { + let _ = item.focus(); + } } }); } let popup = html! {
-
    - { for props.children.iter().map(|child| html! {
  • {child.clone()}
  • }) } +
      + { for props.children.iter().map(|child| html! {
    • {child.clone()}
    • }) }
}; diff --git a/frontend/src/app/components/recording/mod.rs b/frontend/src/app/components/recording/mod.rs new file mode 100644 index 000000000..eea04c5ef --- /dev/null +++ b/frontend/src/app/components/recording/mod.rs @@ -0,0 +1,11 @@ +mod recording_edit_view; +mod recording_form; +mod recording_library_view; +mod recording_rule_form; +mod recording_rules_view; +mod recording_task_edit_form; +#[allow(unused_imports)] +pub use recording_edit_view::*; +pub use recording_form::*; +pub use recording_library_view::*; +pub use recording_rules_view::*; diff --git a/frontend/src/app/components/recording/recording_edit_view.rs b/frontend/src/app/components/recording/recording_edit_view.rs new file mode 100644 index 000000000..e41b3c4b9 --- /dev/null +++ b/frontend/src/app/components/recording/recording_edit_view.rs @@ -0,0 +1,80 @@ +//! Standalone edit view for a single recording task. +//! +//! Looks up the task in the WS-driven library list and renders +//! `TaskEditForm`. Renders an i18n-keyed "task not found" message +//! when the id is unknown (e.g. it was deleted while the user was +//! editing it). + +use crate::{ + app::components::recording::recording_task_edit_form::TaskEditForm, hooks::use_service_context, + i18n::use_translation, model::EventMessage, services::RecordingTaskResponse, +}; +use shared::model::web_socket::ProtocolMessage; +use std::rc::Rc; +use yew::prelude::*; + +/// Read the current task id from a context slot set by the router. +/// Kept as a context (not a prop) so the wrapper is decoupled from +/// whatever the home router does today. +#[derive(Clone, PartialEq, Default)] +#[allow(dead_code)] +pub struct EditingTaskId(pub Rc>); + +#[function_component(RecordingEditView)] +pub fn recording_edit_view() -> Html { + let services = use_service_context(); + let translate = use_translation(); + let editing = use_context::().unwrap_or_default(); + let tasks = use_state(|| Rc::new(Vec::::new())); + + // Subscribe to the same WS stream the library view uses. The + // backend broadcasts the same per-session filtered snapshot to + // every subscriber, so we get the same live updates without a + // second fetch. + { + let tasks = tasks.clone(); + let svc = services.clone(); + use_effect_with((), move |_| { + let sid = svc.event.subscribe(move |msg| { + if let EventMessage::RecordingSnapshot { tasks: incoming, .. } = msg { + let mapped = incoming.iter().map(|t| RecordingTaskResponse::from(t.clone())).collect(); + tasks.set(Rc::new(mapped)); + } + }); + let _ = svc.websocket.send_message(ProtocolMessage::RecordingSnapshotRequest); + move || svc.event.unsubscribe(sid) + }); + } + + let on_done = Callback::from(move |_: ()| { /* router picks this up */ }); + + let body = match (*editing.0).as_ref() { + Some(id) => (*tasks) + .iter() + .find(|t| &t.id == id) + .cloned() + .map(|task| html! { }) + .unwrap_or_else(|| html! {

{ translate.t("MESSAGES.RECORDING.TASK_NOT_FOUND") }

}), + None => html! {

{ translate.t("MESSAGES.RECORDING.NO_TASK_SELECTED") }

}, + }; + + html! { +
+
+

{ translate.t("LABEL.RECORDING_EDIT_TITLE") }

+ { body } +
+
+ } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editing_task_id_default_is_none() { + let ctx = EditingTaskId::default(); + assert!(ctx.0.is_none()); + } +} diff --git a/frontend/src/app/components/recording/recording_form.rs b/frontend/src/app/components/recording/recording_form.rs new file mode 100644 index 000000000..bb2d0b4ff --- /dev/null +++ b/frontend/src/app/components/recording/recording_form.rs @@ -0,0 +1,945 @@ +//! Shared recording form used by the playlist explorer and the EPG view. +//! +//! The form collects padding and visibility only; the caller receives a +//! fully-populated `CreateRecordingTaskRequest` via `on_submit` and decides +//! what to do with it. The server is the source of truth for validation, +//! source resolution, path reservation, and quota admission; every client +//! calculation is a preview. + +use crate::{ + app::components::{number_input::NumberInput, DateTimeInput, RadioButtonGroup}, + i18n::use_translation, + services::{ + ConflictSeverity, CreateRecordingTaskRequest, PreviewCandidateDto, PreviewConflictsRequest, PreviewSourceDto, + RecordingConflictPreview, RecordingService, RecordingSourceInput, + }, +}; +use gloo_timers::future::TimeoutFuture; +#[cfg(test)] +use shared::model::permission::Permission; +use shared::model::recording::EpgEpisodeMetadata; +use std::rc::Rc; +use yew::prelude::*; + +/// How long the form waits after the last edit before asking the server +/// for a conflict preview. Long enough that dragging a duration spinner +/// does not fire a request per keystroke; short enough to feel live. +const CONFLICT_PREVIEW_DEBOUNCE_MS: u32 = 400; + +/// Configured padding bounds. Mirrors the `RecordingConfigDto` fields +/// the server already validates. The frontend uses the upper bound for +/// input validation; the server is authoritative. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaddingBounds { + pub default_pre_roll_secs: u64, + pub max_pre_roll_secs: u64, + pub default_post_roll_secs: u64, + pub max_post_roll_secs: u64, +} + +/// The data the form needs to pre-populate. All identifiers are +/// server-owned; the form never accepts a URL from the caller. +#[derive(Clone, PartialEq)] +pub struct RecordingFormPrefill { + pub source: RecordingSourceInput, + pub program_title: String, + /// Original programme interval (Unix seconds). The form always + /// displays this. The server is the source of truth. + pub program_start: i64, + pub program_end: i64, + pub channel_id: Option, + pub channel_name: Option, + pub epg: Option, + pub padding: PaddingBounds, +} + +impl RecordingFormPrefill { + /// Build a prefill from the minimum surface every caller has. The + /// channel_id / channel_name / epg are optional; the form stores + /// them so the server can use them for visibility and matching. + pub fn new( + source: RecordingSourceInput, + program_title: impl Into, + program_start: i64, + program_end: i64, + padding: PaddingBounds, + ) -> Self { + Self { + source, + program_title: program_title.into(), + program_start, + program_end, + channel_id: None, + channel_name: None, + epg: None, + padding, + } + } + + /// Builder-style channel id. The Playlist Explorer wires this in + /// from the selected `ChannelSelection`; the EPG view wires it from + /// the programme metadata. + pub fn with_channel_id(mut self, channel_id: impl Into) -> Self { + self.channel_id = Some(channel_id.into()); + self + } + + /// Builder-style channel name. + pub fn with_channel_name(mut self, channel_name: impl Into) -> Self { + self.channel_name = Some(channel_name.into()); + self + } + + /// Builder-style EPG metadata. The EPG view wires this in from + /// the programme metadata. + pub fn with_epg(mut self, epg: EpgEpisodeMetadata) -> Self { + self.epg = Some(epg); + self + } +} + +/// Pure: compute the padded scheduled interval from the original +/// programme interval and the user's padding choice. Saturates on +/// overflow so the rendered previews never panic. +pub fn compute_scheduled_interval( + program_start: i64, + program_end: i64, + pre_roll_secs: u64, + post_roll_secs: u64, +) -> (i64, i64) { + let scheduled_start = program_start.saturating_sub(pre_roll_secs as i64); + let scheduled_end = program_end.saturating_add(post_roll_secs as i64); + (scheduled_start, scheduled_end) +} + +/// Pure: validate the user's padding against the configured bounds. +/// The maximums the server enforces are authoritative; the frontend +/// uses the same bounds so the submit button never enables a value the +/// server will reject. +pub fn validate_padding(pre_roll_secs: u64, post_roll_secs: u64, bounds: &PaddingBounds) -> Result<(), String> { + if pre_roll_secs > bounds.max_pre_roll_secs { + return Err(format!( + "Pre-roll ({pre_roll_secs}s) exceeds the configured maximum ({}s)", + bounds.max_pre_roll_secs + )); + } + if post_roll_secs > bounds.max_post_roll_secs { + return Err(format!( + "Post-roll ({post_roll_secs}s) exceeds the configured maximum ({}s)", + bounds.max_post_roll_secs + )); + } + Ok(()) +} + +/// Pure: render a filename preview for the form. Mirrors the +/// placeholder discipline the server's filename template uses so the +/// preview is consistent with the server-rendered name. The result is +/// purely advisory — the server may resolve a different filename for +/// collision, owner templates, or sanitization rules. +pub fn render_filename_preview(prefill: &RecordingFormPrefill, _pre_roll_secs: u64, _post_roll_secs: u64) -> String { + // Preview only — the server's filename template is authoritative. + let channel = prefill.channel_name.as_deref().unwrap_or("channel"); + let title = prefill.program_title.trim(); + let safe_channel = sanitize_preview_token(channel); + let safe_title = if title.is_empty() { "program".to_string() } else { sanitize_preview_token(title) }; + let start = format_timestamp_for_filename(prefill.program_start); + format!("{safe_channel}_{safe_title}_{start}.ts") +} + +fn sanitize_preview_token(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut prev_underscore = false; + for c in s.chars() { + let mapped = match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '_' | '-' => c, + ' ' | '\t' => '_', + _ => '_', + }; + if mapped == '_' { + if !prev_underscore { + out.push(mapped); + } + prev_underscore = true; + } else { + out.push(mapped); + prev_underscore = false; + } + } + out.trim_matches('_').to_string() +} + +fn format_timestamp_for_filename(ts: i64) -> String { + let Some(naive) = chrono::DateTime::from_timestamp(ts, 0) else { + return "0000-00-00_00-00".to_string(); + }; + naive.with_timezone(&chrono::Utc).format("%Y-%m-%d_%H-%M").to_string() +} + +/// Show the Shared visibility option only to administrators with +/// `recording.write`. Non-admins can only record privately. +pub fn can_pick_shared(has_recording_write: bool, is_admin_role: bool) -> bool { has_recording_write && is_admin_role } + +pub fn target_name_for_id( + sources: &shared::model::SourcesConfigDto, + target_id: u16, + input_name: Option<&str>, +) -> Option { + sources.sources.iter().find_map(|source| { + if input_name.is_some_and(|name| !source.inputs.iter().any(|configured| configured.as_ref() == name)) { + return None; + } + source.targets.iter().find(|target| target.id == target_id).map(|target| target.name.to_string()) + }) +} + +/// Translate the user's form choice into the wire enum. The form only +/// ever emits `private` or `shared`; the server's +/// `recording_shared_not_administrator` code path is the authoritative +/// forge defense. +pub fn visibility_to_wire(picked_shared: bool) -> &'static str { + if picked_shared { + "shared" + } else { + "private" + } +} + +/// Build a `CreateRecordingTaskRequest` from the form's prefill + +/// user-controlled padding + visibility. When `override_start` and +/// `override_duration_minutes` are both provided, the request's +/// `program_start` / `program_end` reflect the user's editable window; +/// otherwise the prefill window is passed through unchanged. Either +/// `override_*` being `None` falls back to the prefill's matching field +/// (so a half-set override cannot produce a corrupt interval). +pub fn build_request( + prefill: &RecordingFormPrefill, + pre_roll_secs: u64, + post_roll_secs: u64, + picked_shared: bool, + override_start: Option, + override_duration_minutes: Option, +) -> CreateRecordingTaskRequest { + let (program_start, program_end) = match (override_start, override_duration_minutes) { + (Some(start), Some(minutes)) => { + let minutes_i64 = i64::try_from(minutes).unwrap_or(i64::MAX); + let end = start.saturating_add(minutes_i64.saturating_mul(60)); + (start, end) + } + (Some(start), None) => (start, prefill.program_end), + (None, Some(_)) => (prefill.program_start, prefill.program_end), + (None, None) => (prefill.program_start, prefill.program_end), + }; + CreateRecordingTaskRequest { + source: prefill.source.clone(), + program_title: prefill.program_title.clone(), + program_start, + program_end, + pre_roll_secs, + post_roll_secs, + visibility: visibility_to_wire(picked_shared).to_string(), + channel_id: prefill.channel_id.clone(), + channel_name: prefill.channel_name.clone(), + epg: prefill.epg.clone(), + } +} + +/// Format a Unix timestamp for display in the form. The form shows the +/// original (`program`) and the padded (`scheduled`) intervals as a +/// short local wall-clock time (`HH:MM`) so the user reads the +/// display the same way they typed the start time into the +/// `DateTimeInput` field. Cross-midnight end times show as `HH:MM` +/// without a date marker — a verbose date stamp crowds the dialog. +pub fn format_interval_for_display(ts: i64) -> String { + let Some(dt) = chrono::DateTime::from_timestamp(ts, 0) else { + return "".to_string(); + }; + dt.with_timezone(&chrono::Local).format("%H:%M").to_string() +} + +/// Convenience accessor for callers that track a `PermissionSet`. +#[cfg(test)] +pub fn has_recording_write(permissions: &shared::model::permission::PermissionSet) -> bool { + permissions.contains(Permission::RecordingWrite) +} + +/// Properties for the recording form component. +#[derive(Properties, Clone, PartialEq)] +pub struct RecordingFormProps { + pub prefill: RecordingFormPrefill, + /// `recording.write` permission. + pub has_recording_write: bool, + /// Whether the principal carries the built-in administrator role. + pub is_admin_role: bool, + /// Submit callback. Fires with a fully populated + /// `CreateRecordingTaskRequest` once the form is valid. + pub on_submit: Callback, + /// Cancel callback. The caller decides what to do (close dialog). + pub on_cancel: Callback<()>, +} + +#[component] +pub fn RecordingForm(props: &RecordingFormProps) -> Html { + let translate = use_translation(); + let prefill = &props.prefill; + let pre_state = use_state(|| prefill.padding.default_pre_roll_secs); + let post_state = use_state(|| prefill.padding.default_post_roll_secs); + let shared_offered = can_pick_shared(props.has_recording_write, props.is_admin_role); + let shared_state = use_state(|| false); + let start_state = use_state(|| prefill.program_start); + let duration_minutes_state = use_state(|| { + // Floor: a recording shorter than a minute is never useful. + let secs = (prefill.program_end - prefill.program_start).max(60); + (secs / 60) as u64 + }); + + let on_pre_input = { + let pre_state = pre_state.clone(); + Callback::from(move |value: Option| { + if let Some(v) = value { + if v >= 0 { + pre_state.set(v as u64); + } + } + }) + }; + + let on_post_input = { + let post_state = post_state.clone(); + Callback::from(move |value: Option| { + if let Some(v) = value { + if v >= 0 { + post_state.set(v as u64); + } + } + }) + }; + + let on_start_change = { + let start_state = start_state.clone(); + Callback::from(move |value: Option| { + if let Some(ts) = value { + start_state.set(ts); + } + // None = invalid input. The DateTimeInput component already + // re-displays the last valid value in that case; we just + // don't touch state. + }) + }; + + let on_duration_change = { + let duration_minutes_state = duration_minutes_state.clone(); + Callback::from(move |value: Option| { + if let Some(v) = value { + if v >= 1 { + duration_minutes_state.set(v as u64); + } + } + }) + }; + + let visibility_options: Rc> = if shared_offered { + Rc::new(vec!["private".to_string(), "shared".to_string()]) + } else { + Rc::new(vec!["private".to_string()]) + }; + let visibility_labels: Rc> = Rc::new(vec![translate.t("LABEL.PRIVATE"), translate.t("LABEL.SHARED")]); + let visibility_selected: Rc> = + if *shared_state { Rc::new(vec!["shared".to_string()]) } else { Rc::new(vec!["private".to_string()]) }; + let on_visibility_select = { + let shared_state = shared_state.clone(); + Callback::from(move |selections: Rc>| { + let picked = selections.iter().next().map(String::as_str).unwrap_or("private"); + shared_state.set(picked == "shared"); + }) + }; + + // Mirror the latest valid request into the parent slot on every state change so + // the dialog's own Cancel/Record buttons stay the only way to close. + { + let pre_state = pre_state.clone(); + let post_state = post_state.clone(); + let shared_state = shared_state.clone(); + let start_state = start_state.clone(); + let duration_minutes_state = duration_minutes_state.clone(); + let on_submit = props.on_submit.clone(); + let prefill = prefill.clone(); + use_effect_with( + (*pre_state, *post_state, *shared_state, *start_state, *duration_minutes_state), + move |(pre, post, shared, start, duration_minutes)| { + let emit_empty = || { + // Sentinel request: `program_start == 0 && program_end == 0` + // signals "no valid submission yet" so the parent + // can disable the action button without falling + // back to the previous good request. + on_submit.emit(CreateRecordingTaskRequest { + source: prefill.source.clone(), + program_title: prefill.program_title.clone(), + program_start: 0, + program_end: 0, + pre_roll_secs: 0, + post_roll_secs: 0, + visibility: if *shared { "shared".to_string() } else { "private".to_string() }, + channel_id: None, + channel_name: None, + epg: None, + }); + }; + if *duration_minutes < 1 { + emit_empty(); + return; + } + match validate_padding(*pre, *post, &prefill.padding) { + Ok(()) => { + let request = + build_request(&prefill, *pre, *post, *shared, Some(*start), Some(*duration_minutes)); + on_submit.emit(request); + } + Err(_) => emit_empty(), + } + }, + ); + } + + let effective_start = *start_state; + let effective_end = effective_start.saturating_add((*duration_minutes_state as i64).saturating_mul(60)); + let (scheduled_start, scheduled_end) = + compute_scheduled_interval(effective_start, effective_end, *pre_state, *post_state); + let preview_filename = render_filename_preview(prefill, *pre_state, *post_state); + let padding_error = validate_padding(*pre_state, *post_state, &prefill.padding).err(); + + // Live conflict preview. The endpoint existed but nothing called it, + // so users scheduled blind and only found out a recording had lost + // its provider slot after it failed. The request is debounced and + // keyed on the padded interval, so editing the form is cheap. + let conflict = use_state(|| None::); + let conflict_pending = use_state(|| false); + { + let conflict = conflict.clone(); + let conflict_pending = conflict_pending.clone(); + let source = prefill.source.clone(); + let pre = *pre_state; + let post = *post_state; + use_effect_with((scheduled_start, scheduled_end, pre, post), move |_| { + // Set on teardown, checked after the debounce and after the + // response: an edit that supersedes this one must not let a + // stale answer land. + let cancelled = Rc::new(std::cell::Cell::new(false)); + if scheduled_end <= scheduled_start { + // Interval not valid yet — nothing to preview. + conflict.set(None); + conflict_pending.set(false); + } else { + conflict_pending.set(true); + let superseded = cancelled.clone(); + let request = PreviewConflictsRequest { + source: PreviewSourceDto { + target_name: source.target_id.clone(), + virtual_id: source.virtual_id.clone(), + input_name: source.input_name.clone(), + }, + candidate: PreviewCandidateDto { + padded_start: scheduled_start, + padded_end: scheduled_end, + pre_roll_secs: pre, + post_roll_secs: post, + priority: 0, + }, + }; + wasm_bindgen_futures::spawn_local(async move { + TimeoutFuture::new(CONFLICT_PREVIEW_DEBOUNCE_MS).await; + if superseded.get() { + return; + } + let result = RecordingService::new().preview_conflicts(&request).await; + if superseded.get() { + return; + } + conflict_pending.set(false); + match result { + Ok(preview) => conflict.set(Some(preview)), + // Advisory only: a failed preview must never block + // the form or shout at the user. Drop the badge. + Err(error) => { + log::debug!("conflict preview unavailable: {error}"); + conflict.set(None); + } + } + }); + } + move || cancelled.set(true) + }); + } + let conflict_badge = if *conflict_pending { + html! { + + { translate.t("LABEL.CONFLICT_CHECKING") } + + } + } else if let Some(preview) = conflict.as_ref() { + let label = translate.t(severity_i18n_key(&preview.severity)); + // The tooltip names how many other recordings overlap, never + // which ones: the preview is anonymized server-side and must + // stay that way. + let overlaps = preview.overlap_segments.len(); + let detail = if overlaps > 0 { + translate.t("LABEL.CONFLICT_OVERLAP_COUNT").replace("{count}", &overlaps.to_string()) + } else { + label.clone() + }; + html! { + + { label } + + } + } else { + html! { <> } + }; + + let programme_label = translate.t("LABEL.PROGRAMME"); + let original_label = translate.t("LABEL.ORIGINAL_INTERVAL"); + let scheduled_label = translate.t("LABEL.SCHEDULED_INTERVAL"); + let start_label = translate.t("LABEL.START_TIME"); + let duration_label = translate.t("LABEL.DURATION_MINUTES"); + let pre_roll_label = format!("{} (max {}s)", translate.t("LABEL.PRE_ROLL"), prefill.padding.max_pre_roll_secs); + let post_roll_label = format!("{} (max {}s)", translate.t("LABEL.POST_ROLL"), prefill.padding.max_post_roll_secs); + let visibility_label = translate.t("LABEL.VISIBILITY"); + let filename_preview_label = translate.t("LABEL.FILENAME_PREVIEW"); + let original_interval_value = format!( + "{} — {}", + format_interval_for_display(prefill.program_start), + format_interval_for_display(prefill.program_end) + ); + let scheduled_interval_value = + format!("{} — {}", format_interval_for_display(scheduled_start), format_interval_for_display(scheduled_end)); + + html! { +
+
+
+ { programme_label } + { prefill.program_title.clone() } +
+
+ { original_label } + { original_interval_value } +
+
+ { start_label } + +
+ +
+ { scheduled_label } + { scheduled_interval_value } + { conflict_badge } +
+
+ + + if let Some(err) = padding_error { +
{ err }
+ } +
+ { visibility_label } + +
+
+ { filename_preview_label } + { preview_filename } +
+
+ } +} + +/// Build a `RecordingFormPrefill` from a single source-id tuple. The +/// helper is the minimal builder callers need; the richer +/// `RecordingFormPrefill` builder methods cover the optional fields. +#[cfg(test)] +pub fn prefill_from_source( + source: RecordingSourceInput, + program_title: impl Into, + program_start: i64, + program_end: i64, + padding: PaddingBounds, +) -> RecordingFormPrefill { + RecordingFormPrefill::new(source, program_title, program_start, program_end, padding) +} + +pub struct EpgProgrammePrefillInput { + pub source: RecordingSourceInput, + pub channel_id: Option, + pub channel_name: Option, + pub programme_title: String, + pub programme_start: i64, + pub programme_end: i64, + pub padding: PaddingBounds, + pub episode: Option, +} + +/// Build a `RecordingFormPrefill` from an EPG programme and a +/// channel id / name. The EPG view's Record action uses this +/// helper; the backend revalidates the window when the request arrives. +/// +/// `channel_id` and `channel_name` are optional. `episode` is also +/// optional — when the EPG payload carries episode data, the form +/// forwards it as `EpgEpisodeMetadata` so the server can use it for +/// matching. +pub fn epg_programme_to_prefill(input: EpgProgrammePrefillInput) -> RecordingFormPrefill { + let mut prefill = RecordingFormPrefill::new( + input.source, + input.programme_title, + input.programme_start, + input.programme_end, + input.padding, + ); + if let Some(id) = input.channel_id { + prefill = prefill.with_channel_id(id); + } + if let Some(name) = input.channel_name { + prefill = prefill.with_channel_name(name); + } + if let Some(ep) = input.episode { + prefill = prefill.with_epg(ep); + } + prefill +} + +/// i18n key for a conflict severity. +/// +/// Conflict previews are advisory, so they are a separate surface from +/// `RecordingError`: a conflict never blocks a submission, it only warns +/// that the recording may wait for a slot or miss its window. +pub fn severity_i18n_key(severity: &ConflictSeverity) -> &'static str { + match severity { + ConflictSeverity::NoKnownConflict => "LABEL.CONFLICT_NO_KNOWN_CONFLICT", + ConflictSeverity::PossibleCapacityWait => "LABEL.CONFLICT_POSSIBLE_CAPACITY_WAIT", + ConflictSeverity::LikelyMissedWindow => "LABEL.CONFLICT_LIKELY_MISSED_WINDOW", + } +} + +/// CSS modifier for a conflict severity. Reuses the task-status pill +/// families so a "likely to miss" badge looks like the failure states +/// elsewhere in the UI rather than inventing a third palette. +pub fn severity_modifier(severity: &ConflictSeverity) -> &'static str { + match severity { + ConflictSeverity::NoKnownConflict => "tp__conflict-badge--ok", + ConflictSeverity::PossibleCapacityWait => "tp__conflict-badge--warn", + ConflictSeverity::LikelyMissedWindow => "tp__conflict-badge--danger", + } +} + +/// Tests for the pure helpers. The component itself is exercised by +/// `trunk build` and the integration smoke. +#[cfg(test)] +mod tests { + use super::*; + + fn bounds() -> PaddingBounds { + PaddingBounds { + default_pre_roll_secs: 0, + max_pre_roll_secs: 900, + default_post_roll_secs: 0, + max_post_roll_secs: 1800, + } + } + + fn source() -> RecordingSourceInput { + RecordingSourceInput { + target_id: "default".to_string(), + virtual_id: "virt-1".to_string(), + cluster: shared::model::XtreamCluster::Live, + input_name: "input-1".to_string(), + } + } + + #[test] + fn target_name_lookup_uses_current_id_and_optional_input_source() { + let target = + |id, name: &str| shared::model::ConfigTargetDto { id, name: name.to_string(), ..Default::default() }; + let sources = shared::model::SourcesConfigDto { + sources: vec![ + shared::model::ConfigSourceDto { + inputs: vec!["input-a".to_string().into()], + targets: vec![target(11, "target-a")], + }, + shared::model::ConfigSourceDto { + inputs: vec!["input-b".to_string().into()], + targets: vec![target(12, "stable-target")], + }, + ], + ..Default::default() + }; + + assert_eq!(target_name_for_id(&sources, 12, Some("input-b")).as_deref(), Some("stable-target")); + assert!(target_name_for_id(&sources, 12, Some("input-a")).is_none()); + } + + #[test] + fn prefill_constructor_uses_provided_values() { + let prefill = prefill_from_source(source(), "Title", 1_700_000_000, 1_700_003_600, bounds()); + assert_eq!(prefill.program_title, "Title"); + assert_eq!(prefill.program_start, 1_700_000_000); + assert_eq!(prefill.program_end, 1_700_003_600); + assert_eq!(prefill.padding.max_pre_roll_secs, 900); + assert_eq!(prefill.padding.max_post_roll_secs, 1800); + assert!(prefill.channel_id.is_none()); + assert!(prefill.channel_name.is_none()); + assert!(prefill.epg.is_none()); + } + + #[test] + fn prefill_builders_store_optional_fields() { + let prefill = RecordingFormPrefill::new(source(), "Title", 1, 2, bounds()) + .with_channel_id("ch-1") + .with_channel_name("Channel One"); + assert_eq!(prefill.channel_id.as_deref(), Some("ch-1")); + assert_eq!(prefill.channel_name.as_deref(), Some("Channel One")); + } + + #[test] + fn compute_scheduled_interval_subtracts_pre_roll_and_adds_post_roll() { + let (start, end) = compute_scheduled_interval(1_700_000_000, 1_700_003_600, 60, 120); + assert_eq!(start, 1_700_000_000 - 60); + assert_eq!(end, 1_700_003_600 + 120); + } + + #[test] + fn compute_scheduled_interval_saturates_on_overflow() { + let (start, end) = compute_scheduled_interval(i64::MIN, i64::MAX, 60, 60); + // Saturating subtraction on i64::MIN should not panic and should clamp. + assert_eq!(start, i64::MIN); + assert_eq!(end, i64::MAX); + } + + #[test] + fn validate_padding_accepts_values_within_bounds() { + assert!(validate_padding(0, 0, &bounds()).is_ok()); + assert!(validate_padding(900, 1800, &bounds()).is_ok()); + } + + #[test] + fn validate_padding_rejects_pre_roll_above_max() { + let err = validate_padding(901, 0, &bounds()).unwrap_err(); + assert!(err.contains("Pre-roll")); + } + + #[test] + fn validate_padding_rejects_post_roll_above_max() { + let err = validate_padding(0, 1801, &bounds()).unwrap_err(); + assert!(err.contains("Post-roll")); + } + + #[test] + fn can_pick_shared_requires_recording_write_and_admin() { + assert!(!can_pick_shared(false, true)); + assert!(!can_pick_shared(true, false)); + assert!(can_pick_shared(true, true)); + } + + #[test] + fn visibility_to_wire_stable_strings() { + assert_eq!(visibility_to_wire(false), "private"); + assert_eq!(visibility_to_wire(true), "shared"); + } + + #[test] + fn render_filename_preview_is_derived_from_prefill() { + let prefill = RecordingFormPrefill::new(source(), "My Title", 1_700_000_000, 1_700_003_600, bounds()) + .with_channel_name("Channel-1"); + let preview = render_filename_preview(&prefill, 0, 0); + assert!(preview.starts_with("Channel-1_My_Title_")); + assert!(preview.ends_with(".ts")); + } + + #[test] + fn render_filename_preview_falls_back_when_title_blank() { + let prefill = RecordingFormPrefill::new(source(), " ", 1_700_000_000, 1_700_003_600, bounds()) + .with_channel_name("Channel-1"); + let preview = render_filename_preview(&prefill, 0, 0); + assert!(preview.contains("program")); + } + + #[test] + fn render_filename_preview_replaces_unsafe_chars() { + let prefill = + RecordingFormPrefill::new(source(), "Title/With:Bad?Chars", 1_700_000_000, 1_700_003_600, bounds()) + .with_channel_name("ch"); + let preview = render_filename_preview(&prefill, 0, 0); + assert!(preview.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')); + } + + #[test] + fn build_request_carries_source_padding_and_visibility() { + let prefill = RecordingFormPrefill::new(source(), "Title", 100, 200, bounds()); + let request = build_request(&prefill, 60, 30, false, None, None); + assert_eq!(request.source.target_id, "default"); + assert_eq!(request.source.virtual_id, "virt-1"); + assert_eq!(request.source.input_name, "input-1"); + assert_eq!(request.pre_roll_secs, 60); + assert_eq!(request.post_roll_secs, 30); + assert_eq!(request.visibility, "private"); + } + + #[test] + fn build_request_with_shared_visibility() { + let prefill = RecordingFormPrefill::new(source(), "Title", 100, 200, bounds()); + let request = build_request(&prefill, 0, 0, true, None, None); + assert_eq!(request.visibility, "shared"); + } + + #[test] + fn build_request_does_not_silently_truncate_forged_padding() { + // Even if the caller somehow passes a value above the bounds, + // the request is built but the server is authoritative. The + // frontend's padding input enforces the bound; this test + // documents that the helper does not silently truncate. + let prefill = RecordingFormPrefill::new(source(), "Title", 100, 200, bounds()); + let request = build_request(&prefill, 9999, 9999, false, None, None); + assert_eq!(request.pre_roll_secs, 9999); + assert_eq!(request.post_roll_secs, 9999); + } + + #[test] + fn build_request_emits_override_start_and_duration() { + let prefill = RecordingFormPrefill::new(source(), "Title", 100, 200, bounds()); + let request = build_request(&prefill, 0, 0, false, Some(500), Some(15)); + assert_eq!(request.program_start, 500); + assert_eq!(request.program_end, 500 + 15 * 60); + } + + #[test] + fn build_request_falls_back_to_prefill_when_overrides_are_none() { + let prefill = RecordingFormPrefill::new(source(), "Title", 100, 200, bounds()); + let request = build_request(&prefill, 0, 0, false, None, None); + assert_eq!(request.program_start, 100); + assert_eq!(request.program_end, 200); + } + + #[test] + fn build_request_partial_override_falls_back_to_prefill() { + let prefill = RecordingFormPrefill::new(source(), "Title", 100, 200, bounds()); + // Only start provided — duration falls back; end reverts to prefill end. + let request = build_request(&prefill, 0, 0, false, Some(500), None); + assert_eq!(request.program_start, 500); + assert_eq!(request.program_end, 200); + } + + #[test] + fn build_request_saturates_when_start_plus_duration_would_overflow() { + let prefill = RecordingFormPrefill::new(source(), "Title", 100, 200, bounds()); + let request = build_request(&prefill, 0, 0, false, Some(i64::MAX), Some(u64::MAX)); + // Must not panic. + assert!(request.program_end >= request.program_start); + } + + #[test] + fn has_recording_write_respects_permission_set() { + let perms: shared::model::permission::PermissionSet = Permission::RecordingWrite.into(); + assert!(has_recording_write(&perms)); + let none = shared::model::permission::PermissionSet::new(); + assert!(!has_recording_write(&none)); + } + + #[test] + fn epg_programme_to_prefill_passes_through_source_and_padding() { + let prefill = epg_programme_to_prefill(EpgProgrammePrefillInput { + source: source(), + channel_id: Some("ch-1".into()), + channel_name: Some("Channel 1".into()), + programme_title: "Programme".to_string(), + programme_start: 1_700_000_000, + programme_end: 1_700_003_600, + padding: bounds(), + episode: None, + }); + assert_eq!(prefill.source.target_id, "default"); + assert_eq!(prefill.channel_id.as_deref(), Some("ch-1")); + assert_eq!(prefill.channel_name.as_deref(), Some("Channel 1")); + assert_eq!(prefill.program_title, "Programme"); + assert_eq!(prefill.program_start, 1_700_000_000); + assert_eq!(prefill.program_end, 1_700_003_600); + assert!(prefill.epg.is_none()); + } + + #[test] + fn epg_programme_to_prefill_includes_episode_when_provided() { + let episode = EpgEpisodeMetadata::default(); + let prefill = epg_programme_to_prefill(EpgProgrammePrefillInput { + source: source(), + channel_id: None, + channel_name: None, + programme_title: "Programme".to_string(), + programme_start: 1_700_000_000, + programme_end: 1_700_003_600, + padding: bounds(), + episode: Some(episode), + }); + assert!(prefill.epg.is_some()); + assert!(prefill.channel_id.is_none()); + assert!(prefill.channel_name.is_none()); + } + + #[test] + fn every_severity_has_a_distinct_key_and_modifier() { + // The old mapper took a wire *string* and had a catch-all arm, so + // a renamed severity silently rendered "unknown". Matching on the + // typed enum makes a new variant a compile error instead. + let all = [ + ConflictSeverity::NoKnownConflict, + ConflictSeverity::PossibleCapacityWait, + ConflictSeverity::LikelyMissedWindow, + ]; + let mut keys: Vec<&str> = all.iter().map(severity_i18n_key).collect(); + let mut modifiers: Vec<&str> = all.iter().map(severity_modifier).collect(); + let total = all.len(); + keys.sort_unstable(); + keys.dedup(); + modifiers.sort_unstable(); + modifiers.dedup(); + assert_eq!(keys.len(), total, "two severities share an i18n key"); + assert_eq!(modifiers.len(), total, "two severities share a colour"); + } + + #[test] + fn severity_deserializes_from_the_wire_names() { + for (wire, expected) in [ + ("no_known_conflict", ConflictSeverity::NoKnownConflict), + ("possible_capacity_wait", ConflictSeverity::PossibleCapacityWait), + ("likely_missed_window", ConflictSeverity::LikelyMissedWindow), + ] { + let parsed: ConflictSeverity = serde_json::from_str(&format!("\"{wire}\"")).expect("severity deserializes"); + assert_eq!(parsed, expected); + } + } +} diff --git a/frontend/src/app/components/recording/recording_library_view.rs b/frontend/src/app/components/recording/recording_library_view.rs new file mode 100644 index 000000000..6db620d3a --- /dev/null +++ b/frontend/src/app/components/recording/recording_library_view.rs @@ -0,0 +1,631 @@ +//! DVR library + quota view. +//! +//! The view keeps recordings separate from other media, displays private and +//! shared quota, and gates delete/edit controls by `recording.write` plus the +//! per-task ownership policy. + +use super::recording_edit_view::{EditingTaskId, RecordingEditView}; +use crate::{ + app::components::{task_status_badge::TaskStatusBadge, text_button::TextButton, Table, TableDefinition}, + hooks::use_service_context, + i18n::{use_translation, YewI18n}, + model::{DialogResult, EventMessage}, + services::{DialogService, RecordingError, RecordingQuota, RecordingService, RecordingTaskResponse}, + utils::format_bytes, +}; +use shared::model::{ + permission::Permission, + recording::{RecordingOwner, RecordingVisibility}, + web_socket::ProtocolMessage, + SortOrder, TransferStatusDto, UserId, +}; +use std::rc::Rc; +use yew::prelude::*; + +/// Permission gate: should the DVR navigation entry show? +/// True when the principal has `recording.read`. +#[allow(dead_code)] +pub fn can_show_dvr_nav(has_recording_read: bool) -> bool { has_recording_read } + +/// Permission gate: can this principal edit/delete the given +/// task? Real users may edit their own private tasks with +/// `recording.write`. Administrators may edit/delete any +/// visible task (private, shared, or `LegacyAdmin`). +/// +/// `is_admin_role` is true when the principal's roles include +/// the built-in administrator role. `is_owner` is true when +/// the principal is the immutable `UserId` owner of a private +/// task. +pub fn can_mutate_task(has_recording_write: bool, is_admin_role: bool, is_owner: bool) -> bool { + if !has_recording_write { + return false; + } + is_admin_role || is_owner +} + +/// A task is visible in the recording library unless it is marked +/// `Deleting` (read via the DTO's `deleting_previous_state` flag) +/// or has no recording metadata — i.e. it is a generic download +/// rather than a recording. +pub fn is_visible_recording_task( + owner: Option<&RecordingOwner>, + visibility: Option<&RecordingVisibility>, + deleting_previous_state: bool, +) -> bool { + if deleting_previous_state { + return false; + } + owner.is_some() && visibility.is_some() +} + +/// Format a byte count for the human-readable quota display. +/// Returns a human-readable size, and `unlimited` for `None`, so the +/// view does not need to special-case an absent configured limit. +pub fn quota_line(used: u64, limit: Option) -> String { + match limit { + Some(limit) => format!("{} / {}", format_bytes(used), format_bytes(limit)), + None => format!("{} (unlimited)", format_bytes(used)), + } +} + +/// Bytes transferred so far, with the total and a percentage when the +/// total is known. The recording rows used to show no progress at all, +/// so an in-flight recording looked identical to a scheduled one. +pub fn task_progress(task: &RecordingTaskResponse) -> String { + match task.total_bytes { + Some(total) if total > 0 => { + let percent = (task.downloaded_bytes.saturating_mul(100)) / total; + format!("{} / {} ({percent}%)", format_bytes(task.downloaded_bytes), format_bytes(total)) + } + // A live recording has no known total — the duration is the + // bound, not a content length — so show what has landed so far. + _ if task.downloaded_bytes > 0 => format_bytes(task.downloaded_bytes), + _ => "—".to_string(), + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum LibraryColumn { + Channel, + Title, + Schedule, + Status, + Progress, + Visibility, + Actions, +} + +const HEADERS: &[&str] = &[ + "LABEL.RECORDING_COLUMN_CHANNEL", + "LABEL.RECORDING_COLUMN_TITLE", + "LABEL.RECORDING_COLUMN_SCHEDULE", + "LABEL.RECORDING_COLUMN_STATUS", + "LABEL.RECORDING_COLUMN_PROGRESS", + "LABEL.RECORDING_COLUMN_VISIBILITY", + "LABEL.RECORDING_COLUMN_ACTIONS", +]; + +/// Column index → column. One place to change when a column moves; +/// previously the mapping was written out three times (render, sort +/// predicate, and the sortable check) and could drift. +fn column_at(index: usize) -> LibraryColumn { + match index { + 0 => LibraryColumn::Channel, + 1 => LibraryColumn::Title, + 2 => LibraryColumn::Schedule, + 3 => LibraryColumn::Status, + 4 => LibraryColumn::Progress, + 5 => LibraryColumn::Visibility, + _ => LibraryColumn::Actions, + } +} + +fn task_channel(task: &RecordingTaskResponse) -> String { + task.recording.as_ref().and_then(|r| r.channel_name.clone()).unwrap_or_else(|| "—".to_string()) +} + +fn task_title(task: &RecordingTaskResponse) -> String { + task.recording.as_ref().and_then(|r| r.program_title.clone()).unwrap_or_else(|| task.title.clone()) +} + +fn task_schedule(task: &RecordingTaskResponse) -> String { + let start = task.recording.as_ref().and_then(|r| r.program_start); + let end = task.recording.as_ref().and_then(|r| r.program_end); + match (start, end) { + (Some(s), Some(e)) => format!("{} – {}", format_ts(s), format_ts(e)), + _ => "—".to_string(), + } +} + +fn format_ts(ts: i64) -> String { + use chrono::{TimeZone, Utc}; + Utc.timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +/// i18n key for a task's visibility, or `None` for a task with no +/// recording metadata. +fn task_visibility_key(task: &RecordingTaskResponse) -> Option<&'static str> { + match task.recording.as_ref().map(|r| &r.visibility) { + Some(RecordingVisibility::Shared) => Some("LABEL.RECORDING_VISIBILITY_SHARED"), + Some(RecordingVisibility::Private) => Some("LABEL.RECORDING_VISIBILITY_PRIVATE"), + None => None, + } +} + +fn task_visibility(translate: &YewI18n, task: &RecordingTaskResponse) -> String { + task_visibility_key(task).map_or_else(|| "—".to_string(), |key| translate.t(key)) +} + +/// Sort key for the status column. Ordering follows the lifecycle +/// (`TransferStatusDto`'s own `Ord`) rather than the localized text, so +/// the order does not change with the UI language. +fn task_status_order(task: &RecordingTaskResponse) -> &TransferStatusDto { &task.status } + +#[allow(dead_code)] +fn compare_tasks( + a: &Rc, + b: &Rc, + col: LibraryColumn, +) -> std::cmp::Ordering { + match col { + LibraryColumn::Channel => task_channel(a).cmp(&task_channel(b)), + LibraryColumn::Title => task_title(a).cmp(&task_title(b)), + LibraryColumn::Schedule => task_schedule(a).cmp(&task_schedule(b)), + LibraryColumn::Status => task_status_order(a).cmp(task_status_order(b)), + LibraryColumn::Progress => a.downloaded_bytes.cmp(&b.downloaded_bytes), + LibraryColumn::Visibility => task_visibility_key(a).cmp(&task_visibility_key(b)), + LibraryColumn::Actions => std::cmp::Ordering::Equal, + } +} + +fn is_sortable_col(col: LibraryColumn) -> bool { !matches!(col, LibraryColumn::Actions) } + +/// Translate a service error for display. +/// +/// Every failure path in this view used to render `format!("Cancel +/// failed: {}", e)` — untranslated English with a raw wire code +/// appended. The code still reaches the browser console; the user sees +/// a sentence in their own language. +fn error_message(translate: &YewI18n, error: &RecordingError) -> String { translate.t(error.i18n_key()) } + +#[function_component(RecordingLibraryView)] +pub fn recording_library_view() -> Html { + let services = use_service_context(); + let dialog = use_context::().expect("Dialog service not found"); + let translate = use_translation(); + + let has_recordings_read = services.auth.has_permission(Permission::RecordingRead); + let has_recordings_write = services.auth.has_permission(Permission::RecordingWrite); + let is_admin = services.auth.is_admin(); + + let tasks = use_state(|| Rc::new(Vec::::new())); + let quota = use_state(|| None::); + let editing_task_id = use_state(|| Rc::new(None::)); + // Revision of the snapshot currently rendered, so an out-of-order + // delivery cannot replace newer data with older data. + let last_revision = use_state(|| None::); + // Set when the socket reports an actionable refusal. Distinguishes an + // empty library from an unusable one. + let unavailable = use_state(|| None::); + let translate_for_events = translate.clone(); + + // Subscribe to WS-driven updates. Backend broadcasts RecordingChanged + // after every mutation; each session's WS handler then re-runs the + // per-session filtered snapshot and pushes it back. Live, no polling. + { + let tasks = tasks.clone(); + let last_revision = last_revision.clone(); + let unavailable = unavailable.clone(); + let svc = services.clone(); + use_effect_with((), move |_| { + let toastr = svc.toastr.clone(); + let translate = translate_for_events.clone(); + let sid = svc.event.subscribe(move |msg| { + // The socket refused for an actionable reason — a stale + // token or a server-side DVR switch-off. Both used to + // arrive as an empty task list, so the user stared at an + // empty library with nothing to act on. + if let EventMessage::RecordingUnavailable { code } = &msg { + let error = RecordingError::from_code(code); + log::warn!("recording socket unavailable: {code}"); + toastr.error(error_message(&translate, &error)); + unavailable.set(Some(error)); + return; + } + if let EventMessage::RecordingSnapshot { revision, tasks: incoming } = msg { + unavailable.set(None); + // The revision guard exists for ordering, not for + // completeness: `RecordingSnapshot` is a *full* list, + // so a snapshot that skips revisions is still current + // and needs no re-request. What it must not do is + // overwrite newer data — two events racing through the + // socket would otherwise leave the older list on + // screen until the next mutation. + // + // A gap does matter the moment the backend starts + // sending incremental changes; it is logged so that + // change has a hook to build on. + if let Some(previous) = *last_revision { + if revision < previous { + return; + } + if revision > previous.saturating_add(1) { + log::debug!("recording snapshot skipped revisions {previous} -> {revision}"); + } + } + last_revision.set(Some(revision)); + tasks.set(Rc::new(incoming.iter().map(|t| RecordingTaskResponse::from(t.clone())).collect())); + } + }); + // On WS connect, ask the backend for the current snapshot. + let _ = svc.websocket.send_message(ProtocolMessage::RecordingSnapshotRequest); + move || svc.event.unsubscribe(sid) + }); + } + + // Mount: fetch initial snapshot + quota. + { + let tasks = tasks.clone(); + let quota = quota.clone(); + let last_revision = last_revision.clone(); + use_effect_with((), move |_| { + wasm_bindgen_futures::spawn_local(async move { + // One service instance: each `new()` builds its own HTTP + // client, and two were being constructed for two calls. + let service = RecordingService::new(); + if let Ok(snapshot) = service.list_tasks().await { + last_revision.set(Some(snapshot.revision)); + tasks.set(Rc::new(snapshot.tasks)); + } + if let Ok(q) = service.get_quota().await { + quota.set(Some(q)); + } + }); + || {} + }); + } + + // Recomputed only when the task list actually changes, not on every + // render: the filter clones an owner id and allocates an `Rc` per row. + let filtered = use_memo((*tasks).clone(), |tasks| { + tasks + .iter() + .filter(|t| { + let rec = t.recording.as_ref(); + let owner = rec.and_then(|r| r.owner_id.clone().map(RecordingOwner::User)); + is_visible_recording_task(owner.as_ref(), rec.map(|r| &r.visibility), false) + }) + .cloned() + .map(Rc::new) + .collect::>>() + }); + + let headers: Vec = HEADERS.iter().map(|h| translate.t(h)).collect(); + let translate_for_render_actions = translate.clone(); + let translate_for_quota = translate.clone(); + + let table_items = Rc::new((*filtered).clone()); + let is_empty = table_items.is_empty(); + + let render_header = Callback::from(move |col: usize| { + let col_text = headers.get(col).cloned().unwrap_or_default(); + html! { <>{ col_text } } + }); + + let render_data = { + let svc = services.clone(); + let translate = translate_for_render_actions; + let editing_for_actions = editing_task_id.clone(); + Callback::from(move |(col, _idx, task): (usize, usize, Rc)| { + match column_at(col) { + LibraryColumn::Channel => html! { <>{ task_channel(&task) } }, + LibraryColumn::Title => html! { <>{ task_title(&task) } }, + LibraryColumn::Schedule => html! { <>{ task_schedule(&task) } }, + LibraryColumn::Status => html! { + + }, + LibraryColumn::Progress => html! { { task_progress(&task) } }, + LibraryColumn::Visibility => html! { <>{ task_visibility(&translate, &task) } }, + LibraryColumn::Actions => { + let is_owner = { + let current_user = UserId::from(services.auth.get_username().as_str()); + task.recording + .as_ref() + .and_then(|r| r.owner_id.as_ref()) + .map(|o| o == ¤t_user) + .unwrap_or(false) + }; + let can_mutate = can_mutate_task(has_recordings_write, is_admin, is_owner); + if !can_mutate { + return html! { <> }; + } + let on_edit_click = { + let id_clone = task.id.clone(); + let editing_task_id = editing_for_actions.clone(); + Callback::from(move |_: String| { + editing_task_id.set(Rc::new(Some(id_clone.clone()))); + }) + }; + let id_for_cancel = task.id.clone(); + let svc_for_cancel = svc.clone(); + let translate_for_cancel = translate.clone(); + let on_cancel_click = Callback::from(move |_: String| { + let id = id_for_cancel.clone(); + let svc = svc_for_cancel.clone(); + let translate = translate_for_cancel.clone(); + wasm_bindgen_futures::spawn_local(async move { + match RecordingService::new().cancel_task(&id).await { + Ok(()) => svc.toastr.success(translate.t("MESSAGES.RECORDING.TASK_CANCELLED")), + Err(error) => { + // The wire code stays in the console for + // support; the user gets a sentence. + log::warn!("recording cancel failed: {error}"); + svc.toastr.error(error_message(&translate, &error)); + } + } + }); + }); + let id_for_delete = task.id.clone(); + let svc_for_delete = svc.clone(); + let dialog_for_delete = dialog.clone(); + let translate_for_delete = translate.clone(); + let on_delete_click = Callback::from(move |_: String| { + let id = id_for_delete.clone(); + let svc = svc_for_delete.clone(); + let dialog = dialog_for_delete.clone(); + let translate = translate_for_delete.clone(); + wasm_bindgen_futures::spawn_local(async move { + let prompt = translate.t("LABEL.RECORDING_FORM_DELETE_CONFIRM"); + if dialog.confirm(&prompt).await != DialogResult::Ok { + return; + } + match RecordingService::new().delete_task(&id).await { + Ok(()) => svc.toastr.success(translate.t("MESSAGES.RECORDING.TASK_DELETED")), + Err(error) => { + log::warn!("recording delete failed: {error}"); + svc.toastr.error(error_message(&translate, &error)); + } + } + }); + }); + let edit_label = translate.t("LABEL.RECORDING_ACTION_EDIT"); + let cancel_label = translate.t("LABEL.RECORDING_ACTION_CANCEL"); + let delete_label = translate.t("LABEL.RECORDING_ACTION_DELETE"); + // Row actions carry the recording title in their + // accessible name: nine identical "Delete" buttons in a + // column are indistinguishable to a screen reader. + let row_title = task_title(&task); + html! { +
+ + + +
+ } + } + } + }) + }; + + let is_sortable = Callback::from(|col: usize| is_sortable_col(column_at(col))); + + let on_sort = Callback::from(|_: Option<(usize, SortOrder)>| {}); + + let table_def = Rc::new(TableDefinition:: { + items: Some(table_items.clone()), + num_cols: HEADERS.len(), + is_sortable, + render_header_cell: render_header, + render_data_cell: render_data, + on_sort, + }); + + let quota_view = (*quota).as_ref().map(|q| { + let translate = translate_for_quota.clone(); + let private = quota_line(q.private_used_bytes, q.private_limit_bytes); + let shared = quota_line(q.shared_used_bytes, q.shared_limit_bytes); + html! { +
+ { format!("{}: {}", translate.t("LABEL.RECORDING_QUOTA_PRIVATE"), private) } + { format!("{}: {}", translate.t("LABEL.RECORDING_QUOTA_SHARED"), shared) } +
+ } + }); + + let _ = has_recordings_read; // permission gate is via home.rs; kept for symmetry + + let edit_view: Html = if editing_task_id.is_some() { + let on_done = { + let editing_task_id = editing_task_id.clone(); + Callback::from(move |_: ()| { + editing_task_id.set(Rc::new(None)); + }) + }; + html! { + context={EditingTaskId((*editing_task_id).clone())}> + + // on_done is consumed inside the wrapper; the cancel button + // emits via TaskEditForm's on_done prop. We attach it here so + // that an explicit cancel clears the selection. +
+ +
+
> + } + } else { + html! { <> } + }; + + html! { +
+
+
+
+

{ translate.t("LABEL.RECORDING_LIBRARY") }

+ { quota_view.unwrap_or_else(|| html! { <> }) } +
+
+ if let Some(error) = unavailable.as_ref() { + // Not an empty library — an unusable one. Say + // which, so the user knows whether to wait, + // reload, or ask an administrator. + + } else if is_empty { + // An empty table reads as "something failed". + // Say what the list is for and where to start. +

+ { translate.t("MESSAGES.RECORDING.EMPTY_LIBRARY") } +

+ } else { + definition={table_def} /> + } + { edit_view } +
+
+
+
+ } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::UserId; + + #[test] + fn dvr_nav_only_with_recording_read() { + assert!(!can_show_dvr_nav(false)); + assert!(can_show_dvr_nav(true)); + } + + #[test] + fn mutate_requires_write_and_owner_or_admin() { + assert!(!can_mutate_task(false, true, true)); + assert!(can_mutate_task(true, true, false)); + assert!(can_mutate_task(true, false, true)); + assert!(!can_mutate_task(true, false, false)); + } + + #[test] + fn visible_recording_requires_owner_and_visibility() { + let owner = RecordingOwner::User(UserId::from("web:alice")); + let visibility = RecordingVisibility::Private; + assert!(is_visible_recording_task(Some(&owner), Some(&visibility), false)); + assert!(!is_visible_recording_task(Some(&owner), Some(&visibility), true)); + assert!(!is_visible_recording_task(None, None, false)); + } + + #[test] + fn quota_line_handles_unlimited() { + assert_eq!(quota_line(100, Some(1000)), format!("{} / {}", format_bytes(100), format_bytes(1000))); + assert_eq!(quota_line(100, None), format!("{} (unlimited)", format_bytes(100))); + } + + #[test] + fn task_progress_reports_percentage_only_with_a_known_total() { + let mut task = task_with_channel("Alpha"); + assert_eq!(task_progress(&task), "—"); + + // A live recording has no content length; show what has landed. + Rc::get_mut(&mut task).expect("unique").downloaded_bytes = 2048; + let progress = task_progress(&task); + assert!(!progress.contains('%'), "{progress}"); + assert!(progress.contains(&format_bytes(2048)), "{progress}"); + + Rc::get_mut(&mut task).expect("unique").total_bytes = Some(4096); + assert!(task_progress(&task).contains("(50%)"), "{}", task_progress(&task)); + } + + #[test] + fn task_progress_does_not_divide_by_zero() { + let mut task = task_with_channel("Alpha"); + { + let task = Rc::get_mut(&mut task).expect("unique"); + task.total_bytes = Some(0); + task.downloaded_bytes = 10; + } + assert_eq!(task_progress(&task), format_bytes(10)); + } + + #[test] + fn status_sorts_by_lifecycle_not_by_localized_text() { + // "Completed" sorts before "Failed" alphabetically in English but + // the lifecycle order is what must hold, in every language. + let mut running = task_with_channel("a"); + Rc::get_mut(&mut running).expect("unique").status = TransferStatusDto::Running; + let mut completed = task_with_channel("a"); + Rc::get_mut(&mut completed).expect("unique").status = TransferStatusDto::Completed; + assert_eq!(compare_tasks(&running, &completed, LibraryColumn::Status), std::cmp::Ordering::Less); + } + + #[test] + fn every_header_maps_to_a_column() { + // A header added without a matching `column_at` arm would silently + // render as the actions column. + assert_eq!(HEADERS.len(), 7); + assert!(matches!(column_at(0), LibraryColumn::Channel)); + assert!(matches!(column_at(4), LibraryColumn::Progress)); + assert!(matches!(column_at(HEADERS.len() - 1), LibraryColumn::Actions)); + assert!(!is_sortable_col(column_at(HEADERS.len() - 1))); + } + + fn task_with_channel(channel: &str) -> Rc { + Rc::new(RecordingTaskResponse { + id: "1".to_string(), + title: "t".to_string(), + kind: shared::model::TaskKindDto::Recording, + priority: shared::model::TaskPriorityDto::Normal, + status: TransferStatusDto::Scheduled, + retry_attempts: 0, + downloaded_bytes: 0, + total_bytes: None, + next_retry_at: None, + scheduled_start_at: None, + duration_secs: None, + error: None, + recording: Some(shared::model::recording::RecordingTaskDto { + owner_id: None, + visibility: RecordingVisibility::Private, + channel_id: None, + channel_name: Some(channel.to_string()), + program_title: None, + program_start: None, + program_end: None, + scheduled_start: None, + scheduled_end: None, + pre_roll_secs: 0, + post_roll_secs: 0, + completed_at: None, + filename: None, + epg: None, + rule_id: None, + occurrence_key: None, + }), + }) + } + + #[test] + fn compare_tasks_sorts_by_channel() { + let a = task_with_channel("Alpha"); + let b = task_with_channel("Beta"); + assert_eq!(compare_tasks(&a, &b, LibraryColumn::Channel), std::cmp::Ordering::Less); + } +} diff --git a/frontend/src/app/components/recording/recording_rule_form.rs b/frontend/src/app/components/recording/recording_rule_form.rs new file mode 100644 index 000000000..74577ac17 --- /dev/null +++ b/frontend/src/app/components/recording/recording_rule_form.rs @@ -0,0 +1,658 @@ +//! Form to create or edit a recurring recording rule. +//! +//! Renders inside a dedicated `RecordingRuleFormView` panel. The +//! panel-based layout is the standard master-detail pattern: the +//! rule list is in `RecordingRulesView`, the form is in its own +//! panel, save/cancel switches back. Submit calls +//! `RecordingService::create_rule` or `RecordingService::edit_rule` +//! depending on whether an `existing` rule is provided. +//! +//! Uses the shared form-reducer macros (`generate_form_reducer!` + +//! `edit_field_*!`) so all typed fields share one diffing source of +//! truth. Target and input are `Select` widgets over the config +//! targets / inputs lists — the user only sees the durable target +//! and input names; the target name is the stable wire value. The target +//! dropdown is filtered to the sources that contain the selected +//! input, so picking an input narrows the available targets. + +use crate::{ + app::components::{ + config::HasFormData, select::Select, selection_first_owned, DropDownOption, DropDownSelection, TextButton, + }, + config_field_child, edit_field_bool, edit_field_number_u64, edit_field_number_u8, edit_field_text, + edit_field_text_option, generate_form_reducer, + hooks::use_service_context, + i18n::use_translation, + services::{CreateRecordingRuleRequest, EditRecordingRuleRequest, RecordingRuleSnapshot, RecordingService}, +}; +use shared::model::{ + recording_rule::{RuleBody, RuleVisibility}, + ConfigSourceDto, ConfigTargetDto, +}; +use std::rc::Rc; +use yew::prelude::*; + +/// Flat form DTO. All fields are tracked through the reducer so +/// the diffing + modification flag stay consistent. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RuleFormDto { + pub target_id: Option, + pub virtual_id: String, + pub input_name: String, + pub channel_id: Option, + /// `"weekly"` or `"new_episode"`. + pub kind: String, + // Weekly fields. + pub weekday: u8, + pub start_time: String, + pub duration_secs: u64, + pub timezone: String, + // NewEpisode fields. + pub series_id: Option, + pub title_pattern: Option, + pub exclude_repeat: bool, + // Padding. + pub pre_roll: u64, + pub post_roll: u64, + // Visibility + enabled. + pub visibility: String, + pub enabled: bool, +} + +generate_form_reducer!( + state: RuleFormState { form: RuleFormDto }, + action_name: RuleFormAction, + fields { + TargetId => target_id: Option, + VirtualId => virtual_id: String, + InputName => input_name: String, + ChannelId => channel_id: Option, + Kind => kind: String, + Weekday => weekday: u8, + StartTime => start_time: String, + DurationSecs => duration_secs: u64, + Timezone => timezone: String, + SeriesId => series_id: Option, + TitlePattern => title_pattern: Option, + ExcludeRepeat => exclude_repeat: bool, + PreRoll => pre_roll: u64, + PostRoll => post_roll: u64, + Visibility => visibility: String, + Enabled => enabled: bool, + } +); + +fn dto_from_existing(existing: &RecordingRuleSnapshot) -> RuleFormDto { + let mut dto = RuleFormDto { + target_id: Some(existing.source.target_id.clone()), + virtual_id: existing.source.virtual_id.clone(), + input_name: existing.source.input_name.clone(), + channel_id: existing.channel_id.clone(), + kind: match &existing.body { + RuleBody::NewEpisode { .. } => "new_episode".to_string(), + RuleBody::WeeklyTimeslot { .. } => "weekly".to_string(), + }, + pre_roll: existing.pre_roll_secs, + post_roll: existing.post_roll_secs, + visibility: match existing.visibility { + RuleVisibility::Shared => "shared".to_string(), + RuleVisibility::Private => "private".to_string(), + }, + enabled: existing.enabled, + ..Default::default() + }; + match &existing.body { + RuleBody::NewEpisode { series_id, title_pattern, exclude_repeat } => { + dto.series_id = series_id.clone(); + dto.title_pattern = title_pattern.clone(); + dto.exclude_repeat = *exclude_repeat; + } + RuleBody::WeeklyTimeslot { weekday, local_start_time, duration_secs, timezone } => { + dto.weekday = *weekday; + dto.start_time = local_start_time.clone(); + dto.duration_secs = *duration_secs; + dto.timezone = timezone.clone(); + } + } + dto +} + +fn dto_defaults() -> RuleFormDto { + RuleFormDto { + target_id: None, + virtual_id: String::new(), + input_name: String::new(), + channel_id: None, + kind: "weekly".to_string(), + weekday: 1, + start_time: "20:00".to_string(), + duration_secs: 3600, + timezone: "UTC".to_string(), + series_id: None, + title_pattern: None, + exclude_repeat: true, + pre_roll: 60, + post_roll: 120, + visibility: "private".to_string(), + enabled: true, + } +} + +/// Build a `CreateRecordingRuleRequest` from the form's collected +/// state. Pure — unit-testable. +pub fn build_create_request(form: &RuleFormDto, visibility: RuleVisibility) -> Option { + let channel_id_opt = form.channel_id.clone().filter(|s| !s.trim().is_empty()); + let body = if form.kind == "new_episode" { + RuleBody::NewEpisode { + series_id: form.series_id.clone().filter(|s| !s.trim().is_empty()), + title_pattern: form.title_pattern.clone().filter(|s| !s.trim().is_empty()), + exclude_repeat: form.exclude_repeat, + } + } else { + RuleBody::WeeklyTimeslot { + weekday: form.weekday, + local_start_time: form.start_time.clone(), + duration_secs: form.duration_secs, + timezone: form.timezone.clone(), + } + }; + Some(CreateRecordingRuleRequest { + target_id: form.target_id.clone()?, + virtual_id: form.virtual_id.clone(), + input_name: form.input_name.clone(), + body, + channel_id: channel_id_opt, + pre_roll_secs: form.pre_roll, + post_roll_secs: form.post_roll, + visibility, + }) +} + +fn show_source_controls(existing: bool) -> bool { !existing } + +fn channel_id_patch(existing: Option<&str>, current: Option<&str>) -> (Option, bool) { + let current = current.filter(|value| !value.trim().is_empty()); + match (existing, current) { + (Some(old), Some(new)) if old == new => (None, false), + (Some(_), None) => (None, true), + (None, None) => (None, false), + (_, Some(new)) => (Some(new.to_string()), false), + } +} + +#[derive(Clone, PartialEq, Properties)] +pub struct RuleFormProps { + pub existing: Option, + pub sources: Rc>>, + pub on_done: Option>, +} + +/// Filter the source list to those that contain the given input +/// name. Returns the union of their `targets`. +fn targets_for_input(sources: &[Rc], input_name: &str) -> Vec> { + if input_name.trim().is_empty() { + return sources.iter().flat_map(|s| s.targets.iter().cloned()).map(Rc::new).collect(); + } + sources + .iter() + .filter(|s| s.inputs.iter().any(|i| i.as_ref() == input_name)) + .flat_map(|s| s.targets.iter().cloned()) + .map(Rc::new) + .collect() +} + +/// Collect every input name across all sources, de-duplicated. +fn all_input_names(sources: &[Rc]) -> Vec { + let mut seen: Vec = Vec::new(); + for s in sources { + for name in &s.inputs { + let name_str: String = name.to_string(); + if !seen.contains(&name_str) { + seen.push(name_str); + } + } + } + seen +} + +#[function_component(RecordingRuleForm)] +pub fn recording_rule_form(props: &RuleFormProps) -> Html { + let services = use_service_context(); + let translate = use_translation(); + + let initial = props.existing.as_ref().map(dto_from_existing).unwrap_or_else(dto_defaults); + let form_state: UseReducerHandle = use_reducer(|| RuleFormState { form: initial, modified: false }); + + let inputs = use_memo(props.sources.clone(), |sources| all_input_names(sources)); + + let filtered_targets = + use_memo((props.sources.clone(), form_state.form.input_name.clone()), |(sources, input_name)| { + targets_for_input(sources, input_name) + }); + + let input_options = use_memo((inputs.clone(), form_state.form.input_name.clone()), |(inputs, selected)| { + inputs + .iter() + .map(|name_str| DropDownOption { + id: name_str.clone(), + label: html! { name_str.clone() }, + selected: name_str == selected, + }) + .collect::>() + }); + + let target_options = + use_memo((filtered_targets.clone(), form_state.form.target_id.clone()), |(targets, selected)| { + targets + .iter() + .map(|t| { + let name_str: String = t.name.to_string(); + DropDownOption { + id: name_str.clone(), + label: html! { name_str.clone() }, + selected: selected.as_deref() == Some(name_str.as_str()), + } + }) + .collect::>() + }); + + let kind_options = use_memo(form_state.form.kind.clone(), |kind| { + vec![ + DropDownOption { + id: "weekly".to_string(), + label: html! { translate.t("LABEL.RECORDING_RULE_KIND_WEEKLY") }, + selected: kind == "weekly", + }, + DropDownOption { + id: "new_episode".to_string(), + label: html! { translate.t("LABEL.RECORDING_RULE_KIND_NEW_EPISODE") }, + selected: kind == "new_episode", + }, + ] + }); + + let visibility_options = use_memo(form_state.form.visibility.clone(), |vis| { + vec![ + DropDownOption { id: "private".to_string(), label: html! { "Private" }, selected: vis == "private" }, + DropDownOption { id: "shared".to_string(), label: html! { "Shared" }, selected: vis == "shared" }, + ] + }); + + let on_cancel = { + let on_done = props.on_done.clone(); + Callback::from(move |_: String| { + if let Some(cb) = on_done.clone() { + cb.emit(()); + } + }) + }; + + let on_save = { + let form_state = form_state.clone(); + let existing_id = props.existing.as_ref().map(|r| r.id.clone()); + let existing_channel_id = props.existing.as_ref().and_then(|rule| rule.channel_id.clone()); + let services = services.clone(); + let on_done = props.on_done.clone(); + let translate = translate.clone(); + Callback::from(move |_: String| { + let form = form_state.data().clone(); + if existing_id.is_none() && form.target_id.is_none() { + services.toastr.error(translate.t("MESSAGES.RECORDING.NO_TARGET")); + return; + } + if existing_id.is_none() && form.input_name.trim().is_empty() { + services.toastr.error(translate.t("MESSAGES.RECORDING.NO_INPUT")); + return; + } + let visibility = match form.visibility.as_str() { + "shared" => RuleVisibility::Shared, + _ => RuleVisibility::Private, + }; + let rule_body = if form.kind == "new_episode" { + RuleBody::NewEpisode { + series_id: form.series_id.clone().filter(|s| !s.trim().is_empty()), + title_pattern: form.title_pattern.clone().filter(|s| !s.trim().is_empty()), + exclude_repeat: form.exclude_repeat, + } + } else { + RuleBody::WeeklyTimeslot { + weekday: form.weekday, + local_start_time: form.start_time.clone(), + duration_secs: form.duration_secs, + timezone: form.timezone.clone(), + } + }; + let svc = services.clone(); + let on_done = on_done.clone(); + let existing_id_for_async = existing_id.clone(); + let existing_channel_id = existing_channel_id.clone(); + let translate_for_save = translate.clone(); + wasm_bindgen_futures::spawn_local(async move { + let res = if let Some(id) = existing_id_for_async { + let (channel_id, clear_channel_id) = + channel_id_patch(existing_channel_id.as_deref(), form.channel_id.as_deref()); + let request = EditRecordingRuleRequest { + body: Some(rule_body), + channel_id, + clear_channel_id, + pre_roll_secs: Some(form.pre_roll), + post_roll_secs: Some(form.post_roll), + visibility: Some(visibility), + enabled: Some(form.enabled), + }; + RecordingService::new().edit_rule(&id, request).await + } else { + let Some(request) = build_create_request(&form, visibility) else { + return; + }; + RecordingService::new().create_rule(request).await + }; + match res { + Ok(_) => { + // Covers both create and edit: this handler drives both. + svc.toastr.success(translate_for_save.t("MESSAGES.RECORDING.RULE_SAVED")); + if let Some(cb) = on_done { + cb.emit(()); + } + } + Err(error) => { + log::warn!("recording rule save failed: {error}"); + svc.toastr.error(translate_for_save.t(error.i18n_key())); + } + } + }); + }) + }; + + let kind = form_state.form.kind.clone(); + let weekly_fields = if kind == "weekly" { + html! { + <> + { edit_field_number_u8!(form_state, translate.t("LABEL.RECORDING_FORM_WEEKDAY"), weekday, RuleFormAction::Weekday) } + { edit_field_text!(form_state, translate.t("LABEL.RECORDING_FORM_START_TIME"), start_time, RuleFormAction::StartTime) } + { edit_field_number_u64!(form_state, translate.t("LABEL.RECORDING_FORM_DURATION"), duration_secs, RuleFormAction::DurationSecs) } + { edit_field_text!(form_state, translate.t("LABEL.RECORDING_FORM_TIMEZONE"), timezone, RuleFormAction::Timezone) } + + } + } else { + html! { + <> + { edit_field_text_option!(form_state, translate.t("LABEL.RECORDING_RULE_SERIES_ID"), series_id, RuleFormAction::SeriesId) } + { edit_field_text_option!(form_state, translate.t("LABEL.RECORDING_RULE_TITLE_PATTERN"), title_pattern, RuleFormAction::TitlePattern) } + { edit_field_bool!(form_state, translate.t("LABEL.RECORDING_RULE_EXCLUDE_REPEAT"), exclude_repeat, RuleFormAction::ExcludeRepeat) } + + } + }; + + let input_select = { + let form_state = form_state.clone(); + html! { + + } + }; + + let kind_select = { + let form_state = form_state.clone(); + html! { + + } + }; + + let source_fields = if show_source_controls(props.existing.is_some()) { + html! { + <> + { config_field_child!(translate.t("LABEL.RECORDING_FORM_INPUT_NAME"), "rule_input_name", { + { input_select } + }) } + { config_field_child!(translate.t("LABEL.RECORDING_FORM_TARGET_ID"), "rule_target_id", { + { target_select } + }) } + { edit_field_text!(form_state, translate.t("LABEL.RECORDING_FORM_VIRTUAL_ID"), virtual_id, RuleFormAction::VirtualId) } + + } + } else { + html! {} + }; + + html! { +
+
+ { source_fields } + { edit_field_text_option!(form_state, translate.t("LABEL.RECORDING_FORM_CHANNEL_ID"), channel_id, RuleFormAction::ChannelId) } + { config_field_child!(translate.t("LABEL.RECORDING_RULE_KIND"), "rule_kind", { + { kind_select } + }) } + { weekly_fields } + { edit_field_number_u64!(form_state, translate.t("LABEL.RECORDING_FORM_PRE_ROLL"), pre_roll, RuleFormAction::PreRoll) } + { edit_field_number_u64!(form_state, translate.t("LABEL.RECORDING_FORM_POST_ROLL"), post_roll, RuleFormAction::PostRoll) } + { config_field_child!("Visibility", "rule_visibility", { + { visibility_select } + }) } + { edit_field_bool!(form_state, translate.t("LABEL.RECORDING_FORM_ENABLED"), enabled, RuleFormAction::Enabled) } +
+
+ + +
+
+ } +} + +// The form takes sources as a prop. The home.rs panel reads the +// config context and passes the list to the form. + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_form() -> RuleFormDto { + RuleFormDto { + target_id: Some("default".to_string()), + virtual_id: "1".to_string(), + input_name: "inp".to_string(), + ..dto_defaults() + } + } + + fn mk_source(inputs: &[&str], targets: &[&str]) -> ConfigSourceDto { + ConfigSourceDto { + inputs: inputs.iter().map(|s| (*s).to_string().into()).collect(), + targets: targets + .iter() + .map(|s| ConfigTargetDto { id: 0, name: (*s).to_string(), ..Default::default() }) + .collect(), + } + } + + #[test] + fn targets_for_input_returns_empty_when_input_not_in_any_source() { + let s = mk_source(&["inp-a"], &["tgt-a"]); + let sources: Vec> = vec![Rc::new(s)]; + let result = targets_for_input(&sources, "missing"); + assert!(result.is_empty()); + } + + #[test] + fn targets_for_input_filters_to_matching_source() { + let s1 = mk_source(&["inp-a"], &["tgt-a"]); + let s2 = mk_source(&["inp-b"], &["tgt-b"]); + let sources: Vec> = vec![Rc::new(s1), Rc::new(s2)]; + let result = targets_for_input(&sources, "inp-a"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "tgt-a"); + } + + #[test] + fn targets_for_input_with_empty_input_returns_all_targets() { + let s1 = mk_source(&["inp-a"], &["tgt-a"]); + let s2 = mk_source(&["inp-b"], &["tgt-b"]); + let sources: Vec> = vec![Rc::new(s1), Rc::new(s2)]; + let result = targets_for_input(&sources, ""); + assert_eq!(result.len(), 2); + } + + #[test] + fn all_input_names_deduplicates() { + let s1 = mk_source(&["inp-a", "inp-b"], &["tgt-a"]); + let s2 = mk_source(&["inp-a", "inp-c"], &["tgt-b"]); + let sources: Vec> = vec![Rc::new(s1), Rc::new(s2)]; + let result = all_input_names(&sources); + assert_eq!(result.len(), 3); + assert!(result.contains(&"inp-a".to_string())); + assert!(result.contains(&"inp-b".to_string())); + assert!(result.contains(&"inp-c".to_string())); + } + + #[test] + fn build_create_request_new_episode_sets_three_fields_only() { + let mut f = empty_form(); + f.kind = "new_episode".to_string(); + f.series_id = Some("series-1".to_string()); + f.title_pattern = Some("Title".to_string()); + f.exclude_repeat = true; + let req = build_create_request(&f, RuleVisibility::Private).expect("selected target"); + assert_eq!(req.target_id, "default"); + assert_eq!(req.virtual_id, "1"); + assert_eq!(req.input_name, "inp"); + assert!(matches!( + req.body, + RuleBody::NewEpisode { + series_id: Some(ref series_id), + title_pattern: Some(ref title), + exclude_repeat: true, + } if series_id == "series-1" && title == "Title" + )); + } + + #[test] + fn build_create_request_weekly_sets_weekly_fields_only() { + let mut form = empty_form(); + form.weekday = 3; + let req = build_create_request(&form, RuleVisibility::Private).expect("selected target"); + assert!(matches!( + req.body, + RuleBody::WeeklyTimeslot { + weekday: 3, + ref local_start_time, + duration_secs: 3600, + ref timezone, + } if local_start_time == "20:00" && timezone == "UTC" + )); + } + + #[test] + fn build_create_request_omits_blank_channel_id() { + let mut f = empty_form(); + f.channel_id = Some(" ".to_string()); + let req = build_create_request(&f, RuleVisibility::Private).expect("selected target"); + assert!(req.channel_id.is_none()); + } + + #[test] + fn build_create_request_keeps_non_empty_channel_id() { + let mut f = empty_form(); + f.channel_id = Some("chan-1".to_string()); + let req = build_create_request(&f, RuleVisibility::Private).expect("selected target"); + assert_eq!(req.channel_id.as_deref(), Some("chan-1")); + } + + #[test] + fn existing_target_name_is_restored() { + let existing = RecordingRuleSnapshot { + id: "rule-1".to_string(), + owner_id: "web:alice".to_string(), + visibility: RuleVisibility::Private, + enabled: true, + source: shared::model::recording_rule::RuleSource::new("legacy-target", "42", "inp"), + channel_id: None, + body: RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".to_string(), + duration_secs: 3600, + timezone: "UTC".to_string(), + }, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 1, + updated_at: 1, + }; + + assert_eq!(dto_from_existing(&existing).target_id.as_deref(), Some("legacy-target")); + } + + #[test] + fn existing_rule_source_controls_are_not_editable() { + assert!(!show_source_controls(true)); + assert!(show_source_controls(false)); + } + + #[test] + fn blank_existing_channel_requests_clear_but_absent_channel_does_not() { + assert_eq!(channel_id_patch(Some("channel-1"), None), (None, true)); + assert_eq!(channel_id_patch(None, None), (None, false)); + assert_eq!(channel_id_patch(Some("channel-1"), Some("channel-1")), (None, false)); + assert_eq!(channel_id_patch(Some("channel-1"), Some("channel-2")), (Some("channel-2".to_string()), false)); + } +} diff --git a/frontend/src/app/components/recording/recording_rules_view.rs b/frontend/src/app/components/recording/recording_rules_view.rs new file mode 100644 index 000000000..3d42d9c86 --- /dev/null +++ b/frontend/src/app/components/recording/recording_rules_view.rs @@ -0,0 +1,648 @@ +//! Recurring-rule view. +//! +//! The view lists and mutates recurring rules, shows matching limitations, and +//! gates shared controls to administrators. + +use super::recording_rule_form::RecordingRuleForm; +use crate::{ + app::{ + components::{text_button::TextButton, Table, TableDefinition, ToggleSwitch}, + ConfigContext, + }, + hooks::use_service_context, + i18n::{use_translation, YewI18n}, + model::{DialogResult, EventMessage}, + services::{ + DialogService, EditRecordingRuleRequest, RecordingError, RecordingRuleResponse, RecordingRuleSnapshot, + RecordingService, + }, +}; +use shared::model::{recording_rule::RuleBody, ConfigSourceDto, SortOrder}; +use std::{cell::Cell, rc::Rc}; +use yew::prelude::*; + +/// Permission gate: may the principal see the recurring-rule +/// section at all? Any user with `recording.read` can list rules; creation +/// needs `recording.write`. +#[allow(dead_code)] +pub fn can_show_rules_section(has_recording_read: bool) -> bool { has_recording_read } + +/// Permission gate: may the principal create new rules? Owners +/// can create private rules; only administrators can create shared +/// rules. +#[allow(dead_code)] +pub fn can_create_rule(has_recording_write: bool) -> bool { has_recording_write } + +/// Permission gate: may the principal create a *shared* rule? +/// Administrators with `recording.write` only. +#[allow(dead_code)] +pub fn can_create_shared_rule(has_recording_write: bool, is_admin_role: bool) -> bool { + has_recording_write && is_admin_role +} + +/// Permission gate: may the principal edit this rule? +/// - Private rule: owner with `recording.write`. +/// - Shared rule: administrator with `recording.write`. +#[allow(dead_code)] +pub fn can_edit_rule(has_recording_write: bool, is_admin_role: bool, is_owner: bool, is_shared: bool) -> bool { + if !has_recording_write { + return false; + } + if is_shared { + is_admin_role + } else { + is_admin_role || is_owner + } +} + +/// Permission gate: may the principal delete this rule? +/// Same matrix as edit. +#[allow(dead_code)] +pub fn can_delete_rule(has_recording_write: bool, is_admin_role: bool, is_owner: bool, is_shared: bool) -> bool { + can_edit_rule(has_recording_write, is_admin_role, is_owner, is_shared) +} + +/// The delete future-policy options exposed to the user. The API +/// requires `future=retain|cancel`; the UI mirrors that with two +/// radio buttons. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum DeleteFuture { + Retain, + Cancel, +} + +#[allow(dead_code)] +impl DeleteFuture { + pub fn wire(self) -> &'static str { + match self { + Self::Retain => "retain", + Self::Cancel => "cancel", + } + } + + pub fn from_wire(value: &str) -> Option { + match value { + "retain" => Some(Self::Retain), + "cancel" => Some(Self::Cancel), + _ => None, + } + } +} + +/// A short, user-facing note for recurring-rule limitations. +/// calls out. The form's text surfaces these alongside the +/// matching-field inputs. +#[allow(dead_code)] +pub fn new_episode_limitations_text() -> &'static str { + "When the EPG does not publish a stable series id, the rule falls back to the title. \ + Title fallback may record reruns when provider metadata is incomplete." +} + +/// DST + timezone explanation for weekly rules. The form's text +/// surfaces this next to the timezone input. +#[allow(dead_code)] +pub fn weekly_timezone_hint_text() -> &'static str { + "Local wall-clock time. Daylight-saving transitions follow the timezone: \ + ambiguous times (fall-back) pick the earlier instant; nonexistent times \ + (spring-forward) advance to the next valid instant." +} + +fn recording_rule_form_key(existing: Option<&RecordingRuleSnapshot>) -> String { + existing.map_or_else(|| "new".to_string(), |rule| rule.id.clone()) +} + +/// Map a backend reconciliation error to a stable i18n key the +/// form can render. A failed request may have applied a partial +/// change on the server, so the message tells the user what +/// state the system is in. +#[allow(dead_code)] +pub fn reconciliation_error_to_i18n_key(primary: &str, secondary: &str) -> String { + format!("MESSAGES.RECORDING.PARTIAL_OPERATION/{primary}/{secondary}") +} + +const RULE_HEADERS: &[&str] = &[ + "LABEL.RECORDING_RULE_COLUMN_TARGET", + "LABEL.RECORDING_RULE_COLUMN_VISIBILITY", + "LABEL.RECORDING_RULE_COLUMN_SCHEDULE", + "LABEL.RECORDING_RULE_COLUMN_ENABLED", + "LABEL.RECORDING_COLUMN_ACTIONS", +]; + +pub fn rule_summary(rule: &RecordingRuleResponse) -> String { + let s = &rule.rule.source; + format!("{} / {} / {}", s.target_id, s.virtual_id, s.input_name) +} + +/// i18n key for a rule's visibility. The label used to be a hardcoded +/// English literal, so it stayed in English whatever the UI language. +pub fn rule_visibility_key(rule: &RecordingRuleResponse) -> &'static str { + use shared::model::recording_rule::RuleVisibility; + match rule.rule.visibility { + RuleVisibility::Shared => "LABEL.RECORDING_VISIBILITY_SHARED", + RuleVisibility::Private => "LABEL.RECORDING_VISIBILITY_PRIVATE", + } +} + +pub fn rule_visibility_label(translate: &YewI18n, rule: &RecordingRuleResponse) -> String { + translate.t(rule_visibility_key(rule)) +} + +/// The language-independent part of a weekly rule's schedule: weekday +/// number, local start time, and duration in whole minutes. Split out of +/// [`rule_schedule_label`] so it can be tested without an i18n context. +pub fn rule_weekly_schedule_text(weekday: u8, local_start_time: &str, duration_secs: u64) -> String { + format!("W{weekday} {local_start_time} ({}m)", duration_secs / 60) +} + +pub fn rule_schedule_label(translate: &YewI18n, rule: &RecordingRuleResponse) -> String { + match &rule.rule.body { + RuleBody::NewEpisode { .. } => translate.t("LABEL.RECORDING_RULE_KIND_NEW_EPISODE"), + RuleBody::WeeklyTimeslot { weekday, local_start_time, duration_secs, .. } => { + rule_weekly_schedule_text(*weekday, local_start_time, *duration_secs) + } + } +} + +/// Translate a rule-service failure for display. +fn error_message(translate: &YewI18n, error: &RecordingError) -> String { translate.t(error.i18n_key()) } + +#[function_component(RecordingRulesView)] +pub fn recording_rules_view() -> Html { + let translate = use_translation(); + let services = use_service_context(); + let dialog = use_context::(); + let rules = use_state(|| Rc::new(Vec::::new())); + let editing = use_state(|| None::); + let creating = use_state(|| false); + + { + let rules = rules.clone(); + let svc = services.clone(); + use_effect_with((), move |_| { + wasm_bindgen_futures::spawn_local(async move { + if let Ok(r) = RecordingService::new().list_rules().await { + rules.set(Rc::new(r)); + } + }); + let _ = svc; // suppress unused + || {} + }); + } + + // Live updates: the backend broadcasts `RecordingRulesChanged` + // when the rule repository mutates. Subscribe to it directly + // — no per-recording-snapshot refetch. The initial fetch above + // is what the user sees until the first mutation arrives. + { + let rules = rules.clone(); + let svc = services.clone(); + use_effect_with((), move |_| { + let sid = svc.event.subscribe(move |msg| { + if matches!(msg, EventMessage::RecordingRulesChanged) { + let rules = rules.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(r) = RecordingService::new().list_rules().await { + rules.set(Rc::new(r)); + } + }); + } + }); + move || svc.event.unsubscribe(sid) + }); + } + + let _ = EventMessage::Unauthorized; // ensure EventMessage is referenced for future WS subscription + + let on_done_callback = { + let rules = rules.clone(); + let editing = editing.clone(); + let creating = creating.clone(); + Callback::from(move |_: ()| { + editing.set(None); + creating.set(false); + let rules = rules.clone(); + wasm_bindgen_futures::spawn_local(async move { + if let Ok(r) = RecordingService::new().list_rules().await { + rules.set(Rc::new(r)); + } + }); + }) + }; + + let edit_id_click = { + let editing_outer = editing.clone(); + let rules_outer = rules.clone(); + move |id: String| { + let editing = editing_outer.clone(); + let rules = rules_outer.clone(); + Callback::from(move |_: String| { + if let Some(rule) = (*rules).iter().find(|r| r.rule.id == id).map(|r| r.rule.clone()) { + editing.set(Some(rule)); + } + }) + } + }; + + // Deleting a rule is two decisions, not one: drop the rule, and + // decide what happens to the occurrences it already scheduled. The + // backend has supported `future=retain|cancel` all along but the UI + // hardcoded `retain`, so there was no way to stop upcoming recordings + // from a deleted rule. The dialog now asks. + let delete_id_click = { + let rules_outer = rules.clone(); + let dialog_outer = dialog.clone(); + let services_outer = services.clone(); + let translate_outer = translate.clone(); + move |id: String| { + let rules = rules_outer.clone(); + let dialog = dialog_outer.clone(); + let services = services_outer.clone(); + let translate = translate_outer.clone(); + Callback::from(move |_: String| { + let Some(dialog) = dialog.clone() else { return }; + let id = id.clone(); + let rules = rules.clone(); + let services = services.clone(); + let translate = translate.clone(); + wasm_bindgen_futures::spawn_local(async move { + // Shared with the checkbox in the dialog body: the + // dialog itself only reports Ok/Cancel, so the policy + // travels out of band. + let cancel_future = Rc::new(Cell::new(false)); + let content = { + let cancel_future = cancel_future.clone(); + let on_change = Callback::from(move |value: bool| cancel_future.set(value)); + html! { +
+

{ translate.t("LABEL.RECORDING_FORM_RULE_DELETE_CONFIRM") }

+ +

+ { translate.t("LABEL.RECORDING_RULE_DELETE_RETAIN") } +

+
+ } + }; + if dialog.content(content, None, true).await != DialogResult::Ok { + return; + } + let future = if cancel_future.get() { "cancel" } else { "retain" }; + let service = RecordingService::new(); + match service.delete_rule(&id, future).await { + Ok(()) => services.toastr.success(translate.t("MESSAGES.RECORDING.RULE_DELETED")), + Err(error) => { + log::error!("rule delete failed ({future}): {error}"); + services.toastr.error(error_message(&translate, &error)); + } + } + if let Ok(r) = service.list_rules().await { + rules.set(Rc::new(r)); + } + }); + }) + } + }; + + // Enabling / disabling a rule was only possible by opening the edit + // form; the list now carries the switch. Disabling is the reversible + // way to stop a rule, so it should be the cheapest action available. + let toggle_enabled_click = { + let rules_outer = rules.clone(); + let services_outer = services.clone(); + let translate_outer = translate.clone(); + move |id: String, enabled: bool| { + let rules = rules_outer.clone(); + let services = services_outer.clone(); + let translate = translate_outer.clone(); + Callback::from(move |_: bool| { + let id = id.clone(); + let rules = rules.clone(); + let services = services.clone(); + let translate = translate.clone(); + wasm_bindgen_futures::spawn_local(async move { + let request = EditRecordingRuleRequest { + body: None, + channel_id: None, + clear_channel_id: false, + pre_roll_secs: None, + post_roll_secs: None, + visibility: None, + enabled: Some(!enabled), + }; + let service = RecordingService::new(); + match service.edit_rule(&id, request).await { + // The switch moves optimistically, so success and + // failure look the same for a moment. Say which + // one happened. + Ok(_) => services.toastr.success(translate.t("MESSAGES.RECORDING.RULE_UPDATED")), + Err(error) => { + log::error!("rule enable toggle failed: {error}"); + services.toastr.error(error_message(&translate, &error)); + } + } + // Refetch either way: on failure the switch has to + // snap back to the server's answer. + if let Ok(r) = service.list_rules().await { + rules.set(Rc::new(r)); + } + }); + }) + } + }; + + let on_create_click = { + let creating = creating.clone(); + Callback::from(move |_: String| { + creating.set(true); + }) + }; + + let config_ctx = use_context::(); + let sources: Rc>> = match config_ctx { + Some(ctx) => match ctx.config { + Some(c) => Rc::new(c.sources.sources.iter().cloned().map(Rc::new).collect()), + None => Rc::new(Vec::new()), + }, + None => Rc::new(Vec::new()), + }; + + let form = if let Some(rule) = editing.as_ref().as_ref() { + let rule_value: RecordingRuleSnapshot = (*rule).clone(); + let form_key = recording_rule_form_key(Some(&rule_value)); + html! { + + } + } else if *creating { + html! { + ::None} + sources={sources.clone()} + on_done={on_done_callback.clone()} + /> + } + } else { + html! { <> } + }; + + let headers: Vec = RULE_HEADERS.iter().map(|h| translate.t(h)).collect(); + let render_header = Callback::from(move |col: usize| { + let headers = headers.clone(); + let col_text = headers.get(col).cloned().unwrap_or_default(); + html! { <>{ col_text } } + }); + + // The enabled column is a switch, not text: sorting by it would + // reorder rows under the pointer mid-click. + let is_sortable = Callback::from(|col: usize| matches!(col, 0..=2)); + let on_sort = Callback::from(|_: Option<(usize, SortOrder)>| {}); + + let rules_items: Rc>> = Rc::new((*rules).iter().cloned().map(Rc::new).collect()); + let is_empty = rules_items.is_empty(); + let render_data = { + let translate = translate.clone(); + Callback::from(move |(col, _idx, rule): (usize, usize, Rc)| match col { + 0 => html! { <>{ rule_summary(&rule) } }, + 1 => html! { <>{ rule_visibility_label(&translate, &rule) } }, + 2 => html! { <>{ rule_schedule_label(&translate, &rule) } }, + 3 => { + let enabled = rule.rule.enabled; + let on_change = toggle_enabled_click(rule.rule.id.clone(), enabled); + let label = translate.t(if enabled { + "LABEL.RECORDING_RULE_ACTION_DISABLE" + } else { + "LABEL.RECORDING_RULE_ACTION_ENABLE" + }); + html! { + + + + } + } + _ => { + let id = rule.rule.id.clone(); + let on_edit = edit_id_click(id.clone()); + let on_delete = delete_id_click(id.clone()); + let edit_label = translate.t("LABEL.RECORDING_ACTION_EDIT"); + let delete_label = translate.t("LABEL.RECORDING_ACTION_DELETE"); + let row = rule_summary(&rule); + html! { +
+ + +
+ } + } + }) + }; + + let table_def = Rc::new(TableDefinition:: { + items: Some(rules_items), + num_cols: RULE_HEADERS.len(), + is_sortable, + render_header_cell: render_header, + render_data_cell: render_data, + on_sort, + }); + + html! { +
+
+
+
+

{ translate.t("LABEL.RECORDING_RULES") }

+ +
+
+ { form } + if is_empty { +

+ { translate.t("MESSAGES.RECORDING.EMPTY_RULES") } +

+ } else { + definition={table_def} /> + } +
+
+
+
+ } +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::model::recording_rule::{RuleSource, RuleVisibility}; + + #[test] + fn can_show_rules_section_requires_recording_read() { + assert!(!can_show_rules_section(false)); + assert!(can_show_rules_section(true)); + } + + #[test] + fn recording_rule_form_key_changes_with_rule_id() { + let first = dummy_rule( + RuleBody::NewEpisode { series_id: Some("s1".into()), title_pattern: None, exclude_repeat: true }, + RuleVisibility::Private, + true, + ); + let mut second = first.rule.clone(); + second.id = "r2".to_string(); + + assert_eq!(recording_rule_form_key(Some(&first.rule)), "r1"); + assert_eq!(recording_rule_form_key(Some(&second)), "r2"); + assert_eq!(recording_rule_form_key(None), "new"); + } + + #[test] + fn can_create_rule_requires_recording_write() { + assert!(!can_create_rule(false)); + assert!(can_create_rule(true)); + } + + #[test] + fn can_create_shared_rule_requires_admin() { + assert!(!can_create_shared_rule(false, false)); + assert!(!can_create_shared_rule(false, true)); + assert!(!can_create_shared_rule(true, false)); + assert!(can_create_shared_rule(true, true)); + } + + #[test] + fn can_edit_private_rule_owner_or_admin() { + assert!(!can_edit_rule(false, false, true, false)); + assert!(can_edit_rule(true, false, true, false)); + assert!(!can_edit_rule(true, false, false, false)); + assert!(can_edit_rule(true, true, false, false)); + } + + #[test] + fn can_edit_shared_rule_only_admin() { + assert!(!can_edit_rule(true, false, true, true)); + assert!(can_edit_rule(true, true, false, true)); + } + + #[test] + fn can_delete_rule_matches_edit_rule() { + for (a, b, c, d) in [(false, false, true, false), (true, true, false, true), (true, false, true, false)] { + assert_eq!(can_edit_rule(a, b, c, d), can_delete_rule(a, b, c, d)); + } + } + + #[test] + fn delete_future_round_trip() { + assert_eq!(DeleteFuture::from_wire("retain"), Some(DeleteFuture::Retain)); + assert_eq!(DeleteFuture::from_wire("cancel"), Some(DeleteFuture::Cancel)); + assert_eq!(DeleteFuture::from_wire("bogus"), None); + assert_eq!(DeleteFuture::Retain.wire(), "retain"); + assert_eq!(DeleteFuture::Cancel.wire(), "cancel"); + } + + #[test] + fn new_episode_limitations_text_mentions_title_fallback() { + assert!(new_episode_limitations_text().contains("title")); + } + + #[test] + fn weekly_timezone_hint_text_mentions_dst() { + assert!(weekly_timezone_hint_text().to_lowercase().contains("daylight")); + } + + #[test] + fn reconciliation_error_to_i18n_key_carries_both_labels() { + let k = reconciliation_error_to_i18n_key("rule", "tombstone"); + assert!(k.contains("rule")); + assert!(k.contains("tombstone")); + } + + fn dummy_rule(body: RuleBody, visibility: RuleVisibility, enabled: bool) -> RecordingRuleResponse { + RecordingRuleResponse { + revision: 0, + rule: RecordingRuleSnapshot { + id: "r1".to_string(), + owner_id: "u1".to_string(), + visibility, + enabled, + source: RuleSource::new("tgt", "vid", "input"), + channel_id: None, + body, + pre_roll_secs: 0, + post_roll_secs: 0, + created_at: 0, + updated_at: 0, + }, + } + } + + #[test] + fn rule_summary_includes_target_virtual_and_input() { + let r = dummy_rule( + RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".into(), + duration_secs: 3600, + timezone: "UTC".into(), + }, + RuleVisibility::Private, + true, + ); + let s = rule_summary(&r); + assert!(s.contains("tgt"), "missing target_id: {s}"); + assert!(s.contains("vid"), "missing virtual_id: {s}"); + assert!(s.contains("input"), "missing input_name: {s}"); + } + + #[test] + fn rule_visibility_label_maps_wire_to_label() { + let r_shared = dummy_rule( + RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".into(), + duration_secs: 3600, + timezone: "UTC".into(), + }, + RuleVisibility::Shared, + true, + ); + let r_priv = dummy_rule( + RuleBody::WeeklyTimeslot { + weekday: 1, + local_start_time: "20:00".into(), + duration_secs: 3600, + timezone: "UTC".into(), + }, + RuleVisibility::Private, + true, + ); + // Assert on the i18n key, not the rendered text: the text is + // whatever the active language says, the key is the contract. + assert_eq!(rule_visibility_key(&r_shared), "LABEL.RECORDING_VISIBILITY_SHARED"); + assert_eq!(rule_visibility_key(&r_priv), "LABEL.RECORDING_VISIBILITY_PRIVATE"); + assert_ne!(rule_visibility_key(&r_shared), rule_visibility_key(&r_priv)); + } + + #[test] + fn rule_schedule_label_weekly_format() { + let s = rule_weekly_schedule_text(3, "21:30", 1800); + assert!(s.contains("W3"), "missing weekday: {s}"); + assert!(s.contains("21:30"), "missing start: {s}"); + assert!(s.contains("30m"), "missing duration: {s}"); + } + + #[test] + fn rule_weekly_schedule_rounds_down_to_whole_minutes() { + assert!(rule_weekly_schedule_text(1, "20:00", 90).contains("(1m)")); + assert!(rule_weekly_schedule_text(1, "20:00", 59).contains("(0m)")); + } +} diff --git a/frontend/src/app/components/recording/recording_task_edit_form.rs b/frontend/src/app/components/recording/recording_task_edit_form.rs new file mode 100644 index 000000000..42c7cc4f0 --- /dev/null +++ b/frontend/src/app/components/recording/recording_task_edit_form.rs @@ -0,0 +1,161 @@ +//! Inline form to edit a recording task's mutable fields. +//! +//! Renders directly under a row in `RecordingLibraryView`. The submit +//! callback calls `RecordingService::edit_task` and returns the user +//! to the listing. + +use crate::{ + app::components::{datetime_input::DateTimeInput, number_input::NumberInput, text_button::TextButton}, + hooks::use_service_context, + i18n::use_translation, + services::{EditRecordingTaskRequest, RecordingService, RecordingTaskResponse}, +}; +use yew::prelude::*; + +#[derive(Clone, PartialEq, Properties)] +#[allow(dead_code)] +pub struct TaskEditFormProps { + pub task: RecordingTaskResponse, + #[prop_or_default] + pub on_done: Option>, +} + +#[function_component(TaskEditForm)] +pub fn task_edit_form(props: &TaskEditFormProps) -> Html { + let services = use_service_context(); + let translate = use_translation(); + + let task = props.task.clone(); + let program_start = use_state(|| task.recording.as_ref().and_then(|r| r.program_start).unwrap_or(0)); + let program_end = use_state(|| task.recording.as_ref().and_then(|r| r.program_end).unwrap_or(0)); + let pre_roll = use_state(|| task.recording.as_ref().map(|r| r.pre_roll_secs).unwrap_or(0)); + let post_roll = use_state(|| task.recording.as_ref().map(|r| r.post_roll_secs).unwrap_or(0)); + + let id = task.id.clone(); + + let on_program_start = { + let program_start = program_start.clone(); + Callback::from(move |v: Option| { + if let Some(v) = v { + program_start.set(v); + } + }) + }; + let on_program_end = { + let program_end = program_end.clone(); + Callback::from(move |v: Option| { + if let Some(v) = v { + program_end.set(v); + } + }) + }; + let on_pre = { + let pre_roll = pre_roll.clone(); + Callback::from(move |v: Option| { + if let Some(v) = v { + pre_roll.set(v as u64); + } + }) + }; + let on_post = { + let post_roll = post_roll.clone(); + Callback::from(move |v: Option| { + if let Some(v) = v { + post_roll.set(v as u64); + } + }) + }; + + let on_submit_click = { + let id = id.clone(); + let services = services.clone(); + let program_start = program_start.clone(); + let program_end = program_end.clone(); + let pre_roll = pre_roll.clone(); + let post_roll = post_roll.clone(); + let on_done = props.on_done.clone(); + let translate = translate.clone(); + Callback::from(move |_: String| { + let id = id.clone(); + let svc = services.clone(); + let on_done = on_done.clone(); + let translate = translate.clone(); + let request = EditRecordingTaskRequest { + program_start: Some(*program_start), + program_end: Some(*program_end), + pre_roll_secs: Some(*pre_roll), + post_roll_secs: Some(*post_roll), + program_title: None, + channel_id: None, + channel_name: None, + }; + wasm_bindgen_futures::spawn_local(async move { + match RecordingService::new().edit_task(&id, request).await { + Ok(()) => { + svc.toastr.success(translate.t("MESSAGES.RECORDING.TASK_UPDATED")); + if let Some(cb) = on_done { + cb.emit(()); + } + } + Err(error) => { + log::warn!("recording edit failed: {error}"); + svc.toastr.error(translate.t(error.i18n_key())); + } + } + }); + }) + }; + + let on_cancel_click = { + let on_done = props.on_done.clone(); + Callback::from(move |_: String| { + if let Some(cb) = on_done.clone() { + cb.emit(()); + } + }) + }; + + html! { +
+ + + + +
+ + +
+
+ } +} diff --git a/frontend/src/app/components/search.rs b/frontend/src/app/components/search.rs index f31ce805b..63dabb434 100644 --- a/frontend/src/app/components/search.rs +++ b/frontend/src/app/components/search.rs @@ -1,6 +1,8 @@ use crate::{ app::components::{AppIcon, DropDownIconButton, DropDownOption, DropDownSelection, IconButton}, + hooks::{is_text_input_focused, use_key_down}, html_if, + i18n::use_translation, }; use gloo_timers::callback::Timeout; use shared::model::SearchRequest; @@ -25,17 +27,44 @@ pub struct SearchProps { #[prop_or_default] pub options: Option>>, pub onsearch: Option>, + #[prop_or_default] + pub on_fields_change: Option>>>>, #[prop_or(3)] pub min_length: usize, } #[component] pub fn Search(props: &SearchProps) -> Html { - let search_fields = use_state(|| None::>>); + let translate = use_translation(); + let search_fields = use_state(|| { + // Preselected options (e.g. restored from local storage) apply immediately. + props.options.as_ref().and_then(|options| { + let selected: Vec = + options.iter().filter(|option| option.selected).map(|option| option.id.clone()).collect(); + if selected.is_empty() { + None + } else { + Some(Rc::new(selected)) + } + }) + }); let input_ref = use_node_ref(); let invalid_search = use_state(|| false); let regex_active = use_state(|| RegexState::Inactive); + // Global '/' shortcut focuses the search input + { + let input = input_ref.clone(); + use_key_down((), move |event: &KeyboardEvent| { + if event.key() == "/" && !is_text_input_focused(event) { + if let Some(input) = input.cast::() { + event.prevent_default(); + let _ = input.focus(); + } + } + }); + } + let handle_regex_click = { let regex_active = regex_active.clone(); let input = input_ref.clone(); @@ -114,21 +143,18 @@ pub fn Search(props: &SearchProps) -> Html { let handle_options_click = { let search_fields = search_fields.clone(); let emit_search = emit_search.clone(); - Callback::from(move |(_name, selections)| match selections { - DropDownSelection::Empty => { - search_fields.set(None); - emit_search(None); - } - DropDownSelection::Multi(options) => { - let selected = Rc::new(options); - search_fields.set(Some(selected.clone())); - emit_search(Some(selected)); - } - DropDownSelection::Single(option) => { - let selected = Rc::new(vec![option]); - search_fields.set(Some(selected.clone())); - emit_search(Some(selected)); + let on_fields_change = props.on_fields_change.clone(); + Callback::from(move |(_name, selections)| { + let selected = match selections { + DropDownSelection::Empty => None, + DropDownSelection::Multi(options) => Some(Rc::new(options)), + DropDownSelection::Single(option) => Some(Rc::new(vec![option])), + }; + search_fields.set(selected.clone()); + if let Some(cb_fields) = on_fields_change.as_ref() { + cb_fields.emit(selected.clone()); } + emit_search(selected); }) }; @@ -139,18 +165,26 @@ pub fn Search(props: &SearchProps) -> Html { "option-active", RegexState::Invalid => "option-invalid", RegexState::Inactive => ""}} - name="regex" icon="Regexp" onclick={handle_regex_click} /> + name="regex" icon="Regexp" + hint={translate.t("LABEL.REGEXP")} + aria_label={translate.t("LABEL.REGEXP")} + onclick={handle_regex_click} /> { html_if!( props.options.is_some(), { - + } ) } diff --git a/frontend/src/app/components/select.rs b/frontend/src/app/components/select.rs index fe9f62c2c..83a52a363 100644 --- a/frontend/src/app/components/select.rs +++ b/frontend/src/app/components/select.rs @@ -17,11 +17,16 @@ pub struct SelectProps { pub options: Rc>, #[prop_or_default] pub multi_select: bool, + #[prop_or_default] + pub required: bool, + #[prop_or_default] + pub error: Option, } #[component] pub fn Select(props: &SelectProps) -> Html { let button_ref = use_node_ref(); + let error_id = props.error.as_ref().map(|_| format!("{}-select-error", props.name)); let selected_options = use_state(Vec::new); { @@ -46,7 +51,11 @@ pub fn Select(props: &SelectProps) -> Html { }; html! { -
+
{(*selected_options).clone()} @@ -57,8 +66,15 @@ pub fn Select(props: &SelectProps) -> Html { options={props.options.clone()} name={props.name.clone()} icon={props.icon.as_ref().map_or_else(|| "Popup".to_owned(), |i|i.to_string())} + aria_label={props.name.clone()} + aria_required={props.required.then_some(true)} + aria_invalid={props.error.as_ref().map(|_| true)} + aria_describedby={error_id.clone()} on_select={props.on_select.clone()} />
+ { props.error.as_ref().map_or_else(Html::default, |error| html! { + { error.clone() } + }) }
} } diff --git a/frontend/src/app/components/sidebar.rs b/frontend/src/app/components/sidebar.rs index 0051540fc..1f7bc24b1 100644 --- a/frontend/src/app/components/sidebar.rs +++ b/frontend/src/app/components/sidebar.rs @@ -1,5 +1,8 @@ use crate::{ - app::components::{menu_item::MenuItem, svg_icon::AppIcon, CollapsePanel, IconButton}, + app::{ + components::{menu_item::MenuItem, svg_icon::AppIcon, CollapsePanel, IconButton}, + ConfigContext, + }, hooks::use_service_context, i18n::use_translation, model::ViewType, @@ -58,6 +61,20 @@ fn is_sidebar_expanded(collapsed: CollapseState) -> bool { matches!(collapsed, CollapseState::AutoExpanded | CollapseState::ManualExpanded) } +/// Should the DVR entries appear in the navigation? +/// +/// Two independent gates: the principal needs `recording.read`, and the +/// server must have the DVR switched on. Showing the entries on a server +/// where `recording.enabled: false` would lead every route to +/// `501 recording_disabled`. +/// +/// `recording_enabled` is `None` when the config has not loaded yet or +/// carries no `recording:` block; both mean "assume the default", and the +/// default is enabled. +pub fn show_recording_nav(has_recording_read: bool, recording_enabled: Option) -> bool { + has_recording_read && recording_enabled.unwrap_or(true) +} + #[component] pub fn Sidebar(props: &SidebarProps) -> Html { let services = use_service_context(); @@ -70,6 +87,19 @@ pub fn Sidebar(props: &SidebarProps) -> Html { let is_mobile = use_state(|| false); let resolved_state = resolved_sidebar_state(*collapsed, *is_mobile); let active_menu = props.active_page; + let config_ctx = use_context::(); + let recording_enabled = config_ctx.and_then(|ctx| { + ctx.config.as_ref().and_then(|config| { + config + .config + .video + .as_ref() + .and_then(|video| video.download.as_ref()) + .and_then(|download| download.recording.as_ref()) + .map(|recording| recording.enabled) + }) + }); + let show_recording = show_recording_nav(services.auth.has_permission(Permission::RecordingRead), recording_enabled); let handle_menu_click = { let viewchange = props.onview.clone(); @@ -210,6 +240,9 @@ pub fn Sidebar(props: &SidebarProps) -> Html { {html_if!(auth.has_permission(Permission::UserRead), { })} + {html_if!(auth.has_permission(Permission::ConfigRead), { + + })} {html_if!(auth.has_permission(Permission::SourceRead), { <> @@ -235,6 +268,15 @@ pub fn Sidebar(props: &SidebarProps) -> Html { } )} + {html_if!( + show_recording, + { + + + + + } + )}
} }; @@ -271,6 +313,9 @@ pub fn Sidebar(props: &SidebarProps) -> Html { {html_if!(auth.has_permission(Permission::UserRead), { })} + {html_if!(auth.has_permission(Permission::ConfigRead), { + + })} {html_if!(auth.has_permission(Permission::SourceRead), { <> @@ -292,6 +337,13 @@ pub fn Sidebar(props: &SidebarProps) -> Html { {html_if!(auth.has_permission(Permission::EpgRead), { })} + {html_if!(show_recording, { + <> + + + + + })}
} }; @@ -355,6 +407,7 @@ pub fn Sidebar(props: &SidebarProps) -> Html { #[cfg(test)] mod tests { use super::{is_sidebar_expanded, resolved_sidebar_state, sidebar_variant_class, CollapseState}; + use crate::app::components::show_recording_nav; #[test] fn sidebar_variant_class_reports_collapsed_variants() { @@ -382,4 +435,19 @@ mod tests { assert!(!is_sidebar_expanded(CollapseState::AutoCollapsed)); assert!(!is_sidebar_expanded(CollapseState::ManualCollapsed)); } + + #[test] + fn recording_nav_needs_both_the_permission_and_the_feature_flag() { + assert!(show_recording_nav(true, Some(true))); + assert!(!show_recording_nav(true, Some(false))); + assert!(!show_recording_nav(false, Some(true))); + assert!(!show_recording_nav(false, None)); + } + + #[test] + fn recording_nav_assumes_enabled_before_the_config_arrives() { + // Hiding the entries until the config loads would make them flash + // in on every page load; the default is enabled anyway. + assert!(show_recording_nav(true, None)); + } } diff --git a/frontend/src/app/components/source_editor/alias_item_form.rs b/frontend/src/app/components/source_editor/alias_item_form.rs index 28f3dc14a..42daed908 100644 --- a/frontend/src/app/components/source_editor/alias_item_form.rs +++ b/frontend/src/app/components/source_editor/alias_item_form.rs @@ -192,6 +192,7 @@ pub fn AliasItemForm(props: &AliasItemFormProps) -> Html { password, providers: (!providers.is_empty()).then_some(providers.clone()), }; + let no_exp_date_msg = translate.t("MESSAGES.SOURCE_EDITOR.NO_EXPIRATION_DATE_RETURNED"); spawn_local(async move { let current_snapshot = || { @@ -210,7 +211,7 @@ pub fn AliasItemForm(props: &AliasItemFormProps) -> Html { if let Some(exp_date) = login_info.exp_date { form_state.dispatch(AliasFormAction::ExpDate(Some(exp_date))); } else { - services.toastr.warning("No expiration date returned by provider"); + services.toastr.warning(no_exp_date_msg); } } } diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index fc7b91aeb..e357c0426 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -27,7 +27,9 @@ use std::{ rc::Rc, }; use wasm_bindgen::{prelude::Closure, JsCast}; -use web_sys::{window, Element, Event, HtmlElement, KeyboardEvent, MouseEvent, TouchEvent, WheelEvent}; +use web_sys::{ + window, BeforeUnloadEvent, Element, Event, HtmlElement, KeyboardEvent, MouseEvent, TouchEvent, WheelEvent, +}; use yew::{platform::spawn_local, prelude::*}; const PENDING_LINE: &str = "pending-line"; @@ -552,6 +554,10 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { let editor_state_ref = use_mut_ref(EditorState::default); let initialized_from_playlist = use_state(|| false); let is_local_mode = props.on_sources_change.is_some(); + // Tracks unsaved editor changes for the beforeunload guard + let is_dirty = use_state(|| false); + // Monotonic edit revision; a save only clears is_dirty when no edit happened while it was in flight + let edit_revision = use_mut_ref(|| 0u64); // Delete mode toggle let delete_mode = use_state(|| false); let cursor_grabbing = use_state(|| false); @@ -615,7 +621,11 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { let on_sources_change = props.on_sources_change.clone(); let editor_state_ref = editor_state_ref.clone(); let config_ctx = config_ctx.clone(); + let is_dirty = is_dirty.clone(); + let edit_revision = edit_revision.clone(); Callback::from(move |_| { + *edit_revision.borrow_mut() += 1; + is_dirty.set(true); if let Some(on_sources_change) = on_sources_change.as_ref() { let base_sources = config_ctx.config.as_ref().map(|c| c.sources.clone()).unwrap_or_default(); let editor_state = editor_state_ref.borrow(); @@ -625,6 +635,30 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { }) }; + // Warn before the browser unloads while the editor holds unsaved changes. + { + let dirty = *is_dirty && !is_local_mode; + use_effect_with(dirty, move |&dirty| { + let closure = Closure::::wrap(Box::new(move |event: BeforeUnloadEvent| { + event.prevent_default(); + event.set_return_value(""); + })); + if dirty { + if let Some(win) = window() { + let _ = win.add_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()); + } + } + move || { + if dirty { + if let Some(win) = window() { + let _ = + win.remove_event_listener_with_callback("beforeunload", closure.as_ref().unchecked_ref()); + } + } + } + }); + } + { let playlists = playlist_ctx.clone(); let config_ctx = config_ctx.clone(); @@ -1104,6 +1138,8 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { let editor_state_ref = editor_state_ref.clone(); let services = services.clone(); let translate = translate.clone(); + let is_dirty = is_dirty.clone(); + let edit_revision = edit_revision.clone(); Callback::from(move |_| { let base_sources = config_ctx.config.as_ref().map(|c| c.sources.clone()).unwrap_or_default(); let editor_state = editor_state_ref.borrow(); @@ -1119,9 +1155,18 @@ pub fn SourceEditor(props: &SourceEditorProps) -> Html { let services = services.clone(); let translate = translate.clone(); + let is_dirty = is_dirty.clone(); + let edit_revision = edit_revision.clone(); + let saved_revision = *edit_revision.borrow(); wasm_bindgen_futures::spawn_local(async move { match services.config.save_sources(sources_config).await { - Ok(()) => services.toastr.success(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.SUCCESS")), + Ok(()) => { + // Preserve the dirty flag if the editor changed while the save was in flight + if *edit_revision.borrow() == saved_revision { + is_dirty.set(false); + } + services.toastr.success(translate.t("MESSAGES.SAVE.SOURCES_CONFIG.SUCCESS")); + } Err(err) => services.toastr.error(err.to_string()), } }); diff --git a/frontend/src/app/components/source_editor/layout.rs b/frontend/src/app/components/source_editor/layout.rs index 820e65ee3..5bf358cba 100644 --- a/frontend/src/app/components/source_editor/layout.rs +++ b/frontend/src/app/components/source_editor/layout.rs @@ -89,8 +89,9 @@ fn build_input_target_maps(blocks: &[Block], connections: &[Connection]) -> Inpu let mut adjacency: EdgeMap = HashMap::new(); for con in connections { - let from = &blocks[con.from as usize - 1]; - let to = &blocks[con.to as usize - 1]; + // Skip connections referencing missing blocks instead of panicking + let Some(from) = usize::from(con.from).checked_sub(1).and_then(|i| blocks.get(i)) else { continue }; + let Some(to) = usize::from(con.to).checked_sub(1).and_then(|i| blocks.get(i)) else { continue }; if from.block_type.is_input() && to.block_type.is_target() { push_unique(&mut input_to_targets, con.from, con.to); @@ -403,9 +404,13 @@ pub fn barycentric_sort( let mut target_to_inputs: HashMap> = HashMap::new(); for con in connections { - if blocks[con.from as usize - 1].block_type.is_input() && blocks[con.to as usize - 1].block_type.is_target() { - input_to_targets.entry(con.from).or_default().push(con.to); - target_to_inputs.entry(con.to).or_default().push(con.from); + let from = usize::from(con.from).checked_sub(1).and_then(|i| blocks.get(i)); + let to = usize::from(con.to).checked_sub(1).and_then(|i| blocks.get(i)); + if let (Some(from), Some(to)) = (from, to) { + if from.block_type.is_input() && to.block_type.is_target() { + input_to_targets.entry(con.from).or_default().push(con.to); + target_to_inputs.entry(con.to).or_default().push(con.from); + } } } @@ -413,16 +418,12 @@ pub fn barycentric_sort( for _ in 0..iterations { // sort inputs by middle value of targets input_order.sort_by(|&a, &b| { - barycenter(a, &input_to_targets, &target_order) - .partial_cmp(&barycenter(b, &input_to_targets, &target_order)) - .unwrap() + barycenter(a, &input_to_targets, &target_order).total_cmp(&barycenter(b, &input_to_targets, &target_order)) }); // sort targets by middle value of inputs target_order.sort_by(|&a, &b| { - barycenter(a, &target_to_inputs, &input_order) - .partial_cmp(&barycenter(b, &target_to_inputs, &input_order)) - .unwrap() + barycenter(a, &target_to_inputs, &input_order).total_cmp(&barycenter(b, &target_to_inputs, &input_order)) }); } diff --git a/frontend/src/app/components/table.rs b/frontend/src/app/components/table.rs index 6e5b917a4..b7e7a40c6 100644 --- a/frontend/src/app/components/table.rs +++ b/frontend/src/app/components/table.rs @@ -85,13 +85,23 @@ pub fn Table(props: &TableProps) -> Html { Callback::from(move |_| on_header_click.emit(col_index)) }; + // Enter/Space activate sorting for keyboard users + let on_key_col = { + let on_header_click = on_header_click.clone(); + Callback::from(move |event: KeyboardEvent| { + let key = event.key(); + if key == "Enter" || key == " " { + event.prevent_default(); + on_header_click.emit(col_index); + } + }) + }; + html!{ (props: &TableProps) -> Html { } else { Some("none".to_string()) } } > - - {render_header_cell.emit(col_index)} - {icon_html} - + // Sortable headers expose a real button so the keeps its columnheader semantics + if sortable { + + } else { + + {render_header_cell.emit(col_index)} + {icon_html} + + } } }) diff --git a/frontend/src/app/components/tabset.rs b/frontend/src/app/components/tabset.rs index 72d34fe6f..294677854 100644 --- a/frontend/src/app/components/tabset.rs +++ b/frontend/src/app/components/tabset.rs @@ -54,6 +54,31 @@ pub fn TabSet(props: &TabSetProps) -> Html { }) }; + // Arrow/Home/End keyboard navigation over the tablist + let handle_header_keydown = { + let tabs = props.tabs.clone(); + let active_tab = active_tab.clone(); + let handle_tab_click = handle_tab_click.clone(); + Callback::from(move |event: KeyboardEvent| { + let count = tabs.len(); + if count == 0 { + return; + } + let current = tabs.iter().position(|t| t.id == *active_tab).unwrap_or(0); + let next = match event.key().as_str() { + "ArrowRight" | "ArrowDown" => Some((current + 1) % count), + "ArrowLeft" | "ArrowUp" => Some((current + count - 1) % count), + "Home" => Some(0), + "End" => Some(count - 1), + _ => None, + }; + if let Some(idx) = next { + event.prevent_default(); + handle_tab_click.emit(tabs[idx].id.clone()); + } + }) + }; + let render_tab_buttons = { let tabs = props.tabs.clone(); let active_tab_id = (*active_tab).clone(); @@ -64,7 +89,7 @@ pub fn TabSet(props: &TabSetProps) -> Html { let click_handler = handle_click.clone(); html! { -