diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..575ee91 --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,27 @@ +name: Rust CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Set up rust + uses: actions/checkout@v2 + - name: Install cargo-audit + run: cargo install cargo-audit + - name: Build + run: cargo build --verbose + - name: Test + run: cargo test --verbose + - name: Clippy + run: cargo clippy --verbose -- -D warnings + - name: Audit + run: cargo audit \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 0939937..4e8e438 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hls-playlist" -description = "A library for parsing HLS playlists (aka extended M3U playlists)." +description = "A library for serializing and deserializing HLS playlists (aka extended M3U playlists)." version = "0.1.0" edition = "2021" authors = ["Logan Wemyss"] diff --git a/README.md b/README.md index 5042581..60a462e 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,32 @@ # HLS Playlist -A library for parsing HLS playlists (aka extended M3U playlists). +A library for serializing and deserializing HLS playlists (aka extended M3U playlists). As specified by [this updated version of RFC 8216](https://datatracker.ietf.org/doc/html/draft-pantos-hls-rfc8216bis). ## Usage -TODO +```rust +use hls_playlist::tags::Tag; + +let mut output = vec![]; + +Tag::M3u.serialize(&mut output).unwrap(); +Tag::XStart { offset_seconds: 10.0, is_precise: false }.serialize(&mut output).unwrap(); + +assert_eq!(String::from_utf8(output).unwrap(), "\ +#EXTM3U +#EXT-X-START:TIME-OFFSET=10 +"); +``` ## Roadmap -This library is 100% finished and feature-complete as far as I'm aware with the exception of deserializing playlists. +This library is 100% finished and feature-complete as far as serializing tags goes, but I'd like to eventually implement a serializer for the higher level playlist representation, and also deserialization. -- [x] Serialization -- [ ] Deserialization \ No newline at end of file +- [x] Serialize steering manifest +- [x] Serialize tags +- [ ] Serialize playlist +- [ ] Deserialize steering manifest +- [ ] Deserialize tags +- [ ] Deserialize playlist \ No newline at end of file diff --git a/src/playlist.rs b/src/playlist.rs index 6e8ce4a..c724058 100644 --- a/src/playlist.rs +++ b/src/playlist.rs @@ -14,10 +14,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{io, time::SystemTime}; - -use crate::tags::Tag; - /// A playlist representing a list of renditions and variants of a given piece of media. pub struct MultivariantPlaylist { /// True if all media samples in a Media Segment can be decoded without information @@ -240,7 +236,12 @@ pub struct MediaPlaylist { /// The media sequence number of the first segment in [`MediaPlaylist::segments`]. pub first_media_sequence_number: u64, - // pub discontinuity_sequence_number: todo!(), // TODO: What is this? + + /// Allows synchronization between different renditions of the same `VariantStream` + /// or different `VariantStream`s that have EXT-X-DISCONTINUITY tags in their + /// Media Playlists. + pub discontinuity_sequence_number: u64, + /// True if no more Media Segments will be added to the Media Playlist file. pub finished: bool, @@ -346,7 +347,7 @@ pub struct MediaSegment { /// If Some, the first sample of the `MediaSegment` is associated with this /// time. - pub absolute_time: Option, + pub absolute_time: Option>, /// If true, this `MediaSegment` does not contain media data /// and should not be loaded by clients. @@ -410,201 +411,200 @@ pub struct StartOffset { pub is_precise: bool, } -impl MediaPlaylist { - /// Serializes the `MediaPlaylist` as a extended M3U playlist into `output`. - /// Guaranteed to write valid UTF-8 only. - /// - /// This method makes lots of small calls to write on `output`. If the implementation - /// of write on `output` makes a syscall, like with a `TcpStream`, you should wrap it - /// in a [`std::io::BufWriter`]. - /// - /// # Errors - /// - /// May return `Err` when encountering an io error on `output`. - pub fn serialize(&self, mut output: impl io::Write) -> io::Result<()> { - Tag::M3u.serialize(&mut output)?; - todo!(); - } -} - -#[cfg(test)] -mod tests { - use crate::{EncryptionMethod, FloatOrInteger, PreloadHint}; - - use super::*; - - #[test] - fn serialize_media_playlist() { - let mut output = Vec::new(); - - let playlist = MediaPlaylist { - segments: vec![ - MediaSegment { - uri: "https://example.com/1.mp4".into(), - duration_seconds: FloatOrInteger::Float(5.045), - title: Some("This is the first thingy!".into()), - byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(8000)), - is_discontinuity: false, - encryption: Some(EncryptionMethod::Aes128 { - uri: "https://example.com/key.key".into(), - iv: Some(0x0F91_DC05), - key_format: crate::KeyFormat::Identity, - key_format_versions: vec![1, 7, 6], - }), - media_initialization_section: Some(MediaInitializationSection { - uri: "https://example.com/1.mp4".into(), - range: Some(crate::ByteRangeWithOffset { - length_bytes: 400, - start_offset_bytes: 0, - }), - }), - absolute_time: Some(SystemTime::now()), - is_gap: false, - parts: vec![ - PartialSegment { - uri: "https://example.com/1.mp4".into(), - duration_in_seconds: 5.045 / 2.0, - is_independent: true, - byte_range: Some(crate::ByteRange { - length_bytes: 400, - start_offset_bytes: None, - }), - is_gap: false, - }, - PartialSegment { - uri: "https://example.com/1.mp4".into(), - duration_in_seconds: 5.045 / 2.0, - is_independent: false, - byte_range: Some(crate::ByteRange { - length_bytes: 400, - start_offset_bytes: Some(400), - }), - is_gap: false, - }, - ], - }, - MediaSegment { - uri: "https://example.com/2.mp4".into(), - duration_seconds: FloatOrInteger::Float(5.045), - title: Some("This is the second thingy!".into()), - byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(8000)), - is_discontinuity: false, - encryption: Some(EncryptionMethod::Aes128 { - uri: "https://example.com/key.key".into(), - iv: Some(0x0F91_DC05), - key_format: crate::KeyFormat::Identity, - key_format_versions: vec![1, 7, 6], - }), - media_initialization_section: Some(MediaInitializationSection { - uri: "https://example.com/1.mp4".into(), - range: Some(crate::ByteRangeWithOffset { - length_bytes: 400, - start_offset_bytes: 0, - }), - }), - absolute_time: None, - is_gap: false, - parts: vec![ - PartialSegment { - uri: "https://example.com/2.mp4".into(), - duration_in_seconds: 5.045 / 2.0, - is_independent: true, - byte_range: Some(crate::ByteRange { - length_bytes: 400, - start_offset_bytes: None, - }), - is_gap: false, - }, - PartialSegment { - uri: "https://example.com/2.mp4".into(), - duration_in_seconds: 5.045 / 2.0, - is_independent: false, - byte_range: Some(crate::ByteRange { - length_bytes: 400, - start_offset_bytes: Some(400), - }), - is_gap: false, - }, - ], - }, - MediaSegment { - uri: "https://example.com/3.mp4".into(), - duration_seconds: FloatOrInteger::Float(5.045), - title: Some("This is the third thingy!".into()), - byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(5000)), - is_discontinuity: false, - encryption: None, - media_initialization_section: None, - absolute_time: None, - is_gap: false, - parts: vec![ - PartialSegment { - uri: "https://example.com/3.mp4".into(), - duration_in_seconds: 5.045 / 2.0, - is_independent: true, - byte_range: Some(crate::ByteRange { - length_bytes: 400, - start_offset_bytes: None, - }), - is_gap: false, - }, - PartialSegment { - uri: "https://example.com/3.mp4".into(), - duration_in_seconds: 5.045 / 2.0, - is_independent: false, - byte_range: Some(crate::ByteRange { - length_bytes: 400, - start_offset_bytes: Some(400), - }), - is_gap: false, - }, - ], - }, - ], - start_offset: Some(StartOffset { - offset_in_seconds: 2.0, - is_precise: false, - }), - variables: vec![("cool".into(), "foo".into())], - is_independent_segments: false, - target_duration: 5, - first_media_sequence_number: 0, - finished: false, - playlist_type: Some(crate::PlaylistType::Event), - hold_back_seconds: None, - part_information: Some(PartInformation { - part_hold_back_seconds: 3.0 * 3.0, - part_target_duration: 3, - }), - iframes_only: false, - playlist_delta_updates_information: Some(crate::DeltaUpdateInfo { - skip_boundary_seconds: 3.0 * 6.0, - can_skip_dateranges: true, - }), - supports_blocking_playlist_reloads: true, - metadata: MediaMetadata { - date_ranges: vec![], - skip: None, - preload_hints: vec![PreloadHint { - hint_type: crate::PreloadHintType::Part, - uri: "https://example.com/4.mp4".into(), - start_byte_offset: 0, - length_in_bytes: Some(400), - }], - rendition_reports: vec![crate::RenditionReport { - uri: Some("https://example.com/different.m3u8".into()), - last_sequence_number: None, - last_part_index: None, - }], - }, - }; - - playlist.serialize(&mut output).unwrap(); - - assert_eq!( - String::from_utf8(output).unwrap(), - "#EXTM3U -#EXT-X-VERSION:todo" - ); - } -} +// impl MediaPlaylist { +// /// Serializes the `MediaPlaylist` as a extended M3U playlist into `output`. +// /// Guaranteed to write valid UTF-8 only. +// /// +// /// This method makes lots of small calls to write on `output`. If the implementation +// /// of write on `output` makes a syscall, like with a `TcpStream`, you should wrap it +// /// in a [`std::io::BufWriter`]. +// /// +// /// # Errors +// /// +// /// May return `Err` when encountering an io error on `output`. +// pub fn serialize(&self, mut output: impl io::Write) -> io::Result<()> { +// Tag::M3u.serialize(&mut output)?; +// todo!(); +// } +// } + +// #[cfg(test)] +// mod tests { +// use crate::{EncryptionMethod, FloatOrInteger, PreloadHint}; + +// use super::*; + +// #[test] +// fn serialize_media_playlist() { +// let mut output = Vec::new(); + +// let playlist = MediaPlaylist { +// segments: vec![ +// MediaSegment { +// uri: "https://example.com/1.mp4".into(), +// duration_seconds: FloatOrInteger::Float(5.045), +// title: Some("This is the first thingy!".into()), +// byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(8000)), +// is_discontinuity: false, +// encryption: Some(EncryptionMethod::Aes128 { +// uri: "https://example.com/key.key".into(), +// iv: Some(0x0F91_DC05), +// key_format: crate::KeyFormat::Identity, +// key_format_versions: vec![1, 7, 6], +// }), +// media_initialization_section: Some(MediaInitializationSection { +// uri: "https://example.com/1.mp4".into(), +// range: Some(crate::ByteRangeWithOffset { +// length_bytes: 400, +// start_offset_bytes: 0, +// }), +// }), +// absolute_time: Some(SystemTime::now()), +// is_gap: false, +// parts: vec![ +// PartialSegment { +// uri: "https://example.com/1.mp4".into(), +// duration_in_seconds: 5.045 / 2.0, +// is_independent: true, +// byte_range: Some(crate::ByteRange { +// length_bytes: 400, +// start_offset_bytes: None, +// }), +// is_gap: false, +// }, +// PartialSegment { +// uri: "https://example.com/1.mp4".into(), +// duration_in_seconds: 5.045 / 2.0, +// is_independent: false, +// byte_range: Some(crate::ByteRange { +// length_bytes: 400, +// start_offset_bytes: Some(400), +// }), +// is_gap: false, +// }, +// ], +// }, +// MediaSegment { +// uri: "https://example.com/2.mp4".into(), +// duration_seconds: FloatOrInteger::Float(5.045), +// title: Some("This is the second thingy!".into()), +// byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(8000)), +// is_discontinuity: false, +// encryption: Some(EncryptionMethod::Aes128 { +// uri: "https://example.com/key.key".into(), +// iv: Some(0x0F91_DC05), +// key_format: crate::KeyFormat::Identity, +// key_format_versions: vec![1, 7, 6], +// }), +// media_initialization_section: Some(MediaInitializationSection { +// uri: "https://example.com/1.mp4".into(), +// range: Some(crate::ByteRangeWithOffset { +// length_bytes: 400, +// start_offset_bytes: 0, +// }), +// }), +// absolute_time: None, +// is_gap: false, +// parts: vec![ +// PartialSegment { +// uri: "https://example.com/2.mp4".into(), +// duration_in_seconds: 5.045 / 2.0, +// is_independent: true, +// byte_range: Some(crate::ByteRange { +// length_bytes: 400, +// start_offset_bytes: None, +// }), +// is_gap: false, +// }, +// PartialSegment { +// uri: "https://example.com/2.mp4".into(), +// duration_in_seconds: 5.045 / 2.0, +// is_independent: false, +// byte_range: Some(crate::ByteRange { +// length_bytes: 400, +// start_offset_bytes: Some(400), +// }), +// is_gap: false, +// }, +// ], +// }, +// MediaSegment { +// uri: "https://example.com/3.mp4".into(), +// duration_seconds: FloatOrInteger::Float(5.045), +// title: Some("This is the third thingy!".into()), +// byte_range_or_bitrate: Some(ByteRangeOrBitrate::Bitrate(5000)), +// is_discontinuity: false, +// encryption: None, +// media_initialization_section: None, +// absolute_time: None, +// is_gap: false, +// parts: vec![ +// PartialSegment { +// uri: "https://example.com/3.mp4".into(), +// duration_in_seconds: 5.045 / 2.0, +// is_independent: true, +// byte_range: Some(crate::ByteRange { +// length_bytes: 400, +// start_offset_bytes: None, +// }), +// is_gap: false, +// }, +// PartialSegment { +// uri: "https://example.com/3.mp4".into(), +// duration_in_seconds: 5.045 / 2.0, +// is_independent: false, +// byte_range: Some(crate::ByteRange { +// length_bytes: 400, +// start_offset_bytes: Some(400), +// }), +// is_gap: false, +// }, +// ], +// }, +// ], +// start_offset: Some(StartOffset { +// offset_in_seconds: 2.0, +// is_precise: false, +// }), +// variables: vec![("cool".into(), "foo".into())], +// is_independent_segments: false, +// target_duration: 5, +// first_media_sequence_number: 0, +// finished: false, +// playlist_type: Some(crate::PlaylistType::Event), +// hold_back_seconds: None, +// part_information: Some(PartInformation { +// part_hold_back_seconds: 3.0 * 3.0, +// part_target_duration: 3, +// }), +// iframes_only: false, +// playlist_delta_updates_information: Some(crate::DeltaUpdateInfo { +// skip_boundary_seconds: 3.0 * 6.0, +// can_skip_dateranges: true, +// }), +// supports_blocking_playlist_reloads: true, +// metadata: MediaMetadata { +// date_ranges: vec![], +// skip: None, +// preload_hints: vec![PreloadHint { +// hint_type: crate::PreloadHintType::Part, +// uri: "https://example.com/4.mp4".into(), +// start_byte_offset: 0, +// length_in_bytes: Some(400), +// }], +// rendition_reports: vec![crate::RenditionReport { +// uri: Some("https://example.com/different.m3u8".into()), +// last_sequence_number: None, +// last_part_index: None, +// }], +// }, +// }; + +// playlist.serialize(&mut output).unwrap(); + +// assert_eq!( +// String::from_utf8(output).unwrap(), +// "TODO" +// ); +// } +// } diff --git a/src/tags.rs b/src/tags.rs index 424f592..dec2e40 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -19,15 +19,15 @@ mod serialize; /// A representation of all possible tags. #[derive(Debug, Clone, PartialEq)] pub enum Tag { + /// The EXTM3U tag indicates that the file is an Extended M3U Playlist file. + M3u, + /// The EXT-X-VERSION tag indicates the compatibility version of the /// Playlist file, its associated media, and its server. XVersion { version: u8, }, - /// The EXTM3U tag indicates that the file is an Extended M3U Playlist file. - M3u, - /// The EXT-X-DEFINE tag provides a Playlist variable definition or /// declaration. XDefine(crate::DefinitionType), diff --git a/src/tags/serialize.rs b/src/tags/serialize.rs index b0f53d7..fd6cccf 100644 --- a/src/tags/serialize.rs +++ b/src/tags/serialize.rs @@ -1,3 +1,17 @@ +// Copyright 2024 Logan Wemyss +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + use std::io; use crate::{ @@ -15,13 +29,18 @@ impl Tag { /// of write on `output` makes a syscall, like with a `TcpStream`, you should wrap it /// in a [`std::io::BufWriter`]. /// + /// # Note + /// + /// This method is not guaranteed to write valid M3U tags. It's your job to create + /// valid cinput. + /// /// # Errors /// /// May return `Err` when encountering an io error on `output`. pub fn serialize(&self, mut output: impl io::Write) -> io::Result<()> { match self { - Self::XVersion { version } => write!(output, "#EXT-X-VERSION:{version}")?, Self::M3u => output.write_all(b"#EXTM3U")?, + Self::XVersion { version } => write!(output, "#EXT-X-VERSION:{version}")?, Self::XDefine(definition) => match definition { crate::DefinitionType::Inline { name, value } => { write!(output, "#EXT-X-DEFINE:NAME=\"{name}\",VALUE=\"{value}\"")?;