From 3344136699a1ed10b290f91be4cb8782c6860c72 Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Tue, 18 Nov 2025 19:12:23 +0100 Subject: [PATCH 1/2] feat(hls): new crate (wip) --- Cargo.lock | 11 ++ Cargo.toml | 1 + cargo_targets.bzl | 1 + crates/hls/BUILD.bazel | 14 ++ crates/hls/CHANGELOG.md | 44 +++++++ crates/hls/Cargo.toml | 43 +++++++ crates/hls/LICENSE.Apache-2.0 | 13 ++ crates/hls/LICENSE.MIT | 7 + crates/hls/README.md | 35 +++++ crates/hls/src/attribute_name.rs | 37 ++++++ crates/hls/src/basic.rs | 26 ++++ crates/hls/src/lib.rs | 49 +++++++ crates/hls/src/media_playlist.rs | 3 + .../hls/src/media_playlist/target_duration.rs | 13 ++ crates/hls/src/media_segment.rs | 17 +++ crates/hls/src/media_segment/byterange.rs | 32 +++++ crates/hls/src/media_segment/date_range.rs | 120 ++++++++++++++++++ crates/hls/src/media_segment/discontinuity.rs | 8 ++ crates/hls/src/media_segment/inf.rs | 57 +++++++++ crates/hls/src/media_segment/key.rs | 75 +++++++++++ crates/hls/src/media_segment/map.rs | 32 +++++ .../src/media_segment/program_date_time.rs | 15 +++ vendor/cargo/defs.bzl | 47 +++++++ 23 files changed, 700 insertions(+) create mode 100644 crates/hls/BUILD.bazel create mode 100644 crates/hls/CHANGELOG.md create mode 100644 crates/hls/Cargo.toml create mode 100644 crates/hls/LICENSE.Apache-2.0 create mode 100644 crates/hls/LICENSE.MIT create mode 100644 crates/hls/README.md create mode 100644 crates/hls/src/attribute_name.rs create mode 100644 crates/hls/src/basic.rs create mode 100644 crates/hls/src/lib.rs create mode 100644 crates/hls/src/media_playlist.rs create mode 100644 crates/hls/src/media_playlist/target_duration.rs create mode 100644 crates/hls/src/media_segment.rs create mode 100644 crates/hls/src/media_segment/byterange.rs create mode 100644 crates/hls/src/media_segment/date_range.rs create mode 100644 crates/hls/src/media_segment/discontinuity.rs create mode 100644 crates/hls/src/media_segment/inf.rs create mode 100644 crates/hls/src/media_segment/key.rs create mode 100644 crates/hls/src/media_segment/map.rs create mode 100644 crates/hls/src/media_segment/program_date_time.rs diff --git a/Cargo.lock b/Cargo.lock index 0acab43dc2..a69b57ceb1 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" diff --git a/Cargo.toml b/Cargo.toml index caeb4bfb86..8ea4832c97 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 f39a82b1ef..a04959e1d6 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 0000000000..b6aea8ae4b --- /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 0000000000..29066c429d --- /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 0000000000..39ad9a7ea7 --- /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] +document-features = { optional = true, version = "0.2" } +scuffle-changelog = { optional = true, path = "../changelog", version = "0.1" } +url = "2" +chrono = "0.4" +thiserror = "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 0000000000..a898333163 --- /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 0000000000..80da3c7c22 --- /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 0000000000..7db5a0db37 --- /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/attribute_name.rs b/crates/hls/src/attribute_name.rs new file mode 100644 index 0000000000..48c2d56537 --- /dev/null +++ b/crates/hls/src/attribute_name.rs @@ -0,0 +1,37 @@ +use std::{fmt::Display, 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 0000000000..49623fa4f6 --- /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 ExtVersion(pub u64); + +impl Default for ExtVersion { + fn default() -> Self { + Self(1) + } +} + +impl Tag for ExtVersion { + 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 0000000000..0c87b0be5c --- /dev/null +++ b/crates/hls/src/lib.rs @@ -0,0 +1,49 @@ +//! 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::ExtVersion; + +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) -> ExtVersion { + ExtVersion::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/media_playlist.rs b/crates/hls/src/media_playlist.rs new file mode 100644 index 0000000000..8b4a8250a2 --- /dev/null +++ b/crates/hls/src/media_playlist.rs @@ -0,0 +1,3 @@ +mod target_duration; + +pub use target_duration::*; 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 0000000000..712f4f9d2a --- /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 0000000000..39888bf02e --- /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 0000000000..239bbaa46d --- /dev/null +++ b/crates/hls/src/media_segment/byterange.rs @@ -0,0 +1,32 @@ +use std::fmt::Display; + +use crate::{Tag, basic::ExtVersion}; + +#[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) -> ExtVersion { + ExtVersion(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 0000000000..28de813e61 --- /dev/null +++ b/crates/hls/src/media_segment/date_range.rs @@ -0,0 +1,120 @@ +use std::{collections::HashMap, 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 0000000000..1d8639bb1a --- /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 0000000000..196d70a462 --- /dev/null +++ b/crates/hls/src/media_segment/inf.rs @@ -0,0 +1,57 @@ +use std::fmt::Display; + +use crate::{Tag, basic::ExtVersion}; + +#[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) -> ExtVersion { + match self.duration { + InfDuration::Float(_) => ExtVersion(3), + InfDuration::Int(_) => ExtVersion::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 0000000000..153a4b441a --- /dev/null +++ b/crates/hls/src/media_segment/key.rs @@ -0,0 +1,75 @@ +use std::{fmt::Display, io}; + +use crate::{Tag, basic::ExtVersion}; + +#[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) -> ExtVersion { + let mut version = ExtVersion::default(); + + if let Key::Aes(aes) = self { + if aes.iv.is_some() { + version = ExtVersion(2); + } + if aes.key_format.is_some() || aes.key_format_versions.is_some() { + version = version.max(ExtVersion(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 0000000000..d36cb113b0 --- /dev/null +++ b/crates/hls/src/media_segment/map.rs @@ -0,0 +1,32 @@ +use std::io; + +use crate::{Tag, basic::ExtVersion, 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) -> ExtVersion { + // 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. + ExtVersion(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 0000000000..d987fb5a24 --- /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 2cd1734dde..3529c2fa0c 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", From 01a12fc48629af3095778775670bee11ffe67ca2 Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Tue, 25 Nov 2025 23:18:37 +0100 Subject: [PATCH 2/2] feat(hls): add missing tags --- Cargo.lock | 1 - crates/hls/Cargo.toml | 4 +- crates/hls/src/any_playlist.rs | 5 + .../src/any_playlist/independent_segments.rs | 7 + crates/hls/src/any_playlist/start.rs | 37 ++++ crates/hls/src/attribute_name.rs | 3 +- crates/hls/src/basic.rs | 6 +- crates/hls/src/lib.rs | 9 +- crates/hls/src/master_playlist.rs | 11 ++ .../src/master_playlist/i_frame_stream_inf.rs | 44 +++++ crates/hls/src/master_playlist/media.rs | 160 ++++++++++++++++++ .../hls/src/master_playlist/session_data.rs | 33 ++++ crates/hls/src/master_playlist/session_key.rs | 16 ++ crates/hls/src/master_playlist/stream_inf.rs | 85 ++++++++++ crates/hls/src/media_playlist.rs | 10 ++ .../media_playlist/discontinuity_sequence.rs | 13 ++ crates/hls/src/media_playlist/end_list.rs | 7 + .../hls/src/media_playlist/i_frames_only.rs | 12 ++ .../hls/src/media_playlist/media_sequence.rs | 13 ++ .../hls/src/media_playlist/playlist_type.rs | 17 ++ crates/hls/src/media_segment/byterange.rs | 7 +- crates/hls/src/media_segment/date_range.rs | 3 +- crates/hls/src/media_segment/inf.rs | 9 +- crates/hls/src/media_segment/key.rs | 14 +- crates/hls/src/media_segment/map.rs | 8 +- 25 files changed, 506 insertions(+), 28 deletions(-) create mode 100644 crates/hls/src/any_playlist.rs create mode 100644 crates/hls/src/any_playlist/independent_segments.rs create mode 100644 crates/hls/src/any_playlist/start.rs create mode 100644 crates/hls/src/master_playlist.rs create mode 100644 crates/hls/src/master_playlist/i_frame_stream_inf.rs create mode 100644 crates/hls/src/master_playlist/media.rs create mode 100644 crates/hls/src/master_playlist/session_data.rs create mode 100644 crates/hls/src/master_playlist/session_key.rs create mode 100644 crates/hls/src/master_playlist/stream_inf.rs create mode 100644 crates/hls/src/media_playlist/discontinuity_sequence.rs create mode 100644 crates/hls/src/media_playlist/end_list.rs create mode 100644 crates/hls/src/media_playlist/i_frames_only.rs create mode 100644 crates/hls/src/media_playlist/media_sequence.rs create mode 100644 crates/hls/src/media_playlist/playlist_type.rs diff --git a/Cargo.lock b/Cargo.lock index a69b57ceb1..c74c4e4d1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8959,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/crates/hls/Cargo.toml b/crates/hls/Cargo.toml index 39ad9a7ea7..da66c75537 100644 --- a/crates/hls/Cargo.toml +++ b/crates/hls/Cargo.toml @@ -18,11 +18,11 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } 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" } -url = "2" -chrono = "0.4" thiserror = "2" +url = "2" [package.metadata.docs.rs] all-features = true diff --git a/crates/hls/src/any_playlist.rs b/crates/hls/src/any_playlist.rs new file mode 100644 index 0000000000..956efe7bac --- /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 0000000000..518823e50f --- /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 0000000000..d5fdee8a0b --- /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 index 48c2d56537..7a32603476 100644 --- a/crates/hls/src/attribute_name.rs +++ b/crates/hls/src/attribute_name.rs @@ -1,4 +1,5 @@ -use std::{fmt::Display, str::FromStr}; +use std::fmt::Display; +use std::str::FromStr; #[derive(Debug, PartialEq, Eq, Hash)] pub struct AttributeName(String); diff --git a/crates/hls/src/basic.rs b/crates/hls/src/basic.rs index 49623fa4f6..d2fcf3243b 100644 --- a/crates/hls/src/basic.rs +++ b/crates/hls/src/basic.rs @@ -9,15 +9,15 @@ impl Tag for ExtM3u { } #[derive(PartialEq, Eq, PartialOrd, Ord)] -pub struct ExtVersion(pub u64); +pub struct Version(pub u64); -impl Default for ExtVersion { +impl Default for Version { fn default() -> Self { Self(1) } } -impl Tag for ExtVersion { +impl Tag for Version { const NAME: &'static str = "EXT-X-VERSION"; fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> { diff --git a/crates/hls/src/lib.rs b/crates/hls/src/lib.rs index 0c87b0be5c..5a261282b8 100644 --- a/crates/hls/src/lib.rs +++ b/crates/hls/src/lib.rs @@ -17,11 +17,12 @@ use std::io; -use crate::basic::ExtVersion; +use crate::basic::Version; +pub mod any_playlist; pub mod attribute_name; pub mod basic; -// pub mod master_playlist; +pub mod master_playlist; pub mod media_playlist; pub mod media_segment; @@ -30,8 +31,8 @@ pub use attribute_name::AttributeName; pub trait Tag { const NAME: &'static str; - fn min_version(&self) -> ExtVersion { - ExtVersion::default() + fn min_version(&self) -> Version { + Version::default() } fn write_value(&self, writer: impl io::Write) -> Result<(), io::Error> { diff --git a/crates/hls/src/master_playlist.rs b/crates/hls/src/master_playlist.rs new file mode 100644 index 0000000000..293509a084 --- /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 0000000000..c89a44478a --- /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 0000000000..31c856c9d5 --- /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 0000000000..761bd9cc9d --- /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 0000000000..f75c2b73a3 --- /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 0000000000..bb89a8acbb --- /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 index 8b4a8250a2..af04ff9ca3 100644 --- a/crates/hls/src/media_playlist.rs +++ b/crates/hls/src/media_playlist.rs @@ -1,3 +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 0000000000..8eb702c676 --- /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 0000000000..3ad60b39a0 --- /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 0000000000..9d72832a7b --- /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 0000000000..f779309a5e --- /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 0000000000..293aa6f56c --- /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_segment/byterange.rs b/crates/hls/src/media_segment/byterange.rs index 239bbaa46d..8cc2ca1d8a 100644 --- a/crates/hls/src/media_segment/byterange.rs +++ b/crates/hls/src/media_segment/byterange.rs @@ -1,6 +1,7 @@ use std::fmt::Display; -use crate::{Tag, basic::ExtVersion}; +use crate::Tag; +use crate::basic::Version; #[derive(Debug)] pub struct ByteRange { @@ -22,8 +23,8 @@ impl Display for ByteRange { impl Tag for ByteRange { const NAME: &'static str = "EXT-X-BYTERANGE"; - fn min_version(&self) -> ExtVersion { - ExtVersion(4) + fn min_version(&self) -> Version { + Version(4) } fn write_value(&self, mut writer: impl std::io::Write) -> Result<(), std::io::Error> { diff --git a/crates/hls/src/media_segment/date_range.rs b/crates/hls/src/media_segment/date_range.rs index 28de813e61..c99d050448 100644 --- a/crates/hls/src/media_segment/date_range.rs +++ b/crates/hls/src/media_segment/date_range.rs @@ -1,4 +1,5 @@ -use std::{collections::HashMap, io}; +use std::collections::HashMap; +use std::io; use crate::{AttributeName, Tag}; diff --git a/crates/hls/src/media_segment/inf.rs b/crates/hls/src/media_segment/inf.rs index 196d70a462..63d162a511 100644 --- a/crates/hls/src/media_segment/inf.rs +++ b/crates/hls/src/media_segment/inf.rs @@ -1,6 +1,7 @@ use std::fmt::Display; -use crate::{Tag, basic::ExtVersion}; +use crate::Tag; +use crate::basic::Version; #[derive(Debug)] pub enum InfDuration { @@ -38,10 +39,10 @@ pub struct Inf { impl Tag for Inf { const NAME: &'static str = "EXTINF"; - fn min_version(&self) -> ExtVersion { + fn min_version(&self) -> Version { match self.duration { - InfDuration::Float(_) => ExtVersion(3), - InfDuration::Int(_) => ExtVersion::default(), + InfDuration::Float(_) => Version(3), + InfDuration::Int(_) => Version::default(), } } diff --git a/crates/hls/src/media_segment/key.rs b/crates/hls/src/media_segment/key.rs index 153a4b441a..b0f6e3dc15 100644 --- a/crates/hls/src/media_segment/key.rs +++ b/crates/hls/src/media_segment/key.rs @@ -1,6 +1,8 @@ -use std::{fmt::Display, io}; +use std::fmt::Display; +use std::io; -use crate::{Tag, basic::ExtVersion}; +use crate::Tag; +use crate::basic::Version; #[derive(Debug, PartialEq, Eq)] pub enum KeyMethod { @@ -51,15 +53,15 @@ pub enum Key { impl Tag for Key { const NAME: &'static str = "EXT-X-KEY"; - fn min_version(&self) -> ExtVersion { - let mut version = ExtVersion::default(); + fn min_version(&self) -> Version { + let mut version = Version::default(); if let Key::Aes(aes) = self { if aes.iv.is_some() { - version = ExtVersion(2); + version = Version(2); } if aes.key_format.is_some() || aes.key_format_versions.is_some() { - version = version.max(ExtVersion(5)); + version = version.max(Version(5)); } } diff --git a/crates/hls/src/media_segment/map.rs b/crates/hls/src/media_segment/map.rs index d36cb113b0..1478482036 100644 --- a/crates/hls/src/media_segment/map.rs +++ b/crates/hls/src/media_segment/map.rs @@ -1,6 +1,8 @@ use std::io; -use crate::{Tag, basic::ExtVersion, media_segment::ByteRange}; +use crate::Tag; +use crate::basic::Version; +use crate::media_segment::ByteRange; #[derive(Debug)] pub struct Map { @@ -11,7 +13,7 @@ pub struct Map { impl Tag for Map { const NAME: &'static str = "EXT-X-MAP"; - fn min_version(&self) -> ExtVersion { + 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 @@ -19,7 +21,7 @@ impl Tag for Map { // 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. - ExtVersion(6) + Version(6) } fn write_value(&self, mut writer: impl io::Write) -> Result<(), io::Error> {