quick-m3u8 0.8.0

Parser for M3U8 Playlist format as defined in HLS draft-pantos-hls-rfc8216
Documentation
#[cfg(not(feature = "chrono"))]
use crate::{
    date::{DateTime, DateTimeTimezoneOffset},
    error::{DateTimeSyntaxError, GenericSyntaxError},
};
use crate::{error::ParseNumberError, line::ParsedByteSlice};
use memchr::memchr;
#[cfg(not(feature = "chrono"))]
use memchr::memchr3;
use std::borrow::Cow;

pub trait AsStaticCow {
    fn as_cow(&self) -> Cow<'static, str>;
}

pub fn split_on_new_line<'a>(bytes: &'a [u8]) -> ParsedByteSlice<'a, &'a [u8]> {
    match memchr(b'\n', bytes) {
        Some(n) if n > 0 && bytes[n - 1] == b'\r' => ParsedByteSlice {
            parsed: &bytes[..(n - 1)],
            remaining: Some(&bytes[(n + 1)..]),
        },
        Some(n) => ParsedByteSlice {
            parsed: &bytes[..n],
            remaining: Some(&bytes[(n + 1)..]),
        },
        None => ParsedByteSlice {
            parsed: bytes,
            remaining: None,
        },
    }
}

pub(crate) fn str_from(bytes: &[u8]) -> &str {
    unsafe {
        // SAFETY: The input for bytes is always &str in this project, and I only break on single
        // byte characters, so this is safe to do unchecked.
        std::str::from_utf8_unchecked(bytes)
    }
}

#[cfg(not(feature = "chrono"))]
pub fn parse_date_time_bytes<'a>(
    input: &'a [u8],
) -> Result<ParsedByteSlice<'a, DateTime>, DateTimeSyntaxError> {
    match input.get(4) {
        Some(b'-') => (),
        b => {
            return Err(DateTimeSyntaxError::UnexpectedYearToMonthSeparator(
                b.copied(),
            ));
        }
    };
    let date_fullyear = parse_u32(&input[..4]).map_err(DateTimeSyntaxError::InvalidYear)?;
    match input.get(7) {
        Some(b'-') => (),
        b => {
            return Err(DateTimeSyntaxError::UnexpectedMonthToDaySeparator(
                b.copied(),
            ));
        }
    };
    let date_month = parse_u8(&input[5..7]).map_err(DateTimeSyntaxError::InvalidMonth)?;
    match input.get(10) {
        // As per the note in https://datatracker.ietf.org/doc/html/rfc3339#section-5.6
        // > NOTE: ISO 8601 defines date and time separated by "T". Applications using this syntax
        // > may choose, for the sake of readability, to specify a full-date and full-time separated
        // by (say) a space character.
        Some(b't') | Some(b'T') | Some(b' ') => (),
        b => return Err(DateTimeSyntaxError::UnexpectedDayHourSeparator(b.copied())),
    };
    let date_mday = parse_u8(&input[8..10]).map_err(DateTimeSyntaxError::InvalidDay)?;
    match input.get(13) {
        Some(b':') => (),
        b => {
            return Err(DateTimeSyntaxError::UnexpectedHourMinuteSeparator(
                b.copied(),
            ));
        }
    }
    let time_hour = parse_u8(&input[11..13]).map_err(DateTimeSyntaxError::InvalidHour)?;
    match input.get(16) {
        Some(b':') => (),
        b => {
            return Err(DateTimeSyntaxError::UnexpectedMinuteSecondSeparator(
                b.copied(),
            ));
        }
    };
    let time_minute = parse_u8(&input[14..16]).map_err(DateTimeSyntaxError::InvalidMinute)?;
    let time_offset_byte_index = match memchr3(b'Z', b'+', b'-', &input[16..]) {
        Some(n) => n + 16,
        None => match memchr(b'z', &input[16..]) {
            Some(n) => n + 16,
            None => return Err(GenericSyntaxError::UnexpectedEndOfLine)?,
        },
    };
    let time_offset_byte = input[time_offset_byte_index];
    let time_second = fast_float2::parse(&input[17..time_offset_byte_index])
        .map_err(|_| DateTimeSyntaxError::InvalidSecond)?;
    match time_offset_byte {
        b'Z' | b'z' => {
            let remaining = if input.get(time_offset_byte_index + 1).is_some() {
                split_on_new_line(&input[(time_offset_byte_index + 1)..])
            } else {
                ParsedByteSlice {
                    parsed: b"" as &[u8],
                    remaining: None,
                }
            };
            if !remaining.parsed.is_empty() {
                return Err(DateTimeSyntaxError::UnexpectedCharactersAfterTimezone);
            };
            let remaining = remaining.remaining;
            Ok(ParsedByteSlice {
                parsed: DateTime {
                    date_fullyear,
                    date_month,
                    date_mday,
                    time_hour,
                    time_minute,
                    time_second,
                    timezone_offset: DateTimeTimezoneOffset {
                        time_hour: 0,
                        time_minute: 0,
                    },
                },
                remaining,
            })
        }
        _ => {
            let multiplier = if time_offset_byte == b'-' { -1i8 } else { 1i8 };
            match input.get(time_offset_byte_index + 3) {
                Some(b':') => (),
                b => {
                    return Err(DateTimeSyntaxError::UnexpectedTimezoneHourMinuteSeparator(
                        b.copied(),
                    ));
                }
            };
            let timeoffset_hour =
                parse_u8(&input[(time_offset_byte_index + 1)..(time_offset_byte_index + 3)])
                    .map_err(DateTimeSyntaxError::InvalidTimezoneHour)? as i8;
            let timeoffset_hour = multiplier * timeoffset_hour;
            match input.get(time_offset_byte_index + 4) {
                Some(_) => (),
                None => return Err(GenericSyntaxError::UnexpectedEndOfLine)?,
            };
            let ParsedByteSlice { parsed, remaining } =
                split_on_new_line(&input[(time_offset_byte_index + 4)..]);
            let timeoffset_minute =
                parse_u8(parsed).map_err(DateTimeSyntaxError::InvalidTimezoneMinute)?;
            Ok(ParsedByteSlice {
                parsed: DateTime {
                    date_fullyear,
                    date_month,
                    date_mday,
                    time_hour,
                    time_minute,
                    time_second,
                    timezone_offset: DateTimeTimezoneOffset {
                        time_hour: timeoffset_hour,
                        time_minute: timeoffset_minute,
                    },
                },
                remaining,
            })
        }
    }
}

// Directly copied from https://users.rust-lang.org/t/parse-number-from-u8/104487/6
macro_rules! parse_num_impl {
    ($fn_name:ident -> $ty:ident) => {
        pub fn $fn_name(bytes: &[u8]) -> Result<$ty, ParseNumberError> {
            if bytes.is_empty() {
                return Err(ParseNumberError::Empty);
            }
            let mut n: $ty = 0;
            for &byte in bytes {
                let digit = match byte.checked_sub(b'0') {
                    None => return Err(ParseNumberError::InvalidDigit(byte)),
                    Some(digit) if digit > 9 => {
                        return Err(ParseNumberError::InvalidDigit(byte));
                    }
                    Some(digit) => {
                        debug_assert!((0..=9).contains(&digit));
                        $ty::from(digit)
                    }
                };
                n = n
                    .checked_mul(10)
                    .and_then(|n| n.checked_add(digit))
                    .ok_or_else(|| ParseNumberError::NumberTooBig)?;
            }
            Ok(n)
        }
    };
}

parse_num_impl!(parse_u64 -> u64);
#[cfg(not(feature = "chrono"))]
parse_num_impl!(parse_u32 -> u32);
#[cfg(not(feature = "chrono"))]
parse_num_impl!(parse_u8 -> u8);

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(not(feature = "chrono"))]
    use crate::date_time;
    use pretty_assertions::assert_eq;

    #[cfg(not(feature = "chrono"))]
    #[test]
    fn date_time_parse_with_space_for_day_hour_separator_still_works() {
        assert_eq!(
            date_time!(2025-08-02 T 20:33:45.123 -05:00),
            parse_date_time_bytes(b"2025-08-02 20:33:45.123-05:00")
                .unwrap()
                .parsed
        );
    }

    #[test]
    fn split_on_new_line_should_have_no_remaining_when_no_new_line() {
        assert_eq!(
            ParsedByteSlice {
                parsed: b"test" as &[u8],
                remaining: None,
            },
            split_on_new_line(b"test")
        );
    }

    #[test]
    fn split_on_new_line_should_remove_lf() {
        assert_eq!(
            ParsedByteSlice {
                parsed: b"test" as &[u8],
                remaining: Some(b"remaining"),
            },
            split_on_new_line(b"test\nremaining")
        );
    }

    #[test]
    fn split_on_new_line_should_remove_crlf() {
        assert_eq!(
            ParsedByteSlice {
                parsed: b"test" as &[u8],
                remaining: Some(b"remaining"),
            },
            split_on_new_line(b"test\r\nremaining")
        );
    }

    #[test]
    fn split_on_new_line_should_have_empty_remaining_if_lf_last_char() {
        assert_eq!(
            ParsedByteSlice {
                parsed: b"test" as &[u8],
                remaining: Some(b""),
            },
            split_on_new_line(b"test\n")
        );
    }

    #[test]
    fn split_on_new_line_should_have_empty_remaining_if_crlf_last_char() {
        assert_eq!(
            ParsedByteSlice {
                parsed: b"test" as &[u8],
                remaining: Some(b""),
            },
            split_on_new_line(b"test\r\n")
        );
    }

    #[test]
    fn split_on_new_line_when_lf_is_first_byte_should_not_panic() {
        assert_eq!(
            ParsedByteSlice {
                parsed: b"" as &[u8],
                remaining: Some(b"test"),
            },
            split_on_new_line(b"\ntest")
        );
    }
}