Skip to content

Commit e880cf3

Browse files
Strip BOM before parsing playlists (#8)
Fixes #7.
1 parent 41210ac commit e880cf3

5 files changed

Lines changed: 79 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- 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))
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))
67

78
## v0.3.2 (2026-03-19)
89

src/record/mod.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use tokio_util::io::StreamReader;
1818
use tokio_util::sync::CancellationToken;
1919
use url::Url;
2020

21-
use crate::shared::{ByteRange, MediaSelect, Recording, VariantSelectOptions};
21+
use crate::shared::{ByteRange, MediaSelect, Recording, StripBom, VariantSelectOptions};
2222
pub use rewrite::*;
2323

2424
mod rewrite;
@@ -70,11 +70,12 @@ pub async fn record(
7070
let raw_playlist = token
7171
.run_until_cancelled(download_playlist(&client, url))
7272
.await
73-
.ok_or(RecordError::Cancelled)??;
73+
.ok_or(RecordError::Cancelled)??
74+
.strip_bom();
7475
let initial_playlist = parse_playlist_res(raw_playlist.as_bytes()).map_err(|e| {
7576
RecordError::Parse(anyhow!(
7677
"Error while parsing playlist: {}",
77-
e.map_input(|_| url.to_string())
78+
e.map_input(|i| String::from_utf8_lossy(i))
7879
))
7980
})?;
8081
match initial_playlist {
@@ -280,9 +281,13 @@ async fn record_media_playlist(
280281
let raw_playlist = token
281282
.run_until_cancelled(download_playlist(client, url))
282283
.await
283-
.ok_or(RecordError::Cancelled)??;
284+
.ok_or(RecordError::Cancelled)??
285+
.strip_bom();
284286
parse_media_playlist_res(raw_playlist.as_bytes()).map_err(|e| {
285-
RecordError::Parse(anyhow!("Error while parsing media playlist: {e}"))
287+
RecordError::Parse(anyhow!(
288+
"Error while parsing media playlist: {}",
289+
e.map_input(|i| String::from_utf8_lossy(i))
290+
))
286291
})?
287292
};
288293
let now = Instant::now();

src/replay/mod.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use warp::reject::{Reject, custom};
2020
use warp::{Filter, Rejection, Reply, reject, reply};
2121

2222
use crate::record::strip_media_playlist;
23-
use crate::shared::Recording;
23+
use crate::shared::{Recording, StripBom};
2424

2525
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
2626
struct PlaylistQueryParams {
@@ -168,8 +168,11 @@ async fn m3u8_reply(path: &Path, start: i64) -> Result<impl Reply + use<>, Repla
168168
file.read_to_end(&mut raw_playlist)
169169
.await
170170
.map_err(|e| ReplayError::InvalidPlaylist(anyhow!(e)))?;
171+
(&mut raw_playlist).strip_bom();
171172
let mut playlist = m3u8_rs::parse_playlist_res(&raw_playlist).map_err(|e| {
172-
ReplayError::InvalidPlaylist(anyhow!(e.map_input(|_| path.to_string_lossy().to_string())))
173+
ReplayError::InvalidPlaylist(anyhow!(
174+
e.map_input(|i| String::from_utf8_lossy(i).to_string())
175+
))
173176
})?;
174177
// Rewrite the playlist
175178
match &mut playlist {

src/shared/bom.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
use std::borrow::Cow;
2+
3+
const BOM: &str = "\u{feff}";
4+
5+
pub trait StripBom {
6+
/// Strip the byte-order mark (BOM) from a UTF-8 string.
7+
fn strip_bom(self) -> Self;
8+
}
9+
10+
impl StripBom for &str {
11+
fn strip_bom(self) -> Self {
12+
self.strip_prefix(BOM).unwrap_or(self)
13+
}
14+
}
15+
16+
impl StripBom for &[u8] {
17+
fn strip_bom(self) -> Self {
18+
self.strip_prefix(BOM.as_bytes()).unwrap_or(self)
19+
}
20+
}
21+
22+
impl StripBom for &mut String {
23+
fn strip_bom(self) -> Self {
24+
if self.starts_with(BOM) {
25+
self.drain(0..BOM.len());
26+
}
27+
self
28+
}
29+
}
30+
31+
impl StripBom for String {
32+
fn strip_bom(mut self) -> Self {
33+
(&mut self).strip_bom();
34+
self
35+
}
36+
}
37+
38+
impl StripBom for &mut Vec<u8> {
39+
fn strip_bom(self) -> Self {
40+
if self.starts_with(BOM.as_bytes()) {
41+
self.drain(0..BOM.len());
42+
}
43+
self
44+
}
45+
}
46+
47+
impl StripBom for Vec<u8> {
48+
fn strip_bom(mut self) -> Self {
49+
(&mut self).strip_bom();
50+
self
51+
}
52+
}
53+
54+
impl<'a> StripBom for Cow<'a, str> {
55+
fn strip_bom(self) -> Self {
56+
match self {
57+
Cow::Borrowed(s) => Cow::Borrowed(s.strip_bom()),
58+
Cow::Owned(s) => Cow::Owned(s.strip_bom()),
59+
}
60+
}
61+
}

src/shared/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
pub use bom::*;
12
pub use byte_range::*;
23
pub use ctrlc::*;
34
pub use hexstring::*;
45
pub use recording::*;
56
pub(crate) use url::*;
67

8+
mod bom;
79
mod byte_range;
810
mod ctrlc;
911
mod hexstring;

0 commit comments

Comments
 (0)