diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f27cdf..f30e669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Added `--keep-names` flag to `record` command to preserve the original file names of playlists and segments in the recording. (This may not be compatible with all streams.) ([#6](https://github.com/THEOplayer/streamrr/pull/6)) +- Fixed an issue where HLS playlists that start with a [UTF-8 byte order mark (BOM)](https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8) were not parsed correctly. ([#7](https://github.com/THEOplayer/streamrr/issues/7), [#8](https://github.com/THEOplayer/streamrr/pull/8)) ## v0.3.2 (2026-03-19) diff --git a/src/record/mod.rs b/src/record/mod.rs index b4f5b30..1167566 100644 --- a/src/record/mod.rs +++ b/src/record/mod.rs @@ -18,7 +18,7 @@ use tokio_util::io::StreamReader; use tokio_util::sync::CancellationToken; use url::Url; -use crate::shared::{ByteRange, MediaSelect, Recording, VariantSelectOptions}; +use crate::shared::{ByteRange, MediaSelect, Recording, StripBom, VariantSelectOptions}; pub use rewrite::*; mod rewrite; @@ -70,11 +70,12 @@ pub async fn record( let raw_playlist = token .run_until_cancelled(download_playlist(&client, url)) .await - .ok_or(RecordError::Cancelled)??; + .ok_or(RecordError::Cancelled)?? + .strip_bom(); let initial_playlist = parse_playlist_res(raw_playlist.as_bytes()).map_err(|e| { RecordError::Parse(anyhow!( "Error while parsing playlist: {}", - e.map_input(|_| url.to_string()) + e.map_input(|i| String::from_utf8_lossy(i)) )) })?; match initial_playlist { @@ -280,9 +281,13 @@ async fn record_media_playlist( let raw_playlist = token .run_until_cancelled(download_playlist(client, url)) .await - .ok_or(RecordError::Cancelled)??; + .ok_or(RecordError::Cancelled)?? + .strip_bom(); parse_media_playlist_res(raw_playlist.as_bytes()).map_err(|e| { - RecordError::Parse(anyhow!("Error while parsing media playlist: {e}")) + RecordError::Parse(anyhow!( + "Error while parsing media playlist: {}", + e.map_input(|i| String::from_utf8_lossy(i)) + )) })? }; let now = Instant::now(); diff --git a/src/replay/mod.rs b/src/replay/mod.rs index c93377d..30ea9be 100644 --- a/src/replay/mod.rs +++ b/src/replay/mod.rs @@ -20,7 +20,7 @@ use warp::reject::{Reject, custom}; use warp::{Filter, Rejection, Reply, reject, reply}; use crate::record::strip_media_playlist; -use crate::shared::Recording; +use crate::shared::{Recording, StripBom}; #[derive(Debug, Copy, Clone, Serialize, Deserialize)] struct PlaylistQueryParams { @@ -168,8 +168,11 @@ async fn m3u8_reply(path: &Path, start: i64) -> Result, Repla file.read_to_end(&mut raw_playlist) .await .map_err(|e| ReplayError::InvalidPlaylist(anyhow!(e)))?; + (&mut raw_playlist).strip_bom(); let mut playlist = m3u8_rs::parse_playlist_res(&raw_playlist).map_err(|e| { - ReplayError::InvalidPlaylist(anyhow!(e.map_input(|_| path.to_string_lossy().to_string()))) + ReplayError::InvalidPlaylist(anyhow!( + e.map_input(|i| String::from_utf8_lossy(i).to_string()) + )) })?; // Rewrite the playlist match &mut playlist { diff --git a/src/shared/bom.rs b/src/shared/bom.rs new file mode 100644 index 0000000..1647996 --- /dev/null +++ b/src/shared/bom.rs @@ -0,0 +1,61 @@ +use std::borrow::Cow; + +const BOM: &str = "\u{feff}"; + +pub trait StripBom { + /// Strip the byte-order mark (BOM) from a UTF-8 string. + fn strip_bom(self) -> Self; +} + +impl StripBom for &str { + fn strip_bom(self) -> Self { + self.strip_prefix(BOM).unwrap_or(self) + } +} + +impl StripBom for &[u8] { + fn strip_bom(self) -> Self { + self.strip_prefix(BOM.as_bytes()).unwrap_or(self) + } +} + +impl StripBom for &mut String { + fn strip_bom(self) -> Self { + if self.starts_with(BOM) { + self.drain(0..BOM.len()); + } + self + } +} + +impl StripBom for String { + fn strip_bom(mut self) -> Self { + (&mut self).strip_bom(); + self + } +} + +impl StripBom for &mut Vec { + fn strip_bom(self) -> Self { + if self.starts_with(BOM.as_bytes()) { + self.drain(0..BOM.len()); + } + self + } +} + +impl StripBom for Vec { + fn strip_bom(mut self) -> Self { + (&mut self).strip_bom(); + self + } +} + +impl<'a> StripBom for Cow<'a, str> { + fn strip_bom(self) -> Self { + match self { + Cow::Borrowed(s) => Cow::Borrowed(s.strip_bom()), + Cow::Owned(s) => Cow::Owned(s.strip_bom()), + } + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 8290497..6d8f5bb 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -1,9 +1,11 @@ +pub use bom::*; pub use byte_range::*; pub use ctrlc::*; pub use hexstring::*; pub use recording::*; pub(crate) use url::*; +mod bom; mod byte_range; mod ctrlc; mod hexstring;