diff --git a/Cargo.lock b/Cargo.lock index 0acab43dc..c74c4e4d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5770,6 +5770,17 @@ dependencies = [ "scuffle-expgolomb", ] +[[package]] +name = "scuffle-hls" +version = "0.1.0" +dependencies = [ + "chrono", + "document-features", + "scuffle-changelog", + "thiserror 2.0.16", + "url", +] + [[package]] name = "scuffle-http" version = "0.3.2" @@ -8948,4 +8959,3 @@ dependencies = [ name = "testcontainers" version = "0.25.0" source = "git+https://github.com/testcontainers/testcontainers-rs.git?rev=6d8e248a5637a3bb8ac0bb390717f6c327ffbad1#6d8e248a5637a3bb8ac0bb390717f6c327ffbad1" - diff --git a/Cargo.toml b/Cargo.toml index caeb4bfb8..8ea4832c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ members = [ "crates/future-ext", "crates/h264", "crates/h265", + "crates/hls", "crates/http", "crates/metrics", "crates/metrics/derive", diff --git a/cargo_targets.bzl b/cargo_targets.bzl index f39a82b1e..a04959e1d 100644 --- a/cargo_targets.bzl +++ b/cargo_targets.bzl @@ -32,6 +32,7 @@ _packages = [ "//crates/future-ext", "//crates/h264", "//crates/h265", + "//crates/hls", "//crates/http", "//crates/metrics", "//crates/metrics/derive", diff --git a/crates/hls/BUILD.bazel b/crates/hls/BUILD.bazel new file mode 100644 index 000000000..b6aea8ae4 --- /dev/null +++ b/crates/hls/BUILD.bazel @@ -0,0 +1,14 @@ +load("//misc/utils/rust:manifest.bzl", "cargo_toml") +load("//misc/utils/rust:package.bzl", "scuffle_package") + +cargo_toml() + +scuffle_package( + compile_data = [ + ":CHANGELOG.md", + ":Cargo.toml", + ], + crate_name = "scuffle-hls", + proc_macro_deps = ["//crates/changelog"], + deps = [], +) diff --git a/crates/hls/CHANGELOG.md b/crates/hls/CHANGELOG.md new file mode 100644 index 000000000..29066c429 --- /dev/null +++ b/crates/hls/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + + + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.4](https://github.com/ScuffleCloud/scuffle/releases/tag/scuffle-aac-v0.1.4) - 2025-05-17 + +[View diff on diff.rs](https://diff.rs/scuffle-aac/0.1.3/scuffle-aac/0.1.4/Cargo.toml) + +### 🛠️ Non-breaking changes + +- chore: fix changelog entry & release process ([#465](https://github.com/scufflecloud/scuffle/pull/465)) (@troykomodo, @SimaoMoreira5228, @philipch07) + +## [0.1.3](https://github.com/ScuffleCloud/scuffle/releases/tag/scuffle-aac-v0.1.3) - 2025-05-14 + +[View diff on diff.rs](https://diff.rs/scuffle-aac/0.1.2/scuffle-aac/0.1.3/Cargo.toml) + +### 🛠️ Non-breaking changes + +- chore: cleanup readme and crate docs ([#458](https://github.com/scufflecloud/scuffle/pull/458)) (@troykomodo) + +## [0.1.2](https://github.com/ScuffleCloud/scuffle/releases/tag/scuffle-aac-v0.1.2) - 2025-04-27 + +### 🛠️ Non-breaking changes + +- docs: improved documentation ([#372](https://github.com/scufflecloud/scuffle/pull/372)) (@lennartkloock) + +## [0.1.1](https://github.com/ScuffleCloud/scuffle/releases/tag/scuffle-aac-v0.1.1) - 2025-02-21 + +### 🛠️ Non-breaking changes + +- chore: update to rust edition 2024 ([#373](https://github.com/scufflecloud/scuffle/pull/373)) (@TroyKomodo) diff --git a/crates/hls/Cargo.toml b/crates/hls/Cargo.toml new file mode 100644 index 000000000..da66c7553 --- /dev/null +++ b/crates/hls/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "scuffle-hls" +version = "0.1.0" +authors = ["Scuffle "] +documentation = "https://docs.rs/scuffle-hls" +edition = "2024" +keywords = ["hls"] +license = "MIT OR Apache-2.0" +readme = "README.md" +repository = "https://github.com/scufflecloud/scuffle" +description = "Generating HLS playlists" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } + +[features] +## Enables changelog and documentation of feature flags +docs = ["dep:scuffle-changelog", "dep:document-features"] + +[dependencies] +chrono = "0.4" +document-features = { optional = true, version = "0.2" } +scuffle-changelog = { optional = true, path = "../changelog", version = "0.1" } +thiserror = "2" +url = "2" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = [ + "--cfg", + "docsrs", + "--sort-modules-by-appearance", + "--generate-link-to-definition", +] + +[package.metadata.sync-readme.rustdoc-mappings] +changelog = "./CHANGELOG.md" + +[package.metadata.sync-readme.badges] +docs-rs = true +crates-io = true +license = true +codecov = true diff --git a/crates/hls/LICENSE.Apache-2.0 b/crates/hls/LICENSE.Apache-2.0 new file mode 100644 index 000000000..a89833316 --- /dev/null +++ b/crates/hls/LICENSE.Apache-2.0 @@ -0,0 +1,13 @@ +Copyright 2025 Scuffle LLC. + +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. diff --git a/crates/hls/LICENSE.MIT b/crates/hls/LICENSE.MIT new file mode 100644 index 000000000..80da3c7c2 --- /dev/null +++ b/crates/hls/LICENSE.MIT @@ -0,0 +1,7 @@ +Copyright 2025 Scuffle LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/hls/README.md b/crates/hls/README.md new file mode 100644 index 000000000..7db5a0db3 --- /dev/null +++ b/crates/hls/README.md @@ -0,0 +1,35 @@ + + +# scuffle-aac + + +> [!WARNING] +> This crate is under active development and may not be stable. + + +[![docs.rs](https://img.shields.io/docsrs/scuffle-aac/0.1.4.svg?logo=docs.rs&label=docs.rs&style=flat-square)](https://docs.rs/scuffle-aac/0.1.4) +[![crates.io](https://img.shields.io/badge/crates.io-v0.1.4-orange?style=flat-square&logo=rust&logoColor=white)](https://crates.io/crates/scuffle-aac/0.1.4) +![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-purple.svg?style=flat-square) +![Crates.io Size](https://img.shields.io/crates/size/scuffle-aac/0.1.4.svg?style=flat-square) +![Crates.io Downloads](https://img.shields.io/crates/dv/scuffle-aac/0.1.4.svg?&label=downloads&style=flat-square) +[![Codecov](https://img.shields.io/codecov/c/github/scufflecloud/scuffle.svg?label=codecov&logo=codecov&style=flat-square)](https://app.codecov.io/gh/scufflecloud/scuffle) + + +--- + + +A crate for decoding AAC audio headers. + +See the [changelog](./CHANGELOG.md) for a full release history. + +### Feature flags + +* **`docs`** — Enables changelog and documentation of feature flags + +### License + +This project is licensed under the MIT or Apache-2.0 license. +You can choose between one of them if you use this work. + +`SPDX-License-Identifier: MIT OR Apache-2.0` + diff --git a/crates/hls/src/any_playlist.rs b/crates/hls/src/any_playlist.rs new file mode 100644 index 000000000..956efe7ba --- /dev/null +++ b/crates/hls/src/any_playlist.rs @@ -0,0 +1,5 @@ +mod independent_segments; +mod start; + +pub use independent_segments::*; +pub use start::*; diff --git a/crates/hls/src/any_playlist/independent_segments.rs b/crates/hls/src/any_playlist/independent_segments.rs new file mode 100644 index 000000000..518823e50 --- /dev/null +++ b/crates/hls/src/any_playlist/independent_segments.rs @@ -0,0 +1,7 @@ +use crate::Tag; + +pub struct IndependentSegments; + +impl Tag for IndependentSegments { + const NAME: &'static str = "EXT-X-INDEPENDENT-SEGMENTS"; +} diff --git a/crates/hls/src/any_playlist/start.rs b/crates/hls/src/any_playlist/start.rs new file mode 100644 index 000000000..d5fdee8a0 --- /dev/null +++ b/crates/hls/src/any_playlist/start.rs @@ -0,0 +1,37 @@ +use std::fmt::Display; +use std::io; + +use crate::Tag; + +pub enum StartPrecise { + Yes, + No, +} + +impl Display for StartPrecise { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StartPrecise::Yes => write!(f, "YES"), + StartPrecise::No => write!(f, "NO"), + } + } +} + +pub struct Start { + pub time_offset: f64, + pub precise: Option, +} + +impl Tag for Start { + const NAME: &'static str = "EXT-X-START"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":TIME-OFFSET={}", self.time_offset)?; + + if let Some(precise) = self.precise.as_ref() { + write!(writer, ",PRECISE={}", precise)?; + } + + Ok(()) + } +} diff --git a/crates/hls/src/attribute_name.rs b/crates/hls/src/attribute_name.rs new file mode 100644 index 000000000..7a3260347 --- /dev/null +++ b/crates/hls/src/attribute_name.rs @@ -0,0 +1,38 @@ +use std::fmt::Display; +use std::str::FromStr; + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct AttributeName(String); + +#[derive(thiserror::Error, Debug)] +#[error("invalid attribute name")] +pub struct InvalidAttributeNameError; + +impl TryFrom for AttributeName { + type Error = InvalidAttributeNameError; + + fn try_from(value: String) -> Result { + if !value + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-') + { + return Err(InvalidAttributeNameError); + } + + Ok(Self(value)) + } +} + +impl FromStr for AttributeName { + type Err = InvalidAttributeNameError; + + fn from_str(s: &str) -> Result { + Self::try_from(s.to_string()) + } +} + +impl Display for AttributeName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} diff --git a/crates/hls/src/basic.rs b/crates/hls/src/basic.rs new file mode 100644 index 000000000..d2fcf3243 --- /dev/null +++ b/crates/hls/src/basic.rs @@ -0,0 +1,26 @@ +use std::io; + +use crate::Tag; + +pub struct ExtM3u; + +impl Tag for ExtM3u { + const NAME: &'static str = "EXTM3U"; +} + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +pub struct Version(pub u64); + +impl Default for Version { + fn default() -> Self { + Self(1) + } +} + +impl Tag for Version { + const NAME: &'static str = "EXT-X-VERSION"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":{}", self.0) + } +} diff --git a/crates/hls/src/lib.rs b/crates/hls/src/lib.rs new file mode 100644 index 000000000..5a261282b --- /dev/null +++ b/crates/hls/src/lib.rs @@ -0,0 +1,50 @@ +//! Generating HLS playlists. +#![cfg_attr(feature = "docs", doc = "\n\nSee the [changelog][changelog] for a full release history.")] +#![cfg_attr(feature = "docs", doc = "## Feature flags")] +#![cfg_attr(feature = "docs", doc = document_features::document_features!())] +//! ## License +//! +//! This project is licensed under the MIT or Apache-2.0 license. +//! You can choose between one of them if you use this work. +//! +//! `SPDX-License-Identifier: MIT OR Apache-2.0` +#![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))] +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +// #![deny(missing_docs)] +#![deny(unsafe_code)] +#![deny(unreachable_pub)] +#![deny(clippy::mod_module_files)] + +use std::io; + +use crate::basic::Version; + +pub mod any_playlist; +pub mod attribute_name; +pub mod basic; +pub mod master_playlist; +pub mod media_playlist; +pub mod media_segment; + +pub use attribute_name::AttributeName; + +pub trait Tag { + const NAME: &'static str; + + fn min_version(&self) -> Version { + Version::default() + } + + fn write_value(&self, writer: impl io::Write) -> Result<(), io::Error> { + let _ = writer; + Ok(()) + } + + fn write(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + writer.write_all(b"#")?; + writer.write_all(Self::NAME.as_bytes())?; + self.write_value(&mut writer)?; + writer.write_all(b"\n")?; + Ok(()) + } +} diff --git a/crates/hls/src/master_playlist.rs b/crates/hls/src/master_playlist.rs new file mode 100644 index 000000000..293509a08 --- /dev/null +++ b/crates/hls/src/master_playlist.rs @@ -0,0 +1,11 @@ +mod i_frame_stream_inf; +mod media; +mod session_data; +mod session_key; +mod stream_inf; + +pub use i_frame_stream_inf::*; +pub use media::*; +pub use session_data::*; +pub use session_key::*; +pub use stream_inf::*; diff --git a/crates/hls/src/master_playlist/i_frame_stream_inf.rs b/crates/hls/src/master_playlist/i_frame_stream_inf.rs new file mode 100644 index 000000000..c89a44478 --- /dev/null +++ b/crates/hls/src/master_playlist/i_frame_stream_inf.rs @@ -0,0 +1,44 @@ +use std::io; + +use crate::Tag; +use crate::master_playlist::StreamInfHdcpLevel; + +pub struct IFrameStreamInf { + pub bandwidth: u64, + pub average_bandwidth: Option, + pub codecs: Vec, + pub resolution: Option<(u64, u64)>, + pub hdcp_level: Option, + pub video: Option, + pub uri: url::Url, +} + +impl Tag for IFrameStreamInf { + const NAME: &'static str = "EXT-X-I-FRAME-STREAM-INF"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":BANDWIDTH={}", self.bandwidth)?; + + if let Some(average_bandwidth) = self.average_bandwidth { + write!(writer, ",AVERAGE-BANDWIDTH={}", average_bandwidth)?; + } + + write!(writer, ",CODECS=\"{}\"", self.codecs.join(","))?; + + if let Some((w, h)) = self.resolution { + write!(writer, ",RESOLUTION={}x{}", w, h)?; + } + + if let Some(hdcp_level) = self.hdcp_level.as_ref() { + write!(writer, ",HDCP-LEVEL={}", hdcp_level)?; + } + + if let Some(video) = self.video.as_ref() { + write!(writer, ",VIDEO=\"{}\"", video)?; + } + + write!(writer, ",URI=\"{}\"", self.uri)?; + + Ok(()) + } +} diff --git a/crates/hls/src/master_playlist/media.rs b/crates/hls/src/master_playlist/media.rs new file mode 100644 index 000000000..31c856c9d --- /dev/null +++ b/crates/hls/src/master_playlist/media.rs @@ -0,0 +1,160 @@ +use std::fmt::Display; +use std::io; + +use crate::Tag; + +pub enum MediaDefault { + Yes, + No, +} + +impl Display for MediaDefault { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MediaDefault::Yes => write!(f, "YES"), + MediaDefault::No => write!(f, "NO"), + } + } +} + +pub enum MediaAutoSelect { + Yes, + No, +} + +impl Display for MediaAutoSelect { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MediaAutoSelect::Yes => write!(f, "YES"), + MediaAutoSelect::No => write!(f, "NO"), + } + } +} + +pub enum MediaForced { + Yes, + No, +} + +impl Display for MediaForced { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MediaForced::Yes => write!(f, "YES"), + MediaForced::No => write!(f, "NO"), + } + } +} + +pub enum MediaInStreamId { + CC1, + CC2, + CC3, + CC4, + Service(u8), +} + +impl Display for MediaInStreamId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MediaInStreamId::CC1 => write!(f, "CC1"), + MediaInStreamId::CC2 => write!(f, "CC2"), + MediaInStreamId::CC3 => write!(f, "CC3"), + MediaInStreamId::CC4 => write!(f, "CC4"), + MediaInStreamId::Service(n) => write!(f, "SERVICE{n}"), + } + } +} + +pub enum MediaType { + Audio { + uri: Option, + }, + Video { + uri: Option, + }, + Subtitles { + uri: url::Url, + forced: Option, + }, + ClosedCaptions { + in_stream_id: MediaInStreamId, + }, +} + +impl Display for MediaType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MediaType::Audio { .. } => write!(f, "AUDIO"), + MediaType::Video { .. } => write!(f, "VIDEO"), + MediaType::Subtitles { .. } => write!(f, "SUBTITLES"), + MediaType::ClosedCaptions { .. } => write!(f, "CLOSED-CAPTIONS"), + } + } +} + +pub struct Media { + pub typ: MediaType, + pub group_id: String, + pub language: Option, + pub assoc_language: Option, + pub name: String, + pub default: Option, + pub auto_select: Option, + pub characteristics: Option, + pub channels: Option, +} + +impl Tag for Media { + const NAME: &'static str = "EXT-X-MEDIA"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":TYPE={}", self.typ)?; + + if let MediaType::Audio { uri: Some(uri) } | MediaType::Video { uri: Some(uri) } | MediaType::Subtitles { uri, .. } = + &self.typ + { + write!(writer, ",URI=\"{}\"", uri)?; + } + + write!(writer, ",GROUP-ID=\"{}\"", self.group_id)?; + + if let Some(lang) = self.language.as_ref() { + write!(writer, ",LANGUAGE=\"{}\"", lang)?; + } + + if let Some(lang) = self.assoc_language.as_ref() { + write!(writer, ",ASSOC-LANGUAGE=\"{}\"", lang)?; + } + + write!(writer, ",NAME=\"{}\"", self.name)?; + + if let Some(default) = self.default.as_ref() { + write!(writer, ",DEFAULT={}", default)?; + } + + if let Some(auto_select) = self.auto_select.as_ref() { + write!(writer, ",AUTO-SELECT={}", auto_select)?; + } + + if let MediaType::Subtitles { + forced: Some(forced), .. + } = &self.typ + { + write!(writer, ",FORCED={}", forced)?; + } + + if let MediaType::ClosedCaptions { in_stream_id } = &self.typ { + write!(writer, ",INSTREAM-ID=\"{}\"", in_stream_id)?; + } + + if let Some(characteristics) = self.characteristics.as_ref() { + write!(writer, ",CHARACTERISTICS=\"{}\"", characteristics)?; + } + + if let Some(channels) = self.channels.as_ref() { + write!(writer, ",CHANNELS=\"{}\"", channels)?; + } + + Ok(()) + } +} diff --git a/crates/hls/src/master_playlist/session_data.rs b/crates/hls/src/master_playlist/session_data.rs new file mode 100644 index 000000000..761bd9cc9 --- /dev/null +++ b/crates/hls/src/master_playlist/session_data.rs @@ -0,0 +1,33 @@ +use std::io; + +use crate::Tag; + +pub enum SessionDataType { + Value(String), + Uri(url::Url), +} + +pub struct SessionData { + pub data_id: String, + pub typ: SessionDataType, + pub language: Option, +} + +impl Tag for SessionData { + const NAME: &'static str = "EXT-X-SESSION-DATA"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":DATA-ID=\"{}\"", self.data_id)?; + + match &self.typ { + SessionDataType::Value(v) => write!(writer, ",VALUE={}", v)?, + SessionDataType::Uri(u) => write!(writer, ",URI={}", u)?, + } + + if let Some(lang) = self.language.as_ref() { + write!(writer, ",LANGUAGE={}", lang)?; + } + + Ok(()) + } +} diff --git a/crates/hls/src/master_playlist/session_key.rs b/crates/hls/src/master_playlist/session_key.rs new file mode 100644 index 000000000..f75c2b73a --- /dev/null +++ b/crates/hls/src/master_playlist/session_key.rs @@ -0,0 +1,16 @@ +use std::io; + +use crate::Tag; +use crate::media_segment::KeyAes; + +pub struct SessionKey { + pub key: KeyAes, +} + +impl Tag for SessionKey { + const NAME: &'static str = ""; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":{}", self.key) + } +} diff --git a/crates/hls/src/master_playlist/stream_inf.rs b/crates/hls/src/master_playlist/stream_inf.rs new file mode 100644 index 000000000..bb89a8acb --- /dev/null +++ b/crates/hls/src/master_playlist/stream_inf.rs @@ -0,0 +1,85 @@ +use std::fmt::Display; +use std::io; + +use crate::Tag; + +pub enum StreamInfHdcpLevel { + Type0, + None, +} + +impl Display for StreamInfHdcpLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StreamInfHdcpLevel::Type0 => write!(f, "TYPE-0"), + StreamInfHdcpLevel::None => write!(f, "NONE"), + } + } +} + +pub enum StreamInfClosedCaptions { + None, + ClosedCaptions(String), +} + +pub struct StreamInf { + pub bandwidth: u64, + pub average_bandwidth: Option, + pub codecs: Vec, + pub resolution: Option<(u64, u64)>, + pub frame_rate: Option, + pub hdcp_level: Option, + pub audio: Option, + pub video: Option, + pub subtitles: Option, + pub closed_captions: Option, + pub uri: url::Url, +} + +impl Tag for StreamInf { + const NAME: &'static str = "EXT-X-STREAM-INF"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":BANDWIDTH={}", self.bandwidth)?; + + if let Some(average_bandwidth) = self.average_bandwidth { + write!(writer, ",AVERAGE-BANDWIDTH={}", average_bandwidth)?; + } + + write!(writer, ",CODECS=\"{}\"", self.codecs.join(","))?; + + if let Some((w, h)) = self.resolution { + write!(writer, ",RESOLUTION={}x{}", w, h)?; + } + + if let Some(frame_rate) = self.frame_rate { + write!(writer, ",FRAME-RATE={:.3}", frame_rate)?; + } + + if let Some(hdcp_level) = self.hdcp_level.as_ref() { + write!(writer, ",HDCP-LEVEL={}", hdcp_level)?; + } + + if let Some(audio) = self.audio.as_ref() { + write!(writer, ",AUDIO=\"{}\"", audio)?; + } + + if let Some(video) = self.video.as_ref() { + write!(writer, ",VIDEO=\"{}\"", video)?; + } + + if let Some(subtitles) = self.subtitles.as_ref() { + write!(writer, ",SUBTITLES=\"{}\"", subtitles)?; + } + + match self.closed_captions.as_ref() { + Some(StreamInfClosedCaptions::None) => write!(writer, ",CLOSED-CAPTIONS=NONE")?, + Some(StreamInfClosedCaptions::ClosedCaptions(v)) => write!(writer, ",CLOSED-CAPTIONS=\"{}\"", v)?, + None => {} + } + + write!(writer, "\n{}", self.uri)?; + + Ok(()) + } +} diff --git a/crates/hls/src/media_playlist.rs b/crates/hls/src/media_playlist.rs new file mode 100644 index 000000000..af04ff9ca --- /dev/null +++ b/crates/hls/src/media_playlist.rs @@ -0,0 +1,13 @@ +mod discontinuity_sequence; +mod end_list; +mod i_frames_only; +mod media_sequence; +mod playlist_type; +mod target_duration; + +pub use discontinuity_sequence::*; +pub use end_list::*; +pub use i_frames_only::*; +pub use media_sequence::*; +pub use playlist_type::*; +pub use target_duration::*; diff --git a/crates/hls/src/media_playlist/discontinuity_sequence.rs b/crates/hls/src/media_playlist/discontinuity_sequence.rs new file mode 100644 index 000000000..8eb702c67 --- /dev/null +++ b/crates/hls/src/media_playlist/discontinuity_sequence.rs @@ -0,0 +1,13 @@ +use std::io; + +use crate::Tag; + +pub struct DiscontinuitySequence(pub u64); + +impl Tag for DiscontinuitySequence { + const NAME: &'static str = "EXT-X-DISCONTINUITY-SEQUENCE"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":{}", self.0) + } +} diff --git a/crates/hls/src/media_playlist/end_list.rs b/crates/hls/src/media_playlist/end_list.rs new file mode 100644 index 000000000..3ad60b39a --- /dev/null +++ b/crates/hls/src/media_playlist/end_list.rs @@ -0,0 +1,7 @@ +use crate::Tag; + +pub struct EndList; + +impl Tag for EndList { + const NAME: &'static str = "EXT-X-ENDLIST"; +} diff --git a/crates/hls/src/media_playlist/i_frames_only.rs b/crates/hls/src/media_playlist/i_frames_only.rs new file mode 100644 index 000000000..9d72832a7 --- /dev/null +++ b/crates/hls/src/media_playlist/i_frames_only.rs @@ -0,0 +1,12 @@ +use crate::Tag; +use crate::basic::Version; + +pub struct IFramesOnly; + +impl Tag for IFramesOnly { + const NAME: &'static str = "EXT-X-I-FRAMES-ONLY"; + + fn min_version(&self) -> Version { + Version(4) + } +} diff --git a/crates/hls/src/media_playlist/media_sequence.rs b/crates/hls/src/media_playlist/media_sequence.rs new file mode 100644 index 000000000..f779309a5 --- /dev/null +++ b/crates/hls/src/media_playlist/media_sequence.rs @@ -0,0 +1,13 @@ +use std::io; + +use crate::Tag; + +pub struct MediaSequence(pub u64); + +impl Tag for MediaSequence { + const NAME: &'static str = "EXT-X-MEDIA-SEQUENCE"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":{}", self.0) + } +} diff --git a/crates/hls/src/media_playlist/playlist_type.rs b/crates/hls/src/media_playlist/playlist_type.rs new file mode 100644 index 000000000..293aa6f56 --- /dev/null +++ b/crates/hls/src/media_playlist/playlist_type.rs @@ -0,0 +1,17 @@ +use crate::Tag; + +pub enum PlaylistType { + Event, + Vod, +} + +impl Tag for PlaylistType { + const NAME: &'static str = "EXT-X-PLAYLIST-TYPE"; + + fn write_value(&self, mut writer: impl std::io::Write) -> Result<(), std::io::Error> { + match self { + PlaylistType::Event => write!(writer, ":EVENT"), + PlaylistType::Vod => write!(writer, ":VOD"), + } + } +} diff --git a/crates/hls/src/media_playlist/target_duration.rs b/crates/hls/src/media_playlist/target_duration.rs new file mode 100644 index 000000000..712f4f9d2 --- /dev/null +++ b/crates/hls/src/media_playlist/target_duration.rs @@ -0,0 +1,13 @@ +use std::io; + +use crate::Tag; + +pub struct TargetDuration(pub u64); + +impl Tag for TargetDuration { + const NAME: &'static str = "EXT-X-TARGETDURATION"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":{}", self.0) + } +} diff --git a/crates/hls/src/media_segment.rs b/crates/hls/src/media_segment.rs new file mode 100644 index 000000000..39888bf02 --- /dev/null +++ b/crates/hls/src/media_segment.rs @@ -0,0 +1,17 @@ +//! All tags defined in Section "4.3.2. Media Segment Tags". + +mod byterange; +mod date_range; +mod discontinuity; +mod inf; +mod key; +mod map; +mod program_date_time; + +pub use byterange::*; +pub use date_range::*; +pub use discontinuity::*; +pub use inf::*; +pub use key::*; +pub use map::*; +pub use program_date_time::*; diff --git a/crates/hls/src/media_segment/byterange.rs b/crates/hls/src/media_segment/byterange.rs new file mode 100644 index 000000000..8cc2ca1d8 --- /dev/null +++ b/crates/hls/src/media_segment/byterange.rs @@ -0,0 +1,33 @@ +use std::fmt::Display; + +use crate::Tag; +use crate::basic::Version; + +#[derive(Debug)] +pub struct ByteRange { + pub length: u64, + pub start: Option, +} + +impl Display for ByteRange { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.length)?; + if let Some(start) = self.start { + write!(f, "@{start}")?; + } + + Ok(()) + } +} + +impl Tag for ByteRange { + const NAME: &'static str = "EXT-X-BYTERANGE"; + + fn min_version(&self) -> Version { + Version(4) + } + + fn write_value(&self, mut writer: impl std::io::Write) -> Result<(), std::io::Error> { + write!(writer, ":{}", self) + } +} diff --git a/crates/hls/src/media_segment/date_range.rs b/crates/hls/src/media_segment/date_range.rs new file mode 100644 index 000000000..c99d05044 --- /dev/null +++ b/crates/hls/src/media_segment/date_range.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; +use std::io; + +use crate::{AttributeName, Tag}; + +#[derive(Debug)] +pub struct DateRange { + pub id: String, + pub details: DateRangeDetails, + pub start_date: chrono::DateTime, + pub planned_duration: Option, + pub client_attributes: HashMap, + pub scte35_cmd: Option, + pub scte35_out: Option, + pub scte35_in: Option, +} + +#[derive(Debug)] +pub enum DateRangeDetails { + Normal { + class: Option, + end_date: Option>, + duration: Option, + }, + EndOnNext { + class: String, + }, +} + +#[derive(Debug)] +pub enum ClientAttributeValue { + HexadecimalSeq(u64), + Float(f64), + QuotedString(String), +} + +impl ClientAttributeValue { + fn write(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + match self { + Self::HexadecimalSeq(v) => write!(writer, "0x{v:X}"), + Self::Float(v) => write!(writer, "{v}"), + Self::QuotedString(v) => { + if v.contains(0x0a as char) || v.contains(0x0d as char) || v.contains('"') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "quoted-string contains illegal characters", + )); + } + write!(writer, "\"{v}\"") + } + } + } +} + +impl Tag for DateRange { + const NAME: &'static str = "EXT-X-DATERANGE"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + if let DateRangeDetails::Normal { + duration: Some(duration), + .. + } = &self.details + && *duration < chrono::Duration::zero() + { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "illegal DURATION")); + } + + if self.planned_duration.is_some_and(|d| d < chrono::Duration::zero()) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "illegal PLANNED-DURATION")); + } + + write!(writer, ":ID=\"{}\"", self.id)?; + + if let DateRangeDetails::Normal { class: Some(class), .. } | DateRangeDetails::EndOnNext { class } = &self.details { + write!(writer, ",CLASS=\"{class}\"")?; + } + + write!(writer, ",START-DATE=\"{}\"", self.start_date.format("%+"))?; + + if let DateRangeDetails::Normal { + end_date: Some(end_date), + .. + } = &self.details + { + write!(writer, ",END-DATE=\"{}\"", end_date.format("%+"))?; + } + + if let DateRangeDetails::Normal { + duration: Some(duration), + .. + } = &self.details + { + write!(writer, ",DURATION={}", duration.as_seconds_f64())?; + } + + if let Some(planned_duration) = self.planned_duration { + write!(writer, ",PLANNED-DURATION={}", planned_duration.as_seconds_f64())?; + } + + for (k, v) in self.client_attributes.iter() { + write!(writer, ",{k}=")?; + v.write(&mut writer)?; + } + + if let Some(scte35_cmd) = self.scte35_cmd { + write!(writer, ",SCTE35-CMD=0x{scte35_cmd:X}")?; + } + if let Some(scte35_out) = self.scte35_out { + write!(writer, ",SCTE35-OUT=0x{scte35_out:X}")?; + } + if let Some(scte35_in) = self.scte35_in { + write!(writer, ",SCTE35-IN=0x{scte35_in:X}")?; + } + + if let DateRangeDetails::EndOnNext { .. } = &self.details { + write!(writer, ",END-ON-NEXT=\"YES\"")?; + } + + Ok(()) + } +} diff --git a/crates/hls/src/media_segment/discontinuity.rs b/crates/hls/src/media_segment/discontinuity.rs new file mode 100644 index 000000000..1d8639bb1 --- /dev/null +++ b/crates/hls/src/media_segment/discontinuity.rs @@ -0,0 +1,8 @@ +use crate::Tag; + +#[derive(Debug)] +pub struct Discontinuity; + +impl Tag for Discontinuity { + const NAME: &'static str = "EXT-X-DISCONTINUITY"; +} diff --git a/crates/hls/src/media_segment/inf.rs b/crates/hls/src/media_segment/inf.rs new file mode 100644 index 000000000..63d162a51 --- /dev/null +++ b/crates/hls/src/media_segment/inf.rs @@ -0,0 +1,58 @@ +use std::fmt::Display; + +use crate::Tag; +use crate::basic::Version; + +#[derive(Debug)] +pub enum InfDuration { + Int(u64), + Float(f64), +} + +impl From for InfDuration { + fn from(value: f64) -> Self { + Self::Float(value) + } +} + +impl From for InfDuration { + fn from(value: u64) -> Self { + Self::Int(value) + } +} + +impl Display for InfDuration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Float(v) => v.fmt(f), + Self::Int(v) => v.fmt(f), + } + } +} + +#[derive(Debug)] +pub struct Inf { + pub duration: InfDuration, + pub title: Option, +} + +impl Tag for Inf { + const NAME: &'static str = "EXTINF"; + + fn min_version(&self) -> Version { + match self.duration { + InfDuration::Float(_) => Version(3), + InfDuration::Int(_) => Version::default(), + } + } + + fn write_value(&self, mut writer: impl std::io::Write) -> Result<(), std::io::Error> { + write!(writer, ":{}", self.duration)?; + + if let Some(title) = self.title.as_ref() { + write!(writer, ",{title}")?; + } + + Ok(()) + } +} diff --git a/crates/hls/src/media_segment/key.rs b/crates/hls/src/media_segment/key.rs new file mode 100644 index 000000000..b0f6e3dc1 --- /dev/null +++ b/crates/hls/src/media_segment/key.rs @@ -0,0 +1,77 @@ +use std::fmt::Display; +use std::io; + +use crate::Tag; +use crate::basic::Version; + +#[derive(Debug, PartialEq, Eq)] +pub enum KeyMethod { + Aes128, + SampleAes, +} + +impl Display for KeyMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KeyMethod::Aes128 => write!(f, "AES-128"), + KeyMethod::SampleAes => write!(f, "SAMPLE-AES"), + } + } +} + +#[derive(Debug)] +pub struct KeyAes { + method: KeyMethod, + uri: url::Url, + iv: Option, + key_format: Option, + key_format_versions: Option, +} + +impl Display for KeyAes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "METHOD={},URI=\"{}\"", self.method, self.uri)?; + if let Some(iv) = self.iv { + write!(f, ",IV=0x{iv:X}")?; + } + if let Some(key_format) = self.key_format.as_ref() { + write!(f, ",KEYFORMAT=\"{key_format}\"")?; + } + if let Some(key_format_versions) = self.key_format_versions.as_ref() { + write!(f, ",KEYFORMATVERSIONS=\"{key_format_versions}\"")?; + } + Ok(()) + } +} + +#[derive(Debug)] +pub enum Key { + None, + Aes(KeyAes), +} + +impl Tag for Key { + const NAME: &'static str = "EXT-X-KEY"; + + fn min_version(&self) -> Version { + let mut version = Version::default(); + + if let Key::Aes(aes) = self { + if aes.iv.is_some() { + version = Version(2); + } + if aes.key_format.is_some() || aes.key_format_versions.is_some() { + version = version.max(Version(5)); + } + } + + version + } + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + match self { + Key::None => write!(writer, ":METHOD=NONE"), + Key::Aes(aes) => write!(writer, ":{aes}"), + } + } +} diff --git a/crates/hls/src/media_segment/map.rs b/crates/hls/src/media_segment/map.rs new file mode 100644 index 000000000..147848203 --- /dev/null +++ b/crates/hls/src/media_segment/map.rs @@ -0,0 +1,34 @@ +use std::io; + +use crate::Tag; +use crate::basic::Version; +use crate::media_segment::ByteRange; + +#[derive(Debug)] +pub struct Map { + uri: url::Url, + byte_range: Option, +} + +impl Tag for Map { + const NAME: &'static str = "EXT-X-MAP"; + + fn min_version(&self) -> Version { + // TODO: Check if 5 is sufficient + + // Use of the EXT-X-MAP tag in a Media Playlist that contains the EXT-X-I-FRAMES-ONLY + // tag REQUIRES a compatibility version number of 5 or greater. + // Use of the EXT-X-MAP tag in a Media Playlist that DOES NOT + // contain the EXT-X-I-FRAMES-ONLY tag REQUIRES a compatibility version + // number of 6 or greater. + Version(6) + } + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":URI=\"{}\"", self.uri)?; + if let Some(byte_range) = self.byte_range.as_ref() { + write!(writer, ",BYTERANGE=\"{byte_range}\"")?; + } + Ok(()) + } +} diff --git a/crates/hls/src/media_segment/program_date_time.rs b/crates/hls/src/media_segment/program_date_time.rs new file mode 100644 index 000000000..d987fb5a2 --- /dev/null +++ b/crates/hls/src/media_segment/program_date_time.rs @@ -0,0 +1,15 @@ +use std::io; + +use crate::Tag; + +pub struct ProgramDateTime { + pub date_time_msec: chrono::DateTime, +} + +impl Tag for ProgramDateTime { + const NAME: &'static str = "EXT-X-PROGRAM-DATE-TIME"; + + fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { + write!(writer, ":{}", self.date_time_msec.format("%+")) + } +} diff --git a/vendor/cargo/defs.bzl b/vendor/cargo/defs.bzl index 2cd1734dd..3529c2fa0 100644 --- a/vendor/cargo/defs.bzl +++ b/vendor/cargo/defs.bzl @@ -895,6 +895,15 @@ _NORMAL_DEPENDENCIES = { }, }, }, + "crates/hls": { + _REQUIRED_FEATURE: { + _COMMON_CONDITION: { + "chrono": Label("@cargo_vendor//:chrono-0.4.42"), + "thiserror": Label("@cargo_vendor//:thiserror-2.0.16"), + "url": Label("@cargo_vendor//:url-2.5.7"), + }, + }, + }, "crates/http": { _REQUIRED_FEATURE: { _COMMON_CONDITION: { @@ -1689,6 +1698,12 @@ _NORMAL_ALIASES = { }, }, }, + "crates/hls": { + _REQUIRED_FEATURE: { + _COMMON_CONDITION: { + }, + }, + }, "crates/http": { _REQUIRED_FEATURE: { _COMMON_CONDITION: { @@ -2118,6 +2133,8 @@ _NORMAL_DEV_DEPENDENCIES = { }, }, }, + "crates/hls": { + }, "crates/http": { _REQUIRED_FEATURE: { _COMMON_CONDITION: { @@ -2398,6 +2415,8 @@ _NORMAL_DEV_ALIASES = { }, }, }, + "crates/hls": { + }, "crates/http": { _REQUIRED_FEATURE: { _COMMON_CONDITION: { @@ -2734,6 +2753,13 @@ _PROC_MACRO_DEPENDENCIES = { }, }, }, + "crates/hls": { + "docs": { + _COMMON_CONDITION: { + "document-features": Label("@cargo_vendor//:document-features-0.2.11"), + }, + }, + }, "crates/http": { "docs": { _COMMON_CONDITION: { @@ -3062,6 +3088,8 @@ _PROC_MACRO_ALIASES = { }, "crates/h265": { }, + "crates/hls": { + }, "crates/http": { }, "crates/metrics": { @@ -3224,6 +3252,8 @@ _PROC_MACRO_DEV_DEPENDENCIES = { }, "crates/h265": { }, + "crates/hls": { + }, "crates/http": { }, "crates/metrics": { @@ -3421,6 +3451,8 @@ _PROC_MACRO_DEV_ALIASES = { }, }, }, + "crates/hls": { + }, "crates/http": { _REQUIRED_FEATURE: { _COMMON_CONDITION: { @@ -3623,6 +3655,8 @@ _BUILD_DEPENDENCIES = { }, "crates/h265": { }, + "crates/hls": { + }, "crates/http": { }, "crates/metrics": { @@ -3786,6 +3820,8 @@ _BUILD_ALIASES = { }, "crates/h265": { }, + "crates/hls": { + }, "crates/http": { }, "crates/metrics": { @@ -3941,6 +3977,8 @@ _BUILD_PROC_MACRO_DEPENDENCIES = { }, "crates/h265": { }, + "crates/hls": { + }, "crates/http": { }, "crates/metrics": { @@ -4088,6 +4126,8 @@ _BUILD_PROC_MACRO_ALIASES = { }, "crates/h265": { }, + "crates/hls": { + }, "crates/http": { }, "crates/metrics": { @@ -4331,6 +4371,10 @@ _FEATURE_FLAGS = { "docs": [ ], }, + "crates/hls": { + "docs": [ + ], + }, "crates/http": { "default": [ "http1", @@ -4622,6 +4666,8 @@ _RESOLVED_FEATURE_FLAGS = { }, "crates/h265": { }, + "crates/hls": { + }, "crates/http": { _COMMON_CONDITION: [ "default", @@ -4778,6 +4824,7 @@ _VERSIONS = { "crates/future-ext": "0.1.4", "crates/h264": "0.2.2", "crates/h265": "0.2.2", + "crates/hls": "0.1.0", "crates/http": "0.3.2", "crates/metrics": "0.4.2", "crates/metrics/derive": "0.4.2",