feat(input): Support HLS streams (#242)
This patch adds support for yt-dl streams with the protocol m3u8_native which includes sites like Soundcloud. Closes: #241
This commit is contained in:
@@ -41,6 +41,7 @@ serenity-voice-model = { optional = true, version = "0.2" }
|
|||||||
simd-json = { features = ["serde_impl"], optional = true, version = "0.13" }
|
simd-json = { features = ["serde_impl"], optional = true, version = "0.13" }
|
||||||
socket2 = { optional = true, version = "0.5" }
|
socket2 = { optional = true, version = "0.5" }
|
||||||
streamcatcher = { optional = true, version = "1" }
|
streamcatcher = { optional = true, version = "1" }
|
||||||
|
stream_lib = { optional = true, version = "0.4.1" }
|
||||||
symphonia = { default_features = false, optional = true, version = "0.5.2" }
|
symphonia = { default_features = false, optional = true, version = "0.5.2" }
|
||||||
symphonia-core = { optional = true, version = "0.5.2" }
|
symphonia-core = { optional = true, version = "0.5.2" }
|
||||||
tokio = { default-features = false, optional = true, version = "1.0" }
|
tokio = { default-features = false, optional = true, version = "1.0" }
|
||||||
@@ -83,20 +84,22 @@ driver = [
|
|||||||
"dep:async-trait",
|
"dep:async-trait",
|
||||||
"dep:audiopus",
|
"dep:audiopus",
|
||||||
"dep:byteorder",
|
"dep:byteorder",
|
||||||
|
"dep:bytes",
|
||||||
"dep:crypto_secretbox",
|
"dep:crypto_secretbox",
|
||||||
"dep:discortp",
|
"dep:discortp",
|
||||||
"dep:reqwest",
|
|
||||||
"dep:flume",
|
"dep:flume",
|
||||||
"dep:nohash-hasher",
|
"dep:nohash-hasher",
|
||||||
"dep:once_cell",
|
"dep:once_cell",
|
||||||
"dep:parking_lot",
|
"dep:parking_lot",
|
||||||
"dep:rand",
|
"dep:rand",
|
||||||
|
"dep:reqwest",
|
||||||
"dep:ringbuf",
|
"dep:ringbuf",
|
||||||
"dep:rubato",
|
"dep:rubato",
|
||||||
"dep:rusty_pool",
|
"dep:rusty_pool",
|
||||||
"dep:serde-aux",
|
"dep:serde-aux",
|
||||||
"dep:serenity-voice-model",
|
"dep:serenity-voice-model",
|
||||||
"dep:socket2",
|
"dep:socket2",
|
||||||
|
"dep:stream_lib",
|
||||||
"dep:streamcatcher",
|
"dep:streamcatcher",
|
||||||
"dep:symphonia",
|
"dep:symphonia",
|
||||||
"dep:symphonia-core",
|
"dep:symphonia-core",
|
||||||
|
|||||||
@@ -101,8 +101,9 @@ impl fmt::Display for Error {
|
|||||||
Self::CryptoModeInvalid => write!(f, "server changed negotiated encryption mode"),
|
Self::CryptoModeInvalid => write!(f, "server changed negotiated encryption mode"),
|
||||||
Self::CryptoModeUnavailable => write!(f, "server did not offer chosen encryption mode"),
|
Self::CryptoModeUnavailable => write!(f, "server did not offer chosen encryption mode"),
|
||||||
Self::EndpointUrl => write!(f, "endpoint URL received from gateway was invalid"),
|
Self::EndpointUrl => write!(f, "endpoint URL received from gateway was invalid"),
|
||||||
Self::IllegalDiscoveryResponse =>
|
Self::IllegalDiscoveryResponse => {
|
||||||
write!(f, "IP discovery/NAT punching response was invalid"),
|
write!(f, "IP discovery/NAT punching response was invalid")
|
||||||
|
},
|
||||||
Self::IllegalIp => write!(f, "IP discovery/NAT punching response had bad IP value"),
|
Self::IllegalIp => write!(f, "IP discovery/NAT punching response had bad IP value"),
|
||||||
Self::Io(e) => e.fmt(f),
|
Self::Io(e) => e.fmt(f),
|
||||||
Self::Json(e) => e.fmt(f),
|
Self::Json(e) => e.fmt(f),
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ impl<'a> InternalTrack {
|
|||||||
},
|
},
|
||||||
Ok(MixerInputResultMessage::Seek(parsed, rec, seek_res)) => {
|
Ok(MixerInputResultMessage::Seek(parsed, rec, seek_res)) => {
|
||||||
match seek_res {
|
match seek_res {
|
||||||
Ok(pos) =>
|
Ok(pos) => {
|
||||||
if let Some(time_base) = parsed.decoder.codec_params().time_base {
|
if let Some(time_base) = parsed.decoder.codec_params().time_base {
|
||||||
// Update track's position to match the actual timestamp the
|
// Update track's position to match the actual timestamp the
|
||||||
// seek landed at.
|
// seek landed at.
|
||||||
@@ -282,7 +282,8 @@ impl<'a> InternalTrack {
|
|||||||
SymphError::Unsupported("Track had no recorded time base.")
|
SymphError::Unsupported("Track had no recorded time base.")
|
||||||
.into(),
|
.into(),
|
||||||
))
|
))
|
||||||
},
|
}
|
||||||
|
},
|
||||||
Err(e) => Err(InputReadyingError::Seeking(e)),
|
Err(e) => Err(InputReadyingError::Seeking(e)),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -177,10 +177,12 @@ impl DriverTestHandle {
|
|||||||
OutputPacket::Empty => eprintln!("pkt: Nothing"),
|
OutputPacket::Empty => eprintln!("pkt: Nothing"),
|
||||||
OutputPacket::Rtp(p) => eprintln!("pkt: RTP[{}B]", p.len()),
|
OutputPacket::Rtp(p) => eprintln!("pkt: RTP[{}B]", p.len()),
|
||||||
OutputPacket::Raw(OutputMessage::Silent) => eprintln!("pkt: Raw-Silent"),
|
OutputPacket::Raw(OutputMessage::Silent) => eprintln!("pkt: Raw-Silent"),
|
||||||
OutputPacket::Raw(OutputMessage::Passthrough(p)) =>
|
OutputPacket::Raw(OutputMessage::Passthrough(p)) => {
|
||||||
eprintln!("pkt: Raw-Passthrough[{}B]", p.len()),
|
eprintln!("pkt: Raw-Passthrough[{}B]", p.len())
|
||||||
OutputPacket::Raw(OutputMessage::Mixed(p)) =>
|
},
|
||||||
eprintln!("pkt: Raw-Mixed[{}B]", p.len()),
|
OutputPacket::Raw(OutputMessage::Mixed(p)) => {
|
||||||
|
eprintln!("pkt: Raw-Mixed[{}B]", p.len())
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub struct Output {
|
|||||||
pub uploader: Option<String>,
|
pub uploader: Option<String>,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub webpage_url: Option<String>,
|
pub webpage_url: Option<String>,
|
||||||
|
pub protocol: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Output {
|
impl Output {
|
||||||
|
|||||||
149
src/input/sources/hls.rs
Normal file
149
src/input/sources/hls.rs
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
use std::{
|
||||||
|
io::{ErrorKind as IoErrorKind, Result as IoResult, SeekFrom},
|
||||||
|
pin::Pin,
|
||||||
|
task::{Context, Poll},
|
||||||
|
};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use pin_project::pin_project;
|
||||||
|
use reqwest::{header::HeaderMap, Client};
|
||||||
|
use stream_lib::Event;
|
||||||
|
use symphonia_core::io::MediaSource;
|
||||||
|
use tokio::io::{AsyncRead, AsyncSeek, ReadBuf};
|
||||||
|
use tokio_util::io::StreamReader;
|
||||||
|
|
||||||
|
use crate::input::{
|
||||||
|
AsyncAdapterStream,
|
||||||
|
AsyncMediaSource,
|
||||||
|
AudioStream,
|
||||||
|
AudioStreamError,
|
||||||
|
Compose,
|
||||||
|
Input,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Lazy HLS stream
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HlsRequest {
|
||||||
|
/// HTTP client
|
||||||
|
client: Client,
|
||||||
|
/// URL of hls playlist
|
||||||
|
request: String,
|
||||||
|
/// Headers of the request
|
||||||
|
headers: HeaderMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HlsRequest {
|
||||||
|
#[must_use]
|
||||||
|
/// Create a lazy HLS request.
|
||||||
|
pub fn new(client: Client, request: String) -> Self {
|
||||||
|
Self::new_with_headers(client, request, HeaderMap::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
/// Create a lazy HTTP request.
|
||||||
|
pub fn new_with_headers(client: Client, request: String, headers: HeaderMap) -> Self {
|
||||||
|
HlsRequest {
|
||||||
|
client,
|
||||||
|
request,
|
||||||
|
headers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_stream(&mut self) -> Result<HlsStream, AudioStreamError> {
|
||||||
|
let request = self
|
||||||
|
.client
|
||||||
|
.get(&self.request)
|
||||||
|
.headers(self.headers.clone())
|
||||||
|
.build()
|
||||||
|
.map_err(|why| AudioStreamError::Fail(why.into()))?;
|
||||||
|
|
||||||
|
let hls = stream_lib::download_hls(self.client.clone(), request, None);
|
||||||
|
|
||||||
|
let stream = Box::new(StreamReader::new(hls.map(|ev| match ev {
|
||||||
|
Event::Bytes { bytes } => Ok(bytes),
|
||||||
|
Event::End => Ok(Bytes::new()),
|
||||||
|
Event::Error { error } => Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::UnexpectedEof,
|
||||||
|
error,
|
||||||
|
)),
|
||||||
|
})));
|
||||||
|
|
||||||
|
Ok(HlsStream { stream })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pin_project]
|
||||||
|
struct HlsStream {
|
||||||
|
#[pin]
|
||||||
|
stream: Box<dyn AsyncRead + Send + Sync + Unpin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncRead for HlsStream {
|
||||||
|
fn poll_read(
|
||||||
|
self: Pin<&mut Self>,
|
||||||
|
cx: &mut Context<'_>,
|
||||||
|
buf: &mut ReadBuf<'_>,
|
||||||
|
) -> Poll<IoResult<()>> {
|
||||||
|
AsyncRead::poll_read(self.project().stream, cx, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncSeek for HlsStream {
|
||||||
|
fn start_seek(self: Pin<&mut Self>, _position: SeekFrom) -> IoResult<()> {
|
||||||
|
Err(IoErrorKind::Unsupported.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_complete(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<IoResult<u64>> {
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AsyncMediaSource for HlsStream {
|
||||||
|
fn is_seekable(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn byte_len(&self) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn try_resume(
|
||||||
|
&mut self,
|
||||||
|
_offset: u64,
|
||||||
|
) -> Result<Box<dyn AsyncMediaSource>, AudioStreamError> {
|
||||||
|
Err(AudioStreamError::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Compose for HlsRequest {
|
||||||
|
fn create(&mut self) -> Result<AudioStream<Box<dyn MediaSource>>, AudioStreamError> {
|
||||||
|
self.create_stream().map(|input| {
|
||||||
|
let stream = AsyncAdapterStream::new(Box::new(input), 64 * 1024);
|
||||||
|
|
||||||
|
AudioStream {
|
||||||
|
input: Box::new(stream) as Box<dyn MediaSource>,
|
||||||
|
hint: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_async(
|
||||||
|
&mut self,
|
||||||
|
) -> Result<AudioStream<Box<dyn MediaSource>>, AudioStreamError> {
|
||||||
|
Err(AudioStreamError::Unsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_create_async(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<HlsRequest> for Input {
|
||||||
|
fn from(val: HlsRequest) -> Self {
|
||||||
|
Input::Lazy(Box::new(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod file;
|
mod file;
|
||||||
|
mod hls;
|
||||||
mod http;
|
mod http;
|
||||||
mod ytdl;
|
mod ytdl;
|
||||||
|
|
||||||
pub use self::{file::*, http::*, ytdl::*};
|
pub use self::{file::*, hls::*, http::*, ytdl::*};
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ use std::{error::Error, io::ErrorKind};
|
|||||||
use symphonia_core::io::MediaSource;
|
use symphonia_core::io::MediaSource;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
use super::HlsRequest;
|
||||||
|
|
||||||
const YOUTUBE_DL_COMMAND: &str = "yt-dlp";
|
const YOUTUBE_DL_COMMAND: &str = "yt-dlp";
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -194,14 +196,23 @@ impl Compose for YoutubeDl {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut req = HttpRequest {
|
#[allow(clippy::single_match_else)]
|
||||||
client: self.client.clone(),
|
match result.protocol.as_deref() {
|
||||||
request: result.url,
|
Some("m3u8_native") => {
|
||||||
headers,
|
let mut req =
|
||||||
content_length: result.filesize,
|
HlsRequest::new_with_headers(self.client.clone(), result.url, headers);
|
||||||
};
|
req.create()
|
||||||
|
},
|
||||||
req.create_async().await
|
_ => {
|
||||||
|
let mut req = HttpRequest {
|
||||||
|
client: self.client.clone(),
|
||||||
|
request: result.url,
|
||||||
|
headers,
|
||||||
|
content_length: result.filesize,
|
||||||
|
};
|
||||||
|
req.create_async().await
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_create_async(&self) -> bool {
|
fn should_create_async(&self) -> bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user