Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
15 changes: 10 additions & 5 deletions src/record/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 5 additions & 2 deletions src/replay/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -168,8 +168,11 @@ async fn m3u8_reply(path: &Path, start: i64) -> Result<impl Reply + use<>, 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 {
Expand Down
61 changes: 61 additions & 0 deletions src/shared/bom.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
fn strip_bom(self) -> Self {
if self.starts_with(BOM.as_bytes()) {
self.drain(0..BOM.len());
}
self
}
}

impl StripBom for Vec<u8> {
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()),
}
}
}
2 changes: 2 additions & 0 deletions src/shared/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading