haki-dl 0.2.0

A Rust downloader library and CLI foundation.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
//! Manifest, stream, playlist, segment, and encryption models.

use std::cmp::Ordering;

/// Media type associated with a stream or artifact.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub enum MediaType {
    /// Audio stream.
    Audio,
    /// Video stream.
    Video,
    /// Subtitle stream.
    Subtitles,
    /// Closed captions stream.
    ClosedCaptions,
}

/// Source extractor family.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExtractorType {
    /// MPEG-DASH manifest.
    MpegDash,
    /// HLS manifest.
    Hls,
    /// Direct HTTP live TS input.
    HttpLive,
    /// Smooth Streaming manifest.
    Mss,
}

/// Yes/no choice flag used by manifest metadata.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Choice {
    /// Explicit no.
    No,
    /// Explicit yes.
    Yes,
}

/// DASH role value.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RoleType {
    Subtitle,
    Main,
    Alternate,
    Supplementary,
    Commentary,
    Dub,
    Description,
    Sign,
    Metadata,
    ForcedSubtitle,
    /// Numeric role value without a named variant.
    Numeric(i32),
}

impl RoleType {
    /// Converts an integer role value into a named role when one exists.
    pub const fn from_number(value: i32) -> Self {
        match value {
            0 => Self::Subtitle,
            1 => Self::Main,
            2 => Self::Alternate,
            3 => Self::Supplementary,
            4 => Self::Commentary,
            5 => Self::Dub,
            6 => Self::Description,
            7 => Self::Sign,
            8 => Self::Metadata,
            9 => Self::ForcedSubtitle,
            other => Self::Numeric(other),
        }
    }

    /// Parses a role enum token by name or integer value.
    pub fn parse_enum_token(value: &str) -> Option<Self> {
        let trimmed = value.trim();
        parse_role_name(trimmed).or_else(|| trimmed.parse::<i32>().ok().map(Self::from_number))
    }
}

fn parse_role_name(value: &str) -> Option<RoleType> {
    match value.to_ascii_lowercase().as_str() {
        "subtitle" => Some(RoleType::Subtitle),
        "main" => Some(RoleType::Main),
        "alternate" => Some(RoleType::Alternate),
        "supplementary" => Some(RoleType::Supplementary),
        "commentary" => Some(RoleType::Commentary),
        "dub" => Some(RoleType::Dub),
        "description" => Some(RoleType::Description),
        "sign" => Some(RoleType::Sign),
        "metadata" => Some(RoleType::Metadata),
        "forcedsubtitle" => Some(RoleType::ForcedSubtitle),
        _ => None,
    }
}

/// Segment encryption method.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum EncryptionMethod {
    /// No encryption.
    #[default]
    None,
    Aes128,
    Aes128Ecb,
    SampleAes,
    SampleAesCtr,
    Cenc,
    Chacha20,
    Unknown,
}

impl EncryptionMethod {
    /// Parses the method grammar used by manifests.
    pub fn parse(value: Option<&str>) -> Self {
        match value.map(normalize_token).as_deref() {
            Some("none") => Self::None,
            Some("aes128") => Self::Aes128,
            Some("aes128ecb") => Self::Aes128Ecb,
            Some("sampleaes") => Self::SampleAes,
            Some("sampleaesctr") => Self::SampleAesCtr,
            Some("cenc") => Self::Cenc,
            Some("chacha20") => Self::Chacha20,
            Some(_) => Self::Unknown,
            None => Self::Unknown,
        }
    }
}

/// Origin of key material.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum KeySource {
    /// No key material.
    #[default]
    None,
    /// Inline manifest or CLI material.
    Inline,
    /// Key URI from a manifest.
    Uri,
    /// Key file from CLI/API.
    File,
    /// Key text file lookup.
    KeyTextFile,
    /// API-provided custom material.
    Custom,
}

/// Encryption metadata attached to init or media segments.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct EncryptionInfo {
    /// Encryption method.
    pub method: EncryptionMethod,
    /// Content key bytes when already known.
    pub key: Option<Vec<u8>>,
    /// Initialization vector bytes when known.
    pub iv: Option<Vec<u8>>,
    /// Key identifier bytes when known.
    pub kid: Option<Vec<u8>>,
    /// Protection scheme such as cenc/cbcs when known.
    pub scheme: Option<String>,
    /// Raw protection data needed by downstream decryptors.
    pub protection_data: Option<Vec<u8>>,
    /// Key material source.
    pub source: KeySource,
}

impl EncryptionInfo {
    /// Returns true when the segment uses encryption.
    pub fn is_encrypted(&self) -> bool {
        self.method != EncryptionMethod::None
    }
}

/// Inclusive byte range over a segment resource.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ByteRange {
    /// First byte position.
    pub start: i64,
    /// Expected byte length.
    pub length: i64,
}

impl ByteRange {
    /// Returns the inclusive final byte position.
    pub fn end(&self) -> Option<i64> {
        Some(self.start.wrapping_add(self.length).wrapping_sub(1))
    }
}

/// One media segment or initialization segment.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug)]
pub struct MediaSegment {
    /// Segment index.
    pub index: i64,
    /// Segment duration in seconds.
    pub duration: f64,
    /// Optional title.
    pub title: Option<String>,
    /// Program date-time as source text until a time crate is introduced.
    pub program_date_time: Option<String>,
    /// Byte-range start.
    pub start_range: Option<i64>,
    /// Expected byte length.
    pub expected_length: Option<i64>,
    /// Encryption metadata.
    pub encryption: EncryptionInfo,
    /// Segment URL.
    pub url: String,
    /// DASH name generated from a template variable.
    pub name_from_var: Option<String>,
}

impl Default for MediaSegment {
    fn default() -> Self {
        Self {
            index: 0,
            duration: 0.0,
            title: None,
            program_date_time: None,
            start_range: None,
            expected_length: None,
            encryption: EncryptionInfo::default(),
            url: String::new(),
            name_from_var: None,
        }
    }
}

impl MediaSegment {
    /// Returns the inclusive byte-range end when start and length are known.
    pub fn stop_range(&self) -> Option<i64> {
        match (self.start_range, self.expected_length) {
            (Some(start), Some(length)) => Some(start.wrapping_add(length).wrapping_sub(1)),
            _ => None,
        }
    }

    /// Returns true when the segment is encrypted.
    pub fn is_encrypted(&self) -> bool {
        self.encryption.is_encrypted()
    }
}

impl PartialEq for MediaSegment {
    fn eq(&self, other: &Self) -> bool {
        self.index == other.index
            && (self.duration - other.duration).abs() < 0.001
            && self.title == other.title
            && self.start_range == other.start_range
            && self.stop_range() == other.stop_range()
            && self.expected_length == other.expected_length
            && self.url == other.url
    }
}

/// Segment group separated by discontinuities.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MediaPart {
    /// Segments in this part.
    pub media_segments: Vec<MediaSegment>,
}

impl MediaPart {
    /// Sum of segment durations in seconds.
    pub fn total_duration(&self) -> f64 {
        self.media_segments
            .iter()
            .map(|segment| segment.duration)
            .sum()
    }
}

/// Playlist attached to a stream.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, PartialEq)]
pub struct Playlist {
    /// Playlist URL.
    pub url: String,
    /// Whether the playlist is live.
    pub is_live: bool,
    /// Live refresh interval in milliseconds.
    pub refresh_interval_ms: f64,
    /// Target duration in seconds.
    pub target_duration: Option<f64>,
    /// Initialization segment.
    pub media_init: Option<MediaSegment>,
    /// Discontinuity-separated media parts.
    pub media_parts: Vec<MediaPart>,
}

impl Default for Playlist {
    fn default() -> Self {
        Self {
            url: String::new(),
            is_live: false,
            refresh_interval_ms: 15_000.0,
            target_duration: None,
            media_init: None,
            media_parts: Vec::new(),
        }
    }
}

impl Playlist {
    /// Sum of all media segment durations in seconds.
    pub fn total_duration(&self) -> f64 {
        self.media_parts.iter().map(MediaPart::total_duration).sum()
    }

    /// Total number of media segments.
    pub fn segments_count(&self) -> usize {
        self.media_parts
            .iter()
            .map(|part| part.media_segments.len())
            .sum()
    }
}

/// Smooth Streaming metadata.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MssData {
    pub four_cc: String,
    pub codec_private_data: String,
    pub stream_type: String,
    pub timescale: i32,
    pub sampling_rate: i32,
    pub channels: i32,
    pub bits_per_sample: i32,
    pub nal_unit_length_field: i32,
    pub duration: i64,
    pub is_protection: bool,
    pub protection_system_id: String,
    pub protection_data: String,
}

