From 8835b8483ed31cb9af71552d8b10a1b3dae7191d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 1 Jul 2026 12:01:10 +0200 Subject: [PATCH 1/4] Print playlists as strings in error messages --- src/record/mod.rs | 7 +++++-- src/replay/mod.rs | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/record/mod.rs b/src/record/mod.rs index b4f5b30..6e2e2e5 100644 --- a/src/record/mod.rs +++ b/src/record/mod.rs @@ -74,7 +74,7 @@ pub async fn record( 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 { @@ -282,7 +282,10 @@ async fn record_media_playlist( .await .ok_or(RecordError::Cancelled)??; 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..db65780 100644 --- a/src/replay/mod.rs +++ b/src/replay/mod.rs @@ -169,7 +169,9 @@ async fn m3u8_reply(path: &Path, start: i64) -> Result, Repla .await .map_err(|e| ReplayError::InvalidPlaylist(anyhow!(e)))?; 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 { From 7592767ec71c9d04276c6cdfa6e77e9f2266e4c1 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 1 Jul 2026 13:27:51 +0200 Subject: [PATCH 2/4] Add `strip_bom` helper --- src/shared/bom.rs | 61 +++++++++++++++++++++++++++++++++++++++++++++++ src/shared/mod.rs | 2 ++ 2 files changed, 63 insertions(+) create mode 100644 src/shared/bom.rs 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; From dd50fb2c3682d4b818111d6de159c874500dcd3a Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 1 Jul 2026 13:30:21 +0200 Subject: [PATCH 3/4] Strip BOM before parsing playlists --- src/record/mod.rs | 8 +++++--- src/replay/mod.rs | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/record/mod.rs b/src/record/mod.rs index 6e2e2e5..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,7 +70,8 @@ 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: {}", @@ -280,7 +281,8 @@ 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: {}", diff --git a/src/replay/mod.rs b/src/replay/mod.rs index db65780..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,6 +168,7 @@ 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(|i| String::from_utf8_lossy(i).to_string()) From b7c1720d6480e6b924c6d3fa57addae1fd82a4ef Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 1 Jul 2026 13:35:44 +0200 Subject: [PATCH 4/4] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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)