Skip to main content

ferrex_model/
scan.rs

1use std::path::PathBuf;
2
3use uuid::Uuid;
4
5use super::LibraryId;
6use crate::chrono::{DateTime, Utc};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub struct ScanRequest {
11    pub library_id: LibraryId,
12    pub force_refresh: bool,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17pub struct ScanResponse {
18    pub status: ScanStatus,
19    pub scan_id: Option<Uuid>,
20    pub message: String,
21}
22
23impl ScanResponse {
24    pub fn new(
25        status: ScanStatus,
26        scan_id: Option<Uuid>,
27        message: String,
28    ) -> Self {
29        ScanResponse {
30            status,
31            scan_id,
32            message,
33        }
34    }
35
36    pub fn new_scan_started(scan_id: Uuid, message: String) -> Self {
37        ScanResponse {
38            status: ScanStatus::Scanning,
39            scan_id: Some(scan_id),
40            message,
41        }
42    }
43
44    pub fn new_failed(message: String) -> Self {
45        ScanResponse {
46            status: ScanStatus::Failed,
47            scan_id: None,
48            message,
49        }
50    }
51
52    pub fn new_canceled(scan_id: Uuid) -> Self {
53        ScanResponse {
54            status: ScanStatus::Cancelled,
55            scan_id: Some(scan_id),
56            message: "Scan canceled".to_string(),
57        }
58    }
59}
60
61#[derive(Debug, Clone)]
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub struct ScanProgress {
64    pub scan_id: Uuid,
65    pub status: ScanStatus,
66    pub paths: Vec<PathBuf>,
67    pub library_names: Vec<String>,
68    pub library_ids: Vec<String>,
69    pub folders_to_scan: usize,
70    pub folders_scanned: usize,
71    pub movies_scanned: usize,
72    pub series_scanned: usize,
73    pub seasons_scanned: usize,
74    pub episodes_scanned: usize,
75    pub skipped_samples: usize,
76    pub errors: Vec<String>,
77    pub current_media: Option<String>,
78    pub current_library: Option<String>,
79    pub started_at: DateTime<Utc>,
80    pub completed_at: Option<DateTime<Utc>>,
81    pub estimated_time_remaining: Option<std::time::Duration>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub enum ScanStatus {
87    Pending,
88    Scanning,
89    Completed,
90    Failed,
91    Cancelled,
92}
93
94pub mod scanner {
95    pub mod settings {
96        /// Default file extensions treated as video assets by the scanner.
97        pub const DEFAULT_VIDEO_FILE_EXTENSIONS: &[&str] = &[
98            "mp4", "mkv", "avi", "mov", "webm", "flv", "wmv", "m4v", "mpg",
99            "mpeg",
100        ];
101
102        /// Convenience helper for consumers that work with owned strings.
103        pub fn default_video_file_extensions_vec() -> Vec<String> {
104            DEFAULT_VIDEO_FILE_EXTENSIONS
105                .iter()
106                .map(|ext| ext.to_string())
107                .collect()
108        }
109    }
110}
111
112pub mod orchestration {
113    pub mod budget {
114        #[cfg(feature = "serde")]
115        use serde::{Deserialize, Serialize};
116
117        /// Configuration for workload limits.
118        #[derive(Clone, Debug)]
119        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
120        pub struct BudgetConfig {
121            /// Default 1 - one library scan at a time.
122            pub library_scan_limit: usize,
123            /// Default low to avoid disk overload.
124            pub media_analysis_limit: usize,
125            /// Default 2 * CPU count.
126            pub metadata_limit: usize,
127            /// Default moderate.
128            pub indexing_limit: usize,
129            /// Poster/backdrop workers.
130            pub image_fetch_limit: usize,
131        }
132
133        impl Default for BudgetConfig {
134            fn default() -> Self {
135                let cpu_count =
136                    std::thread::available_parallelism().map_or(1, |n| n.get());
137                Self {
138                    library_scan_limit: 1,
139                    media_analysis_limit: 4,
140                    metadata_limit: cpu_count * 2,
141                    indexing_limit: cpu_count,
142                    image_fetch_limit: 4,
143                }
144            }
145        }
146    }
147
148    pub mod config {
149        use std::collections::HashMap;
150
151        use crate::ids::LibraryId;
152
153        #[cfg(feature = "serde")]
154        use serde::{Deserialize, Serialize};
155
156        /// Global knobs that tune orchestrator behaviour.
157        ///
158        /// All fields carry defaults so existing deployments can progressively adopt
159        /// new scheduling features without supplying a full configuration payload.
160        #[derive(Clone, Debug, Default)]
161        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
162        pub struct OrchestratorConfig {
163            /// Queue sizing, fairness weights, and per-library overrides.
164            pub queue: QueueConfig,
165            /// Priority weights used by the scheduler when rotating buckets.
166            pub priority_weights: PriorityWeights,
167            /// Retry/backoff policy shared by all workers.
168            pub retry: RetryConfig,
169            /// Limits for metadata enrichment workers.
170            pub metadata_limits: MetadataLimits,
171            /// Bulk maintenance tuning settings.
172            pub bulk_mode: BulkModeTuning,
173            /// Lease defaults (TTL, renewal thresholds, housekeeping cadence).
174            pub lease: LeaseConfig,
175            /// Global concurrency budget configuration for actor workloads.
176            pub budget: super::budget::BudgetConfig,
177            /// Filesystem watch debounce and batching configuration.
178            pub watch: WatchConfig,
179        }
180
181        #[derive(Clone, Debug)]
182        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
183        pub struct QueueConfig {
184            /// Maximum worker concurrency per queue. These values drive worker pool sizes.
185            pub max_parallel_scans: usize,
186            pub max_parallel_series_resolve: usize,
187            pub max_parallel_analyses: usize,
188            pub max_parallel_metadata: usize,
189            pub max_parallel_index: usize,
190            pub max_parallel_image_fetch: usize,
191            /// Per-device cap for scan workers touching the same mount.
192            pub max_parallel_scans_per_device: usize,
193            /// High watermark for queued jobs. Beyond this we start coalescing low priority work.
194            pub high_watermark: usize,
195            /// Critical watermark for queued jobs. Beyond this P2/P3 work is merged instead of enqueued.
196            pub critical_watermark: usize,
197            /// Sliding window (milliseconds) for aggregating duplicate work items.
198            pub coalesce_window_ms: u64,
199            /// Default maximum in-flight leases allowed per library.
200            pub default_library_cap: usize,
201            /// Default scheduling weight assigned to libraries without overrides.
202            pub default_library_weight: u32,
203            /// Optional per-library overrides.
204            #[cfg_attr(feature = "serde", serde(default))]
205            pub library_overrides: HashMap<LibraryId, LibraryQueuePolicy>,
206        }
207
208        impl Default for QueueConfig {
209            fn default() -> Self {
210                Self {
211                    max_parallel_scans: 6,
212                    max_parallel_series_resolve: 2,
213                    max_parallel_analyses: 2,
214                    max_parallel_metadata: 4,
215                    max_parallel_index: 1,
216                    max_parallel_image_fetch: 4,
217                    max_parallel_scans_per_device: 16,
218                    high_watermark: 10_000,
219                    critical_watermark: 20_000,
220                    coalesce_window_ms: 100,
221                    default_library_cap: 32,
222                    default_library_weight: 1,
223                    library_overrides: HashMap::new(),
224                }
225            }
226        }
227
228        /// Library-specific overrides for queue fairness.
229        #[derive(Clone, Debug, Default)]
230        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
231        pub struct LibraryQueuePolicy {
232            /// Optional in-flight cap; falls back to `default_library_cap` when missing.
233            pub max_inflight: Option<usize>,
234            /// Optional scheduling weight multiplier; falls back to `default_library_weight`.
235            pub weight: Option<u32>,
236        }
237
238        /// Lease/heartbeat tuning for worker tasks.
239        #[derive(Clone, Copy, Debug)]
240        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
241        pub struct LeaseConfig {
242            /// Default TTL for job leases (seconds).
243            pub lease_ttl_secs: i64,
244            /// Renew when remaining TTL drops below this fraction of the original TTL (e.g. 0.5).
245            pub renew_at_fraction: f32,
246            /// Minimum margin before expiry to trigger a renewal regardless of fraction (ms).
247            pub renew_min_margin_ms: u64,
248            /// Housekeeping cadence for scanning expired leases (ms).
249            pub housekeeper_interval_ms: u64,
250        }
251
252        impl Default for LeaseConfig {
253            fn default() -> Self {
254                Self {
255                    lease_ttl_secs: 30,
256                    renew_at_fraction: 0.5,
257                    renew_min_margin_ms: 2_000,
258                    housekeeper_interval_ms: 15_000,
259                }
260            }
261        }
262
263        #[derive(Clone, Copy, Debug)]
264        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
265        pub struct PriorityWeights {
266            pub p0: u8,
267            pub p1: u8,
268            pub p2: u8,
269            pub p3: u8,
270        }
271
272        impl Default for PriorityWeights {
273            fn default() -> Self {
274                Self {
275                    p0: 8,
276                    p1: 4,
277                    p2: 2,
278                    p3: 1,
279                }
280            }
281        }
282
283        #[derive(Clone, Copy, Debug)]
284        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
285        pub struct RetryConfig {
286            pub max_attempts: u16,
287            pub backoff_base_ms: u64,
288            pub backoff_max_ms: u64,
289            /// Attempts that should receive the "fast retry" treatment for user-visible scans.
290            pub fast_retry_attempts: u16,
291            /// Multiplier applied to base delay while in the fast retry window.
292            pub fast_retry_factor: f32,
293            /// When a library accumulates this many retry-heavy jobs we slow the whole queue.
294            pub heavy_library_attempt_threshold: u16,
295            /// Slowdown multiplier applied when a library is considered under stress.
296            pub heavy_library_slowdown_factor: f32,
297            /// Percentage-based jitter to spread out retries.
298            pub jitter_ratio: f32,
299            /// Minimum jitter in milliseconds so tiny jobs still randomise a bit.
300            pub jitter_min_ms: u64,
301        }
302
303        impl RetryConfig {
304            pub fn backoff_base(&self) -> core::time::Duration {
305                core::time::Duration::from_millis(self.backoff_base_ms)
306            }
307
308            pub fn backoff_max(&self) -> core::time::Duration {
309                core::time::Duration::from_millis(self.backoff_max_ms)
310            }
311        }
312
313        impl Default for RetryConfig {
314            fn default() -> Self {
315                Self {
316                    max_attempts: 5,
317                    backoff_base_ms: 2_000,
318                    backoff_max_ms: 5 * 60 * 1_000,
319                    fast_retry_attempts: 2,
320                    fast_retry_factor: 0.35,
321                    heavy_library_attempt_threshold: 4,
322                    heavy_library_slowdown_factor: 1.8,
323                    jitter_ratio: 0.25,
324                    jitter_min_ms: 250,
325                }
326            }
327        }
328
329        #[derive(Clone, Copy, Debug)]
330        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
331        pub struct MetadataLimits {
332            pub max_concurrency: usize,
333            pub max_qps: u32,
334        }
335
336        impl Default for MetadataLimits {
337            fn default() -> Self {
338                Self {
339                    max_concurrency: 2,
340                    max_qps: 100,
341                }
342            }
343        }
344
345        #[derive(Clone, Debug)]
346        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
347        pub struct BulkModeTuning {
348            pub speedup_factor: f32,
349            pub maintenance_partition_count: usize,
350        }
351
352        impl Default for BulkModeTuning {
353            fn default() -> Self {
354                Self {
355                    speedup_factor: 1.2,
356                    maintenance_partition_count: 8,
357                }
358            }
359        }
360
361        /// Tuning controls for filesystem watch coalescing.
362        #[derive(Clone, Debug)]
363        #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
364        pub struct WatchConfig {
365            /// Debounce window in milliseconds.
366            pub debounce_window_ms: u64,
367            /// Maximum number of events to flush in a single batch.
368            pub max_batch_events: usize,
369            /// Polling cadence in milliseconds for filesystems without native watchers.
370            #[cfg_attr(
371                feature = "serde",
372                serde(default = "WatchConfig::default_poll_interval_ms")
373            )]
374            pub poll_interval_ms: u64,
375        }
376
377        impl Default for WatchConfig {
378            fn default() -> Self {
379                Self {
380                    debounce_window_ms: 250,
381                    max_batch_events: 8192,
382                    poll_interval_ms: Self::default_poll_interval_ms(),
383                }
384            }
385        }
386
387        impl WatchConfig {
388            const fn default_poll_interval_ms() -> u64 {
389                30_000
390            }
391        }
392    }
393}