/// Stream metadata used by selectors and progress events.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Stream {
    /// Stable stream identifier.
    pub id: String,
    /// Stream media type when known.
    pub media_type: Option<MediaType>,
    /// Group identifier.
    pub group_id: Option<String>,
    /// Language tag when known.
    pub language: Option<String>,
    /// Human-readable stream name when known.
    pub name: Option<String>,
    /// Default flag when known.
    pub default: Option<Choice>,
    /// Forced subtitle/audio flag when known.
    pub forced: Option<Choice>,
    /// Duration skipped by selection/range logic.
    pub skipped_duration: Option<f64>,
    /// Smooth Streaming metadata.
    pub mss_data: Option<MssData>,
    /// Bandwidth in bits per second when known.
    pub bandwidth: Option<i64>,
    /// Codec string when known.
    pub codecs: Option<String>,
    /// Resolution label when known.
    pub resolution: Option<String>,
    /// Frame rate when known.
    pub frame_rate: Option<f64>,
    /// Channel label when known.
    pub channels: Option<String>,
    /// Stream extension hint when known.
    pub extension: Option<String>,
    /// DASH role when known.
    pub role: Option<RoleType>,
    /// Video range or color range metadata.
    pub video_range: Option<String>,
    /// HLS/DASH characteristics metadata.
    pub characteristics: Option<String>,
    /// Publish time as source text until a time crate is introduced.
    pub publish_time: Option<String>,
    /// Associated external audio group ID.
    pub audio_id: Option<String>,
    /// Associated external video group ID.
    pub video_id: Option<String>,
    /// Associated external subtitle group ID.
    pub subtitle_id: Option<String>,
    /// DASH period ID.
    pub period_id: Option<String>,
    /// Current URL.
    pub url: String,
    /// Original URL before URL processing.
    pub original_url: String,
    /// Expanded playlist.
    pub playlist: Option<Playlist>,
}

impl Stream {
    /// Number of media segments in the playlist.
    pub fn segments_count(&self) -> usize {
        self.playlist.as_ref().map_or(0, Playlist::segments_count)
    }

    /// Sum of media segment durations in seconds.
    pub fn total_duration(&self) -> Option<f64> {
        self.playlist.as_ref().map(Playlist::total_duration)
    }
}

/// Parsed manifest model.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Manifest {
    /// Extractor family that produced this manifest.
    pub extractor_type: Option<ExtractorType>,
    /// Source URL or path.
    pub source: Option<String>,
    /// Streams exposed by the manifest.
    pub streams: Vec<Stream>,
    /// Non-fatal parser warnings.
    pub warnings: Vec<String>,
    /// Whether any selected playlist should be treated as live.
    pub is_live: bool,
}

/// Sorts streams using compatibility ordering.
pub fn sort_streams_compatible(streams: &mut [Stream]) {
    streams.sort_by(compare_streams_compatible);
}

/// Compares streams using media type, bandwidth descending, then audio channel order descending.
pub fn compare_streams_compatible(left: &Stream, right: &Stream) -> Ordering {
    media_rank(left.media_type)
        .cmp(&media_rank(right.media_type))
        .then_with(|| compare_option_i64_desc(left.bandwidth, right.bandwidth))
        .then_with(|| audio_channel_order(right).cmp(&audio_channel_order(left)))
}

/// Parses the audio channel ordering key used by selection.
pub fn audio_channel_order(stream: &Stream) -> i32 {
    let Some(channels) = &stream.channels else {
        return 0;
    };
    let first = channels.split('/').next().unwrap_or_default();
    first.parse::<i32>().unwrap_or_default()
}

fn media_rank(media_type: Option<MediaType>) -> i32 {
    match media_type {
        None => -1,
        Some(MediaType::Audio) => 0,
        Some(MediaType::Video) => 1,
        Some(MediaType::Subtitles) => 2,
        Some(MediaType::ClosedCaptions) => 3,
    }
}

fn compare_option_i64_desc(left: Option<i64>, right: Option<i64>) -> Ordering {
    match (left, right) {
        (Some(left), Some(right)) => right.cmp(&left),
        (Some(_), None) => Ordering::Less,
        (None, Some(_)) => Ordering::Greater,
        (None, None) => Ordering::Equal,
    }
}

fn normalize_token(value: &str) -> String {
    value
        .chars()
        .filter(|ch| *ch != '-' && *ch != '_' && *ch != ' ')
        .flat_map(char::to_lowercase)
        .collect()
}

/// API stream selection model.
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub enum StreamSelector {
    /// Preserve CLI-interactive behavior where available.
    #[default]
    Interactive,
    /// Auto-select the best basic stream, audio languages, and subtitles.
    Auto,
    /// Select subtitles only.
    SubtitlesOnly,
    /// Select explicit stream identifiers.
    ExplicitIds(Vec<String>),
}