Fetch streams in bounded windows and restore yt-dlp for stream URLs
YouTube caps tokenless stream URLs at exactly their leading 1 MiB: plain, open-ended, and oversized requests get 403, and fresh URLs refuse offset starts, so playback died mid-first-minute. PO tokens would lift the cap but the token-capable Innertube clients need signature deciphering that is broken in rustypipe upstream (botguard was built and tested — ineffective through the iOS client). The player now streams every http(s) source through a windowed SourceStream (bounded ~1 MiB ranges, 200-body fallback, eager seek/reconnect so rejected windows fail typed instead of retrying forever) and the capture downloader windows the same way. Stream URLs come from a minimal yt-dlp sidecar again — metadata stays on the pure-Rust rustypipe extractor — whose cipher-solved URLs stream whole files at a throttled ~32 KB/s; a missing binary degrades to 1 MiB streams with a warning. botguard_bin is wired through so streams flip back to pure Rust when upstream deciphering recovers. Live-verified on the exact track from the failure log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
6f787285bf
commit
973bb7bbc6
|
|
@ -303,7 +303,10 @@ name = "audio-player"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"bytes",
|
||||||
"flume",
|
"flume",
|
||||||
|
"futures",
|
||||||
|
"reqwest 0.13.1",
|
||||||
"rodio",
|
"rodio",
|
||||||
"stream-download",
|
"stream-download",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ edition = "2021"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
bytes = "1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
clap-serde-derive = "0.2"
|
clap-serde-derive = "0.2"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
# YouTube provider on rustypipe (pure Rust)
|
# YouTube provider on rustypipe
|
||||||
|
|
||||||
|
> **Amended the same day** by D2-revised below: pure-Rust stream
|
||||||
|
> *fetching* turned out to be blocked by YouTube's PO-token
|
||||||
|
> enforcement, so stream URLs go through a minimal `yt-dlp` sidecar
|
||||||
|
> again while all metadata stays on rustypipe.
|
||||||
|
|
||||||
## Context and problem statement
|
## Context and problem statement
|
||||||
|
|
||||||
|
|
@ -74,6 +79,49 @@ likely not decode locally, but the URL is still honest — e.g. a future
|
||||||
player may cope). Download captures inherit the same choice, so their
|
player may cope). Download captures inherit the same choice, so their
|
||||||
audio is `.m4a`.
|
audio is `.m4a`.
|
||||||
|
|
||||||
|
## D2-revised — Stream *fetching* (2026-07-21, second round)
|
||||||
|
|
||||||
|
Real use immediately hit YouTube's tokenless-fetch enforcement. Live
|
||||||
|
findings against googlevideo (all reproduced, same day):
|
||||||
|
|
||||||
|
- Every rustypipe (iOS-client) stream URL serves **exactly its leading
|
||||||
|
~1 MiB**, measured to the byte (403 at cumulative 1 048 576): plain
|
||||||
|
GETs 403, open-ended ranges 403, bounded ranges work only until the
|
||||||
|
budget is spent, and a **fresh URL refuses to start at an offset** —
|
||||||
|
so chaining fresh URLs per window is impossible.
|
||||||
|
- PO tokens would lift the cap, but rustypipe attaches them only to
|
||||||
|
web-family clients, whose **signature deciphering is broken upstream
|
||||||
|
right now** ("could not extract sig fn name", also on git master) —
|
||||||
|
`rustypipe-botguard` was built and tested; ineffective through the
|
||||||
|
iOS client (no `pot` parameter).
|
||||||
|
- `yt-dlp` (2026.07.04) still solves the cipher challenges; its URLs
|
||||||
|
accept plain and ranged GETs for the whole file, **throttled to
|
||||||
|
~32 KB/s** — twice the itag-140 audio bitrate, so playback holds and
|
||||||
|
captures are merely slow. Its TV/Android clients are SABR-blocked
|
||||||
|
(no URLs at all), so this is the state of the art everywhere.
|
||||||
|
|
||||||
|
**Decisions:**
|
||||||
|
|
||||||
|
1. **Windowed HTTP fetching everywhere.** The player streams through a
|
||||||
|
`WindowedHttpStream` (audio-player, a `stream-download`
|
||||||
|
`SourceStream`) and the capture `Downloader` downloads in the same
|
||||||
|
bounded ~1 MiB `Range` windows — the request pattern real players
|
||||||
|
produce, correct for every provider (servers that ignore `Range`
|
||||||
|
degrade to one 200 body; rejected windows fail typed, never loop).
|
||||||
|
2. **`yt-dlp` returns as a stream-URL-only sidecar.** All metadata
|
||||||
|
(search, playlists, details) stays on the pure-Rust rustypipe
|
||||||
|
extractor; only `get_urls_for_track` consults `yt-dlp` (argv-only,
|
||||||
|
bounded, stderr-summarized). A missing binary degrades with a
|
||||||
|
warning — streams then stop after their first ~1 MiB instead of
|
||||||
|
failing entirely.
|
||||||
|
3. **The pure-Rust path stays wired.** `botguard_bin` is configurable
|
||||||
|
(and PATH-auto-detected by rustypipe); when upstream deciphering
|
||||||
|
recovers, PO-token'd rustypipe URLs make the sidecar unnecessary
|
||||||
|
without code changes beyond removing the fallback preference.
|
||||||
|
|
||||||
|
The per-track capture deadline was raised to 30 minutes — at 32 KB/s a
|
||||||
|
long track legitimately takes that.
|
||||||
|
|
||||||
## D3 — Login and playlists
|
## D3 — Login and playlists
|
||||||
|
|
||||||
The `cookies` setting keeps its meaning: a path to a Netscape
|
The `cookies` setting keeps its meaning: a path to a Netscape
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,10 @@ edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
flume.workspace = true
|
flume.workspace = true
|
||||||
|
futures.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
rodio.workspace = true
|
rodio.workspace = true
|
||||||
stream-download.workspace = true
|
stream-download.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
mod player;
|
mod player;
|
||||||
mod player_engine;
|
mod player_engine;
|
||||||
|
pub mod windowed_http;
|
||||||
|
|
||||||
pub use player::{Player, PlayerError};
|
pub use player::{Player, PlayerError};
|
||||||
pub use player_engine::{MediaInfo, PlayerMessage};
|
pub use player_engine::{MediaInfo, PlayerMessage};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ use rodio::stream::{DeviceSinkBuilder, MixerDeviceSink};
|
||||||
use rodio::{Decoder, Source};
|
use rodio::{Decoder, Source};
|
||||||
use stream_download::storage::temp::TempStorageProvider;
|
use stream_download::storage::temp::TempStorageProvider;
|
||||||
use stream_download::{Settings, StreamDownload};
|
use stream_download::{Settings, StreamDownload};
|
||||||
|
|
||||||
|
use crate::windowed_http::{WindowedHttpParams, WindowedHttpStream};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tracing::{debug, info, instrument, trace, warn};
|
use tracing::{debug, info, instrument, trace, warn};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
@ -78,6 +80,8 @@ pub struct PlayerEngine {
|
||||||
// Present when the engine had to bring its own runtime because the
|
// Present when the engine had to bring its own runtime because the
|
||||||
// creating thread was not inside a tokio context.
|
// creating thread was not inside a tokio context.
|
||||||
_owned_runtime: Option<tokio::runtime::Runtime>,
|
_owned_runtime: Option<tokio::runtime::Runtime>,
|
||||||
|
/// Shared client for windowed network streams.
|
||||||
|
http: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlayerEngine {
|
impl PlayerEngine {
|
||||||
|
|
@ -101,6 +105,10 @@ impl PlayerEngine {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!("audio output device opened");
|
info!("audio output device opened");
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.connect_timeout(Duration::from_secs(30))
|
||||||
|
.build()
|
||||||
|
.context("failed to build the http client")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
current_source: None,
|
current_source: None,
|
||||||
media_info: None,
|
media_info: None,
|
||||||
|
|
@ -111,6 +119,7 @@ impl PlayerEngine {
|
||||||
tx_player,
|
tx_player,
|
||||||
runtime,
|
runtime,
|
||||||
_owned_runtime: owned_runtime,
|
_owned_runtime: owned_runtime,
|
||||||
|
http,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,11 +204,15 @@ impl PlayerEngine {
|
||||||
let duration = match Url::parse(source_str) {
|
let duration = match Url::parse(source_str) {
|
||||||
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
Ok(url) if matches!(url.scheme(), "http" | "https") => {
|
||||||
trace!(%url, "opening network stream");
|
trace!(%url, "opening network stream");
|
||||||
|
// Windowed fetching: some CDNs (googlevideo) reject plain
|
||||||
|
// and open-ended requests with 403 and only serve bounded
|
||||||
|
// ranges (see audio-player/src/windowed_http.rs).
|
||||||
|
let params = WindowedHttpParams::new(url.clone(), self.http.clone());
|
||||||
let reader = self.runtime.block_on(async {
|
let reader = self.runtime.block_on(async {
|
||||||
tokio::time::timeout(
|
tokio::time::timeout(
|
||||||
STREAM_OPEN_TIMEOUT,
|
STREAM_OPEN_TIMEOUT,
|
||||||
StreamDownload::new_http(
|
StreamDownload::new::<WindowedHttpStream>(
|
||||||
url.clone(),
|
params,
|
||||||
TempStorageProvider::new(),
|
TempStorageProvider::new(),
|
||||||
Settings::default(),
|
Settings::default(),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,547 @@
|
||||||
|
//! A windowed HTTP [`SourceStream`]: fetches media in small **bounded**
|
||||||
|
//! `Range` requests instead of one open-ended GET.
|
||||||
|
//!
|
||||||
|
//! Some CDNs (notably googlevideo, see
|
||||||
|
//! `architecture/youtube-rustypipe.md`) reject plain and open-ended
|
||||||
|
//! requests from unattested clients with `403 Forbidden` and only serve
|
||||||
|
//! bounded ranges of about a megabyte — the request pattern real players
|
||||||
|
//! produce. This stream chains such windows transparently; servers that
|
||||||
|
//! ignore the `Range` header (plain `200`) degrade to one continuous
|
||||||
|
//! body without windowing (and without seek support).
|
||||||
|
//!
|
||||||
|
//! Error messages never include the URL — stream URLs may embed tokens.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::task::{Context, Poll};
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use futures::{Future, Stream};
|
||||||
|
use stream_download::source::{DecodeError, SourceStream};
|
||||||
|
use tracing::{debug, trace, warn};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
/// Bytes per request window. Verified against googlevideo: 1 MiB windows
|
||||||
|
/// are served, 8 MiB and open-ended requests are rejected.
|
||||||
|
pub const WINDOW_SIZE: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
/// Parameters for [`WindowedHttpStream::create`].
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct WindowedHttpParams {
|
||||||
|
pub url: Url,
|
||||||
|
pub client: reqwest::Client,
|
||||||
|
/// Window size in bytes; [`WINDOW_SIZE`] outside of tests.
|
||||||
|
pub window: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowedHttpParams {
|
||||||
|
pub fn new(url: Url, client: reqwest::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
url,
|
||||||
|
client,
|
||||||
|
window: WINDOW_SIZE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error creating the stream (first window request failed).
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("{0}")]
|
||||||
|
pub struct WindowedHttpError(String);
|
||||||
|
|
||||||
|
impl DecodeError for WindowedHttpError {}
|
||||||
|
|
||||||
|
type BytesStream = Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send + Sync>>;
|
||||||
|
type WindowFuture = Pin<Box<dyn Future<Output = io::Result<Window>> + Send + Sync>>;
|
||||||
|
|
||||||
|
/// One server response being drained.
|
||||||
|
struct Window {
|
||||||
|
stream: BytesStream,
|
||||||
|
/// Absolute end (exclusive) of the bytes this response carries;
|
||||||
|
/// `u64::MAX` for an un-ranged whole-body response.
|
||||||
|
end: u64,
|
||||||
|
/// Total resource size, when the response revealed it.
|
||||||
|
total: Option<u64>,
|
||||||
|
/// The server honored the `Range` header (`206`).
|
||||||
|
ranged: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum State {
|
||||||
|
/// Draining the current response.
|
||||||
|
Streaming {
|
||||||
|
stream: BytesStream,
|
||||||
|
end: u64,
|
||||||
|
},
|
||||||
|
/// Waiting for the next window request.
|
||||||
|
Requesting(WindowFuture),
|
||||||
|
Finished,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See the module docs.
|
||||||
|
pub struct WindowedHttpStream {
|
||||||
|
// (Debug impl below — the state machine holds unnameable futures.)
|
||||||
|
client: reqwest::Client,
|
||||||
|
url: Url,
|
||||||
|
window: u64,
|
||||||
|
/// Total resource size (from `Content-Range`); `None` when unknown.
|
||||||
|
content_length: Option<u64>,
|
||||||
|
/// The server honors ranges — windowing and seeking are available.
|
||||||
|
ranged: bool,
|
||||||
|
/// Absolute offset of the next byte to hand out.
|
||||||
|
position: u64,
|
||||||
|
/// Exclusive end requested via `seek_range`; `None` = to the end.
|
||||||
|
limit: Option<u64>,
|
||||||
|
/// Bytes to silently drop before yielding (un-ranged reconnect
|
||||||
|
/// catch-up).
|
||||||
|
discard: u64,
|
||||||
|
state: State,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests one bounded window `[start, end_exclusive)`.
|
||||||
|
///
|
||||||
|
/// `206` yields a ranged window (end and total parsed from
|
||||||
|
/// `Content-Range`, falling back to `Content-Length`); `200` means the
|
||||||
|
/// server ignored the header and sent the whole body; `416` past the end
|
||||||
|
/// yields an empty terminal window. Anything else is an error carrying
|
||||||
|
/// the status only.
|
||||||
|
async fn request_window(
|
||||||
|
client: reqwest::Client,
|
||||||
|
url: Url,
|
||||||
|
start: u64,
|
||||||
|
end_exclusive: u64,
|
||||||
|
) -> io::Result<Window> {
|
||||||
|
let range = format!("bytes={start}-{}", end_exclusive.saturating_sub(1));
|
||||||
|
trace!(range, "requesting window");
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
|
.header(reqwest::header::RANGE, range)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| io::Error::other(format!("window request failed: {}", err.without_url())))?;
|
||||||
|
match response.status() {
|
||||||
|
reqwest::StatusCode::PARTIAL_CONTENT => {
|
||||||
|
// `Content-Range: bytes <start>-<end>/<total|*>`
|
||||||
|
let content_range = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_RANGE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.map(str::to_string);
|
||||||
|
let (end, total) = match content_range.as_deref().and_then(parse_content_range) {
|
||||||
|
Some((_, range_end, total)) => (range_end + 1, total),
|
||||||
|
None => {
|
||||||
|
// No usable Content-Range: derive the window end from
|
||||||
|
// the body length.
|
||||||
|
let len = response.content_length().unwrap_or(0);
|
||||||
|
(start + len, None)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Window {
|
||||||
|
stream: Box::pin(response.bytes_stream()),
|
||||||
|
end,
|
||||||
|
total,
|
||||||
|
ranged: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reqwest::StatusCode::OK => {
|
||||||
|
let total = response.content_length();
|
||||||
|
Ok(Window {
|
||||||
|
stream: Box::pin(response.bytes_stream()),
|
||||||
|
end: u64::MAX,
|
||||||
|
total,
|
||||||
|
ranged: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reqwest::StatusCode::RANGE_NOT_SATISFIABLE => Ok(Window {
|
||||||
|
stream: Box::pin(futures::stream::empty()),
|
||||||
|
end: start,
|
||||||
|
total: None,
|
||||||
|
ranged: true,
|
||||||
|
}),
|
||||||
|
status => Err(io::Error::other(format!(
|
||||||
|
"window request rejected: {status}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `bytes <start>-<end>/<total|*>` into `(start, end, total)`.
|
||||||
|
fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
|
||||||
|
let rest = value.trim().strip_prefix("bytes ")?;
|
||||||
|
let (range, total) = rest.split_once('/')?;
|
||||||
|
let (start, end) = range.split_once('-')?;
|
||||||
|
let total = match total.trim() {
|
||||||
|
"*" => None,
|
||||||
|
n => Some(n.parse().ok()?),
|
||||||
|
};
|
||||||
|
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for WindowedHttpStream {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("WindowedHttpStream")
|
||||||
|
.field("content_length", &self.content_length)
|
||||||
|
.field("ranged", &self.ranged)
|
||||||
|
.field("position", &self.position)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowedHttpStream {
|
||||||
|
/// The exclusive end the consumer currently wants: the seek limit
|
||||||
|
/// clamped to the known size.
|
||||||
|
fn effective_end(&self) -> Option<u64> {
|
||||||
|
match (self.limit, self.content_length) {
|
||||||
|
(Some(limit), Some(len)) => Some(limit.min(len)),
|
||||||
|
(Some(limit), None) => Some(limit),
|
||||||
|
(None, len) => len,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules the request for the window starting at `start`, or
|
||||||
|
/// finishes when nothing is left to fetch.
|
||||||
|
fn schedule_window(&mut self, start: u64) {
|
||||||
|
let end = match self.window_end(start) {
|
||||||
|
Some(end) => end,
|
||||||
|
None => {
|
||||||
|
self.state = State::Finished;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
self.state = State::Requesting(Box::pin(request_window(
|
||||||
|
self.client.clone(),
|
||||||
|
self.url.clone(),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exclusive end of the window starting at `start`; `None` when
|
||||||
|
/// nothing is left to fetch.
|
||||||
|
fn window_end(&self, start: u64) -> Option<u64> {
|
||||||
|
match self.effective_end() {
|
||||||
|
Some(effective) if start >= effective => None,
|
||||||
|
Some(effective) => Some((start + self.window).min(effective)),
|
||||||
|
None => Some(start + self.window),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eagerly opens the window at `start` (seeks and reconnects), so
|
||||||
|
/// request failures surface to the caller instead of re-arising on
|
||||||
|
/// every poll.
|
||||||
|
async fn open_window(&mut self, start: u64) -> io::Result<()> {
|
||||||
|
match self.window_end(start) {
|
||||||
|
None => {
|
||||||
|
self.state = State::Finished;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(end) => {
|
||||||
|
let window =
|
||||||
|
request_window(self.client.clone(), self.url.clone(), start, end).await?;
|
||||||
|
self.state = State::Streaming {
|
||||||
|
stream: window.stream,
|
||||||
|
end: window.end,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Stream for WindowedHttpStream {
|
||||||
|
type Item = io::Result<Bytes>;
|
||||||
|
|
||||||
|
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||||
|
let this = &mut *self;
|
||||||
|
loop {
|
||||||
|
match &mut this.state {
|
||||||
|
State::Finished => return Poll::Ready(None),
|
||||||
|
State::Requesting(future) => match future.as_mut().poll(cx) {
|
||||||
|
Poll::Pending => return Poll::Pending,
|
||||||
|
Poll::Ready(Err(err)) => {
|
||||||
|
this.state = State::Finished;
|
||||||
|
return Poll::Ready(Some(Err(err)));
|
||||||
|
}
|
||||||
|
Poll::Ready(Ok(window)) => {
|
||||||
|
this.state = State::Streaming {
|
||||||
|
stream: window.stream,
|
||||||
|
end: window.end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
State::Streaming { stream, end } => match stream.as_mut().poll_next(cx) {
|
||||||
|
Poll::Pending => return Poll::Pending,
|
||||||
|
Poll::Ready(Some(Err(err))) => {
|
||||||
|
return Poll::Ready(Some(Err(io::Error::other(format!(
|
||||||
|
"stream body failed: {}",
|
||||||
|
err.without_url()
|
||||||
|
)))));
|
||||||
|
}
|
||||||
|
Poll::Ready(Some(Ok(mut bytes))) => {
|
||||||
|
// Un-ranged reconnect catch-up: drop the prefix the
|
||||||
|
// consumer already has.
|
||||||
|
if this.discard > 0 {
|
||||||
|
let drop_now = this.discard.min(bytes.len() as u64) as usize;
|
||||||
|
let _ = bytes.split_to(drop_now);
|
||||||
|
this.discard -= drop_now as u64;
|
||||||
|
if bytes.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.position += bytes.len() as u64;
|
||||||
|
return Poll::Ready(Some(Ok(bytes)));
|
||||||
|
}
|
||||||
|
Poll::Ready(None) => {
|
||||||
|
let window_end = *end;
|
||||||
|
if !this.ranged {
|
||||||
|
// One whole-body response: its end is the end.
|
||||||
|
this.state = State::Finished;
|
||||||
|
return Poll::Ready(None);
|
||||||
|
}
|
||||||
|
if let Some(effective) = this.effective_end() {
|
||||||
|
if this.position >= effective {
|
||||||
|
this.state = State::Finished;
|
||||||
|
return Poll::Ready(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if this.position < window_end && this.content_length.is_none() {
|
||||||
|
// A short window with no known total: the
|
||||||
|
// resource ended early.
|
||||||
|
this.state = State::Finished;
|
||||||
|
return Poll::Ready(None);
|
||||||
|
}
|
||||||
|
let next = this.position;
|
||||||
|
this.schedule_window(next);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SourceStream for WindowedHttpStream {
|
||||||
|
type Params = WindowedHttpParams;
|
||||||
|
type StreamCreationError = WindowedHttpError;
|
||||||
|
|
||||||
|
async fn create(params: Self::Params) -> Result<Self, Self::StreamCreationError> {
|
||||||
|
let window = request_window(params.client.clone(), params.url.clone(), 0, params.window)
|
||||||
|
.await
|
||||||
|
.map_err(|err| WindowedHttpError(err.to_string()))?;
|
||||||
|
let content_length = window.total;
|
||||||
|
let ranged = window.ranged;
|
||||||
|
debug!(?content_length, ranged, "windowed http stream open");
|
||||||
|
Ok(Self {
|
||||||
|
client: params.client,
|
||||||
|
url: params.url,
|
||||||
|
window: params.window,
|
||||||
|
content_length,
|
||||||
|
ranged,
|
||||||
|
position: 0,
|
||||||
|
limit: None,
|
||||||
|
discard: 0,
|
||||||
|
state: State::Streaming {
|
||||||
|
stream: window.stream,
|
||||||
|
end: window.end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_length(&self) -> Option<u64> {
|
||||||
|
self.content_length
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seek_range(&mut self, start: u64, end: Option<u64>) -> io::Result<()> {
|
||||||
|
trace!(start, ?end, "seek");
|
||||||
|
self.position = start;
|
||||||
|
self.limit = end;
|
||||||
|
self.discard = 0;
|
||||||
|
// Eager: the request happens *here*, so a rejected window is an
|
||||||
|
// error the retry logic can time out on — a lazily scheduled
|
||||||
|
// request that keeps failing would look like a successful
|
||||||
|
// reconnect every time and retry forever.
|
||||||
|
self.open_window(start).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reconnect(&mut self, current_position: u64) -> io::Result<()> {
|
||||||
|
if self.ranged {
|
||||||
|
self.position = current_position;
|
||||||
|
self.discard = 0;
|
||||||
|
return self.open_window(current_position).await;
|
||||||
|
}
|
||||||
|
// The server does not honor ranges: refetch from the start and
|
||||||
|
// drop what the consumer already has.
|
||||||
|
warn!(
|
||||||
|
current_position,
|
||||||
|
"reconnecting to a server without range support"
|
||||||
|
);
|
||||||
|
self.position = current_position;
|
||||||
|
self.discard = current_position;
|
||||||
|
let window = request_window(self.client.clone(), self.url.clone(), 0, u64::MAX).await?;
|
||||||
|
self.state = State::Streaming {
|
||||||
|
stream: window.stream,
|
||||||
|
end: window.end,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supports_seek(&self) -> bool {
|
||||||
|
self.ranged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
/// A minimal HTTP server for one fixed body. With `ranged`, bounded
|
||||||
|
/// `Range` requests get `206` + `Content-Range` slices — and, like
|
||||||
|
/// googlevideo, open-ended or oversized ranges get `403`. Without,
|
||||||
|
/// every request gets the whole body as `200`.
|
||||||
|
async fn serve(body: Vec<u8>, ranged: bool, max_window: u64) -> Url {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("bind test server");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((mut sock, _)) = listener.accept().await {
|
||||||
|
let body = body.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
match sock.read(&mut chunk).await {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let request = String::from_utf8_lossy(&buf).to_lowercase();
|
||||||
|
let range = request
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("range: bytes="))
|
||||||
|
.and_then(|spec| {
|
||||||
|
let (start, end) = spec.trim().split_once('-')?;
|
||||||
|
let start: u64 = start.parse().ok()?;
|
||||||
|
let end: Option<u64> = end.parse().ok();
|
||||||
|
Some((start, end))
|
||||||
|
});
|
||||||
|
let total = body.len() as u64;
|
||||||
|
let response = match (ranged, range) {
|
||||||
|
(true, Some((start, Some(end))))
|
||||||
|
if start < total && end - start < max_window =>
|
||||||
|
{
|
||||||
|
let end = end.min(total - 1);
|
||||||
|
let slice = &body[start as usize..=end as usize];
|
||||||
|
let mut head = format!(
|
||||||
|
"HTTP/1.1 206 Partial Content\r\nContent-Range: bytes {start}-{end}/{total}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
slice.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
head.extend_from_slice(slice);
|
||||||
|
head
|
||||||
|
}
|
||||||
|
(true, Some((start, _))) if start >= total => {
|
||||||
|
format!("HTTP/1.1 416 Range Not Satisfiable\r\nContent-Range: bytes */{total}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
|
||||||
|
.into_bytes()
|
||||||
|
}
|
||||||
|
(true, _) => {
|
||||||
|
// Open-ended or oversized: rejected, like
|
||||||
|
// googlevideo without a PO token.
|
||||||
|
b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec()
|
||||||
|
}
|
||||||
|
(false, _) => {
|
||||||
|
let mut head = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {total}\r\nConnection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
head.extend_from_slice(&body);
|
||||||
|
head
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = sock.write_all(&response).await;
|
||||||
|
let _ = sock.shutdown().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Url::parse(&format!("http://{addr}/stream")).expect("url")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn params(url: Url, window: u64) -> WindowedHttpParams {
|
||||||
|
WindowedHttpParams {
|
||||||
|
url,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_all(stream: &mut WindowedHttpStream) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
out.extend_from_slice(&chunk.expect("chunk"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn chains_windows_over_a_range_only_server() {
|
||||||
|
// 10 windows of 16 bytes against a server that 403s anything
|
||||||
|
// bigger — exactly the googlevideo behavior.
|
||||||
|
let body: Vec<u8> = (0..160u32).map(|i| i as u8).collect();
|
||||||
|
let url = serve(body.clone(), true, 64).await;
|
||||||
|
let mut stream = WindowedHttpStream::create(params(url, 16))
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
assert_eq!(stream.content_length(), Some(160));
|
||||||
|
assert!(stream.supports_seek());
|
||||||
|
assert_eq!(read_all(&mut stream).await, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seeks_restart_the_window_chain() {
|
||||||
|
let body: Vec<u8> = (0..160u32).map(|i| i as u8).collect();
|
||||||
|
let url = serve(body.clone(), true, 64).await;
|
||||||
|
let mut stream = WindowedHttpStream::create(params(url, 16))
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
stream.seek_range(100, None).await.expect("seek");
|
||||||
|
assert_eq!(read_all(&mut stream).await, body[100..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plain_servers_stream_one_body_without_seek() {
|
||||||
|
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
|
||||||
|
let url = serve(body.clone(), false, 0).await;
|
||||||
|
let mut stream = WindowedHttpStream::create(params(url, 16))
|
||||||
|
.await
|
||||||
|
.expect("create");
|
||||||
|
assert_eq!(stream.content_length(), Some(100));
|
||||||
|
assert!(!stream.supports_seek());
|
||||||
|
assert_eq!(read_all(&mut stream).await, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejections_surface_as_errors_without_the_url() {
|
||||||
|
// A ranged server with max_window 0 rejects everything.
|
||||||
|
let url = serve(vec![0; 10], true, 0).await;
|
||||||
|
let err = WindowedHttpStream::create(params(url, 16))
|
||||||
|
.await
|
||||||
|
.expect_err("403 fails creation");
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(message.contains("403"), "{message}");
|
||||||
|
assert!(!message.contains("127.0.0.1"), "no url: {message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_range_parses_totals_and_wildcards() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_content_range("bytes 0-1023/7831134"),
|
||||||
|
Some((0, 1023, Some(7831134)))
|
||||||
|
);
|
||||||
|
assert_eq!(parse_content_range("bytes 5-9/*"), Some((5, 9, None)));
|
||||||
|
assert_eq!(parse_content_range("garbage"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,9 +27,19 @@ use tracing::warn;
|
||||||
/// Connect timeout for download requests.
|
/// Connect timeout for download requests.
|
||||||
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
pub const DOWNLOAD_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
|
/// Bytes per download request window. Some CDNs (googlevideo) reject
|
||||||
|
/// plain and open-ended requests from unattested clients with 403 and
|
||||||
|
/// only serve bounded ranges around this size — the same windowing the
|
||||||
|
/// player uses (audio-player/src/windowed_http.rs). Servers that ignore
|
||||||
|
/// the `Range` header answer 200 with the whole body, which is handled
|
||||||
|
/// as a single window.
|
||||||
|
pub const DOWNLOAD_WINDOW: u64 = 1024 * 1024;
|
||||||
|
|
||||||
/// Total per-track deadline: URL fetch, request, and streaming the whole
|
/// Total per-track deadline: URL fetch, request, and streaming the whole
|
||||||
/// body. A stalled transfer aborts the capture instead of hanging it.
|
/// body. A stalled transfer aborts the capture instead of hanging it.
|
||||||
pub const DOWNLOAD_TRACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
|
/// Generous: tokenless YouTube URLs are throttled to ~32 KB/s, so a long
|
||||||
|
/// track legitimately takes many minutes.
|
||||||
|
pub const DOWNLOAD_TRACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1800);
|
||||||
|
|
||||||
/// Size limits for one capture. The walk aborts with
|
/// Size limits for one capture. The walk aborts with
|
||||||
/// [`CaptureError::TooLarge`] when a limit trips — a runaway provider tree
|
/// [`CaptureError::TooLarge`] when a limit trips — a runaway provider tree
|
||||||
|
|
@ -209,21 +219,29 @@ struct TrackEntry {
|
||||||
///
|
///
|
||||||
/// One shared HTTP client with a connect timeout; each track is bounded by
|
/// One shared HTTP client with a connect timeout; each track is bounded by
|
||||||
/// [`DOWNLOAD_TRACK_TIMEOUT`] end to end and never retried (a capture is
|
/// [`DOWNLOAD_TRACK_TIMEOUT`] end to end and never retried (a capture is
|
||||||
/// re-runnable; resuming re-attempts what is missing). Bodies are streamed
|
/// re-runnable; resuming re-attempts what is missing). Bodies are fetched
|
||||||
/// to disk against the capture's remaining byte budget.
|
/// in bounded [`DOWNLOAD_WINDOW`] ranges and streamed to disk against the
|
||||||
|
/// capture's remaining byte budget.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Downloader {
|
pub struct Downloader {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
|
window: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Downloader {
|
impl Downloader {
|
||||||
/// Builds the shared HTTP client. Fails only when the TLS backend
|
/// Builds the shared HTTP client. Fails only when the TLS backend
|
||||||
/// cannot initialize.
|
/// cannot initialize.
|
||||||
pub fn new() -> Result<Self, reqwest::Error> {
|
pub fn new() -> Result<Self, reqwest::Error> {
|
||||||
|
Self::with_window(DOWNLOAD_WINDOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::new`] with an explicit window size — the seam the
|
||||||
|
/// window-chaining tests use.
|
||||||
|
pub(crate) fn with_window(window: u64) -> Result<Self, reqwest::Error> {
|
||||||
let http = reqwest::Client::builder()
|
let http = reqwest::Client::builder()
|
||||||
.connect_timeout(DOWNLOAD_CONNECT_TIMEOUT)
|
.connect_timeout(DOWNLOAD_CONNECT_TIMEOUT)
|
||||||
.build()?;
|
.build()?;
|
||||||
Ok(Self { http })
|
Ok(Self { http, window })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settles one track: audio file first, then the toml pointing at
|
/// Settles one track: audio file first, then the toml pointing at
|
||||||
|
|
@ -312,14 +330,30 @@ impl Downloader {
|
||||||
let download_err = |err: reqwest::Error| {
|
let download_err = |err: reqwest::Error| {
|
||||||
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
CaptureError::Download(format!("{track_path}: {}", err.without_url()))
|
||||||
};
|
};
|
||||||
|
// First bounded window; its status decides the mode. Some CDNs
|
||||||
|
// (googlevideo) 403 plain and open-ended requests, so every
|
||||||
|
// request carries a bounded range; servers that ignore the header
|
||||||
|
// answer 200 with the whole body.
|
||||||
|
let mut start = 0u64;
|
||||||
let mut response = self
|
let mut response = self
|
||||||
.http
|
.http
|
||||||
.get(url)
|
.get(url)
|
||||||
|
.header(
|
||||||
|
reqwest::header::RANGE,
|
||||||
|
format!("bytes=0-{}", self.window - 1),
|
||||||
|
)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(download_err)?
|
|
||||||
.error_for_status()
|
|
||||||
.map_err(download_err)?;
|
.map_err(download_err)?;
|
||||||
|
let windowed = match response.status() {
|
||||||
|
reqwest::StatusCode::PARTIAL_CONTENT => true,
|
||||||
|
reqwest::StatusCode::OK => false,
|
||||||
|
status => {
|
||||||
|
return Err(CaptureError::Download(format!(
|
||||||
|
"{track_path}: HTTP status {status}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
let content_type = response
|
let content_type = response
|
||||||
.headers()
|
.headers()
|
||||||
.get(reqwest::header::CONTENT_TYPE)
|
.get(reqwest::header::CONTENT_TYPE)
|
||||||
|
|
@ -328,13 +362,65 @@ impl Downloader {
|
||||||
let ext = extension_for(content_type.as_deref(), url);
|
let ext = extension_for(content_type.as_deref(), url);
|
||||||
let audio_name = audio_file_name(index, &track.title, &ext);
|
let audio_name = audio_file_name(index, &track.title, &ext);
|
||||||
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).await?;
|
let mut audio = tokio::fs::File::create(dir.join(&audio_name)).await?;
|
||||||
while let Some(chunk) = response.chunk().await.map_err(download_err)? {
|
loop {
|
||||||
let len = chunk.len() as u64;
|
// The window's extent and the resource total, from
|
||||||
if len > *bytes_left {
|
// `Content-Range: bytes <a>-<b>/<total>` (Content-Length
|
||||||
return Err(CaptureError::TooLarge("download budget exhausted"));
|
// fallback for the extent).
|
||||||
|
let (window_end, total) = if windowed {
|
||||||
|
let content_range = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::CONTENT_RANGE)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(parse_content_range);
|
||||||
|
match content_range {
|
||||||
|
Some((_, range_end, total)) => (range_end + 1, total),
|
||||||
|
None => (start + response.content_length().unwrap_or(0), None),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(u64::MAX, None)
|
||||||
|
};
|
||||||
|
let mut received = 0u64;
|
||||||
|
while let Some(chunk) = response.chunk().await.map_err(download_err)? {
|
||||||
|
let len = chunk.len() as u64;
|
||||||
|
if len > *bytes_left {
|
||||||
|
return Err(CaptureError::TooLarge("download budget exhausted"));
|
||||||
|
}
|
||||||
|
*bytes_left -= len;
|
||||||
|
received += len;
|
||||||
|
tokio::io::AsyncWriteExt::write_all(&mut audio, &chunk).await?;
|
||||||
|
}
|
||||||
|
if !windowed {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
start = window_end.max(start + received);
|
||||||
|
match total {
|
||||||
|
Some(total) if start >= total => break,
|
||||||
|
// A short or empty window with no known total: the
|
||||||
|
// resource ended early.
|
||||||
|
_ if received == 0 => break,
|
||||||
|
None if received < self.window => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
response = self
|
||||||
|
.http
|
||||||
|
.get(url)
|
||||||
|
.header(
|
||||||
|
reqwest::header::RANGE,
|
||||||
|
format!("bytes={start}-{}", start + self.window - 1),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(download_err)?;
|
||||||
|
match response.status() {
|
||||||
|
reqwest::StatusCode::PARTIAL_CONTENT => {}
|
||||||
|
// Past the end: everything is on disk.
|
||||||
|
reqwest::StatusCode::RANGE_NOT_SATISFIABLE => break,
|
||||||
|
status => {
|
||||||
|
return Err(CaptureError::Download(format!(
|
||||||
|
"{track_path}: HTTP status {status}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
*bytes_left -= len;
|
|
||||||
tokio::io::AsyncWriteExt::write_all(&mut audio, &chunk).await?;
|
|
||||||
}
|
}
|
||||||
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
|
tokio::io::AsyncWriteExt::flush(&mut audio).await?;
|
||||||
drop(audio);
|
drop(audio);
|
||||||
|
|
@ -346,6 +432,18 @@ impl Downloader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses `bytes <start>-<end>/<total|*>` into `(start, end, total)`.
|
||||||
|
fn parse_content_range(value: &str) -> Option<(u64, u64, Option<u64>)> {
|
||||||
|
let rest = value.trim().strip_prefix("bytes ")?;
|
||||||
|
let (range, total) = rest.split_once('/')?;
|
||||||
|
let (start, end) = range.split_once('-')?;
|
||||||
|
let total = match total.trim() {
|
||||||
|
"*" => None,
|
||||||
|
n => Some(n.parse().ok()?),
|
||||||
|
};
|
||||||
|
Some((start.trim().parse().ok()?, end.trim().parse().ok()?, total))
|
||||||
|
}
|
||||||
|
|
||||||
/// Writes the skipped toml for `track` at listing position `index`,
|
/// Writes the skipped toml for `track` at listing position `index`,
|
||||||
/// overwriting whatever was there.
|
/// overwriting whatever was there.
|
||||||
async fn write_skipped(
|
async fn write_skipped(
|
||||||
|
|
@ -631,6 +729,16 @@ mod tests {
|
||||||
assert_eq!(extension_for(None, "not a url"), "bin");
|
assert_eq!(extension_for(None, "not a url"), "bin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_range_parses_totals_and_wildcards() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_content_range("bytes 0-1023/7831134"),
|
||||||
|
Some((0, 1023, Some(7831134)))
|
||||||
|
);
|
||||||
|
assert_eq!(parse_content_range("bytes 5-9/*"), Some((5, 9, None)));
|
||||||
|
assert_eq!(parse_content_range("garbage"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_files_pair_with_their_toml_names() {
|
fn audio_files_pair_with_their_toml_names() {
|
||||||
// Same prefix and sanitized stem as `fsdy::track_file_name`, so the
|
// Same prefix and sanitized stem as `fsdy::track_file_name`, so the
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,74 @@ mod tests {
|
||||||
format!("http://{addr}/stream")
|
format!("http://{addr}/stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [`serve`], but the server enforces bounded ranges the way
|
||||||
|
/// googlevideo does: ranged requests up to `max_window` bytes get
|
||||||
|
/// `206` + `Content-Range` slices, anything else (plain, open-ended,
|
||||||
|
/// oversized) gets `403`.
|
||||||
|
async fn serve_ranged(content_type: &'static str, body: Vec<u8>, max_window: u64) -> String {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("bind test server");
|
||||||
|
let addr = listener.local_addr().expect("test server addr");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Ok((mut sock, _)) = listener.accept().await {
|
||||||
|
let body = body.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
match sock.read(&mut chunk).await {
|
||||||
|
Ok(0) | Err(_) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
if buf.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let request = String::from_utf8_lossy(&buf).to_lowercase();
|
||||||
|
let range = request
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("range: bytes="))
|
||||||
|
.and_then(|spec| {
|
||||||
|
let (start, end) = spec.trim().split_once('-')?;
|
||||||
|
let start: u64 = start.parse().ok()?;
|
||||||
|
let end: u64 = end.parse().ok()?;
|
||||||
|
Some((start, end))
|
||||||
|
});
|
||||||
|
let total = body.len() as u64;
|
||||||
|
let response = match range {
|
||||||
|
Some((start, end)) if start < total && end - start < max_window => {
|
||||||
|
let end = end.min(total - 1);
|
||||||
|
let slice = &body[start as usize..=end as usize];
|
||||||
|
let mut head = format!(
|
||||||
|
"HTTP/1.1 206 Partial Content\r\nContent-Type: {content_type}\r\n\
|
||||||
|
Content-Range: bytes {start}-{end}/{total}\r\n\
|
||||||
|
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
slice.len()
|
||||||
|
)
|
||||||
|
.into_bytes();
|
||||||
|
head.extend_from_slice(slice);
|
||||||
|
head
|
||||||
|
}
|
||||||
|
Some((start, _)) if start >= total => format!(
|
||||||
|
"HTTP/1.1 416 Range Not Satisfiable\r\n\
|
||||||
|
Content-Range: bytes */{total}\r\nContent-Length: 0\r\n\
|
||||||
|
Connection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
.into_bytes(),
|
||||||
|
_ => b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
.to_vec(),
|
||||||
|
};
|
||||||
|
let _ = sock.write_all(&response).await;
|
||||||
|
let _ = sock.shutdown().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
format!("http://{addr}/stream")
|
||||||
|
}
|
||||||
|
|
||||||
/// A provider with one album (`/mock/a`: tracks `one`, `two`) whose
|
/// A provider with one album (`/mock/a`: tracks `one`, `two`) whose
|
||||||
/// stream URLs are the test server's, and a node-level download
|
/// stream URLs are the test server's, and a node-level download
|
||||||
/// blessing toggle.
|
/// blessing toggle.
|
||||||
|
|
@ -626,6 +694,31 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn downloads_chain_bounded_windows_on_strict_cdns() {
|
||||||
|
// A googlevideo-style server: only bounded ranges under 16 bytes
|
||||||
|
// are served; plain or oversized requests are 403. A downloader
|
||||||
|
// with a 10-byte window must fetch the 100-byte body completely.
|
||||||
|
let body: Vec<u8> = (0..100u32).map(|i| i as u8).collect();
|
||||||
|
let url = serve_ranged("audio/flac", body.clone(), 16).await;
|
||||||
|
let mock = MockProvider::new(&[("/mock/a/1", &url)], true);
|
||||||
|
let dir = TempDir::new().expect("store tempdir");
|
||||||
|
let store_dir = dir.path().join("captures");
|
||||||
|
tokio::fs::create_dir_all(&store_dir).await.expect("mkdir");
|
||||||
|
let store = CaptureStore {
|
||||||
|
dir: store_dir,
|
||||||
|
sink: crate::capture::Sink::Download(
|
||||||
|
crate::capture::Downloader::with_window(10).expect("downloader"),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.capture(&mock, "/mock/a/1", "windowed", &silent())
|
||||||
|
.await
|
||||||
|
.expect("windowed capture");
|
||||||
|
let audio = fs::read(store.dir().join("windowed/0001 one.flac")).expect("audio");
|
||||||
|
assert_eq!(audio, body, "all windows stitched in order");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn capture_reports_progress_totals_and_skips() {
|
async fn capture_reports_progress_totals_and_skips() {
|
||||||
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
let url = serve("200 OK", "audio/flac", b"x".to_vec()).await;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ let
|
||||||
d2
|
d2
|
||||||
pkg-config
|
pkg-config
|
||||||
protobuf
|
protobuf
|
||||||
|
# Stream-URL sidecar for the ytdy provider: YouTube caps tokenless
|
||||||
|
# stream URLs at ~1 MiB and yt-dlp is the only maintained cipher
|
||||||
|
# solver (architecture/youtube-rustypipe.md, D2-revised).
|
||||||
|
yt-dlp
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,35 @@
|
||||||
# Implementation summaries
|
# Implementation summaries
|
||||||
|
|
||||||
|
## youtube stream fetching (2026-07-21, follow-up)
|
||||||
|
|
||||||
|
The rustypipe swap fixed the decode problem but real playback then hit
|
||||||
|
YouTube's tokenless-fetch enforcement, measured live: every stream URL
|
||||||
|
serves exactly its leading 1 MiB (403 beyond — plain, open-ended, and
|
||||||
|
oversized requests are rejected outright, and fresh URLs refuse offset
|
||||||
|
starts, killing URL-per-window chaining). PO tokens would lift the cap,
|
||||||
|
but rustypipe only attaches them to web clients whose signature
|
||||||
|
deciphering is currently broken upstream (verified on git master;
|
||||||
|
`rustypipe-botguard` built and tested — ineffective through the iOS
|
||||||
|
client). `yt-dlp` still solves the ciphers; its URLs stream the whole
|
||||||
|
file at a throttled ~32 KB/s — double the audio bitrate.
|
||||||
|
|
||||||
|
Shipped: (1) **windowed HTTP fetching** everywhere — a
|
||||||
|
`WindowedHttpStream` `SourceStream` in audio-player (bounded ~1 MiB
|
||||||
|
ranges, 200-body fallback for range-ignoring servers, eager
|
||||||
|
seek/reconnect so rejected windows fail typed instead of retrying
|
||||||
|
forever, URLs never in errors) and the same windowing in the capture
|
||||||
|
downloader (strict-CDN test stitches windows byte-exact); (2) `yt-dlp`
|
||||||
|
back as a **stream-URL-only sidecar** — all metadata stays on
|
||||||
|
rustypipe; a missing binary degrades to 1 MiB streams with a warning,
|
||||||
|
a failing call falls back to the rustypipe URL; (3) `botguard_bin`
|
||||||
|
config passthrough so streams flip back to pure Rust when upstream
|
||||||
|
deciphering recovers; (4) capture per-track deadline raised to 30 min
|
||||||
|
for the throttle. Live-verified end to end on the exact track from the
|
||||||
|
user's log: sidecar URL in 3 s, windowed stream + rodio decode
|
||||||
|
producing samples at 4.3 s. 179 workspace tests green (12 new);
|
||||||
|
`quality/youtube-rustypipe.md` gained a checked "Stream fetching"
|
||||||
|
section.
|
||||||
|
|
||||||
## youtube-rustypipe (2026-07-21)
|
## youtube-rustypipe (2026-07-21)
|
||||||
|
|
||||||
Built per `plan/youtube-rustypipe.md`: the ytdy provider's `yt-dlp`
|
Built per `plan/youtube-rustypipe.md`: the ytdy provider's `yt-dlp`
|
||||||
|
|
|
||||||
|
|
@ -27,3 +27,12 @@ Ordered tasks; each names its verification (ytdy tests and/or gates in
|
||||||
URL fetch, decode, download capture — removed after passing.
|
URL fetch, decode, download capture — removed after passing.
|
||||||
- [x] **T7 — Docs.** `plan/summary.md` section incl. deviations;
|
- [x] **T7 — Docs.** `plan/summary.md` section incl. deviations;
|
||||||
reconcile the architecture docs.
|
reconcile the architecture docs.
|
||||||
|
- [x] **T8 — Same-day fix: windowed fetching + stream sidecar.**
|
||||||
|
YouTube caps tokenless URLs at their leading ~1 MiB (measured; offset
|
||||||
|
starts refused; upstream web-client deciphering broken; botguard
|
||||||
|
ineffective through iOS). `WindowedHttpStream` in audio-player,
|
||||||
|
windowed capture downloads, `yt-dlp` back as a stream-URL-only
|
||||||
|
sidecar with degrade-and-fallback, `botguard_bin` passthrough,
|
||||||
|
capture deadline 30 min. Verifies: audio-player windowed tests,
|
||||||
|
capture window-chaining test, ytdy sidecar tests, gates "Stream
|
||||||
|
fetching"; live probe on the failing track.
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,23 @@ probe).
|
||||||
- [x] Live probe (removed after passing): search + stream URL +
|
- [x] Live probe (removed after passing): search + stream URL +
|
||||||
download capture through the real provider; stream decodes with the
|
download capture through the real provider; stream decodes with the
|
||||||
player's decoder stack.
|
player's decoder stack.
|
||||||
|
|
||||||
|
## Stream fetching (D2-revised, same-day fix)
|
||||||
|
|
||||||
|
- [x] The player streams every http(s) source through the windowed
|
||||||
|
`SourceStream` (bounded ~1 MiB ranges); servers that ignore `Range`
|
||||||
|
degrade to one 200 body; rejected windows are typed errors (eager
|
||||||
|
seek/reconnect — no infinite retry loops), and error messages never
|
||||||
|
carry URLs.
|
||||||
|
- [x] The capture downloader uses the same bounded windows with a 200
|
||||||
|
whole-body fallback; existing capture tests pass unchanged and a
|
||||||
|
googlevideo-style strict server test stitches windows correctly.
|
||||||
|
- [x] `yt-dlp` is consulted **only** for stream URLs (argv-only,
|
||||||
|
bounded, stderr summarized ≤200 chars); a missing binary degrades
|
||||||
|
with a warning, never a failed init; a failing call falls back to the
|
||||||
|
rustypipe URL.
|
||||||
|
- [x] `botguard_bin` is configurable and forwarded to rustypipe (the
|
||||||
|
future pure-Rust exit).
|
||||||
|
- [x] Live probe (removed after passing): the user's exact failing
|
||||||
|
track resolved via the sidecar and decoded through the real
|
||||||
|
player path (windowed StreamDownload → rodio) in ~4 s.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ dirs.workspace = true
|
||||||
rustypipe.workspace = true
|
rustypipe.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["process", "time", "io-util"] }
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -125,12 +125,22 @@ impl RustyPipeExtractor {
|
||||||
/// Builds the client. `storage_dir` holds rustypipe's cache file
|
/// Builds the client. `storage_dir` holds rustypipe's cache file
|
||||||
/// (client state and the rotated auth cookie — as secret as the
|
/// (client state and the rotated auth cookie — as secret as the
|
||||||
/// cookies file itself); `timeout` bounds every YouTube request.
|
/// cookies file itself); `timeout` bounds every YouTube request.
|
||||||
pub fn new(storage_dir: PathBuf, timeout: Duration) -> Result<Self, ExtractError> {
|
/// `botguard_bin` optionally names a `rustypipe-botguard` binary for
|
||||||
|
/// PO-token attestation (rustypipe also auto-detects it on PATH).
|
||||||
|
pub fn new(
|
||||||
|
storage_dir: PathBuf,
|
||||||
|
timeout: Duration,
|
||||||
|
botguard_bin: Option<&Path>,
|
||||||
|
) -> Result<Self, ExtractError> {
|
||||||
std::fs::create_dir_all(&storage_dir)
|
std::fs::create_dir_all(&storage_dir)
|
||||||
.map_err(|err| ExtractError::Client(format!("storage directory: {err}")))?;
|
.map_err(|err| ExtractError::Client(format!("storage directory: {err}")))?;
|
||||||
let rp = RustyPipe::builder()
|
let mut builder = RustyPipe::builder()
|
||||||
.storage_dir(storage_dir)
|
.storage_dir(storage_dir)
|
||||||
.timeout(timeout)
|
.timeout(timeout);
|
||||||
|
if let Some(bin) = botguard_bin {
|
||||||
|
builder = builder.botguard_bin(bin.as_os_str().to_owned());
|
||||||
|
}
|
||||||
|
let rp = builder
|
||||||
.build()
|
.build()
|
||||||
.map_err(|err| ExtractError::Client(err.to_string()))?;
|
.map_err(|err| ExtractError::Client(err.to_string()))?;
|
||||||
Ok(Self { rp })
|
Ok(Self { rp })
|
||||||
|
|
@ -297,7 +307,7 @@ mod tests {
|
||||||
// missing file short-circuit before any request.
|
// missing file short-circuit before any request.
|
||||||
let dir = tempfile::TempDir::new().expect("tempdir");
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||||
let extractor =
|
let extractor =
|
||||||
RustyPipeExtractor::new(dir.path().join("rustypipe"), Duration::from_secs(5))
|
RustyPipeExtractor::new(dir.path().join("rustypipe"), Duration::from_secs(5), None)
|
||||||
.expect("client builds");
|
.expect("client builds");
|
||||||
assert!(!extractor.login(None).await);
|
assert!(!extractor.login(None).await);
|
||||||
assert!(!extractor.login(Some(&dir.path().join("gone.txt"))).await);
|
assert!(!extractor.login(Some(&dir.path().join("gone.txt"))).await);
|
||||||
|
|
|
||||||
149
ytdy/src/lib.rs
149
ytdy/src/lib.rs
|
|
@ -1,6 +1,10 @@
|
||||||
//! YouTube media provider, backed by the pure-Rust `rustypipe`
|
//! YouTube media provider: metadata (search, playlists, video details)
|
||||||
//! Innertube client (see `architecture/youtube-provider.md` for the
|
//! comes from the pure-Rust `rustypipe` Innertube client; **stream
|
||||||
//! tree shape and `architecture/youtube-rustypipe.md` for the engine).
|
//! URLs** go through a minimal `yt-dlp` sidecar when available, because
|
||||||
|
//! YouTube currently caps tokenless URLs at their leading ~1 MiB (see
|
||||||
|
//! `architecture/youtube-provider.md` for the tree shape and
|
||||||
|
//! `architecture/youtube-rustypipe.md` for the engine and its
|
||||||
|
//! constraints).
|
||||||
//!
|
//!
|
||||||
//! Mounted at [`PROVIDER_ROOT`]. Search works without login: creatable
|
//! Mounted at [`PROVIDER_ROOT`]. Search works without login: creatable
|
||||||
//! search-term nodes exactly like `/tidal/search` (in-memory terms,
|
//! search-term nodes exactly like `/tidal/search` (in-memory terms,
|
||||||
|
|
@ -19,7 +23,9 @@ use serde::{Deserialize, Serialize};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
pub mod extract;
|
pub mod extract;
|
||||||
|
pub mod ytdlp;
|
||||||
use extract::{Extract, ExtractError, RustyPipeExtractor, VideoEntry};
|
use extract::{Extract, ExtractError, RustyPipeExtractor, VideoEntry};
|
||||||
|
use ytdlp::YtDlp;
|
||||||
|
|
||||||
/// First path segment owned by this provider.
|
/// First path segment owned by this provider.
|
||||||
pub const PROVIDER_ROOT: &str = "/youtube";
|
pub const PROVIDER_ROOT: &str = "/youtube";
|
||||||
|
|
@ -49,6 +55,19 @@ pub struct Settings {
|
||||||
/// Per-request timeout in seconds. Default
|
/// Per-request timeout in seconds. Default
|
||||||
/// [`DEFAULT_CALL_TIMEOUT_SECS`].
|
/// [`DEFAULT_CALL_TIMEOUT_SECS`].
|
||||||
pub call_timeout_secs: Option<u64>,
|
pub call_timeout_secs: Option<u64>,
|
||||||
|
/// The `yt-dlp` binary used **only** to resolve stream URLs (a bare
|
||||||
|
/// name resolves via PATH; default `yt-dlp`). YouTube currently
|
||||||
|
/// caps tokenless URLs at their leading ~1 MiB, and yt-dlp is the
|
||||||
|
/// only maintained cipher solver — without it, playback of a
|
||||||
|
/// YouTube track stops after roughly a minute (see
|
||||||
|
/// architecture/youtube-rustypipe.md).
|
||||||
|
pub binary: Option<PathBuf>,
|
||||||
|
/// Optional `rustypipe-botguard` binary for PO-token attestation
|
||||||
|
/// (also auto-detected on PATH). Currently ineffective — the
|
||||||
|
/// token-capable Innertube clients need signature deciphering that
|
||||||
|
/// is broken in rustypipe upstream — but wired so streams flip back
|
||||||
|
/// to pure Rust when upstream recovers.
|
||||||
|
pub botguard_bin: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed `/youtube/...` path.
|
/// A parsed `/youtube/...` path.
|
||||||
|
|
@ -118,6 +137,9 @@ fn entry_to_track(entry: &VideoEntry, node_path: &str) -> Track {
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
extractor: Box<dyn Extract>,
|
extractor: Box<dyn Extract>,
|
||||||
|
/// The stream-URL sidecar; `None` degrades to rustypipe URLs (which
|
||||||
|
/// YouTube currently caps at their leading ~1 MiB).
|
||||||
|
ytdlp: Option<YtDlp>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
/// The login attempt at init succeeded — gates the playlists
|
/// The login attempt at init succeeded — gates the playlists
|
||||||
/// subtree.
|
/// subtree.
|
||||||
|
|
@ -133,6 +155,7 @@ impl Client {
|
||||||
fn with_extractor(extractor: Box<dyn Extract>, settings: Settings, logged_in: bool) -> Self {
|
fn with_extractor(extractor: Box<dyn Extract>, settings: Settings, logged_in: bool) -> Self {
|
||||||
Self {
|
Self {
|
||||||
extractor,
|
extractor,
|
||||||
|
ytdlp: None,
|
||||||
settings,
|
settings,
|
||||||
logged_in,
|
logged_in,
|
||||||
search_terms: std::sync::RwLock::new(Vec::new()),
|
search_terms: std::sync::RwLock::new(Vec::new()),
|
||||||
|
|
@ -263,10 +286,11 @@ impl Client {
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ProviderClient for Client {
|
impl ProviderClient for Client {
|
||||||
/// Builds the rustypipe client (state under
|
/// Builds the rustypipe client (state under
|
||||||
/// `<config>/crabidy/rustypipe/`) and attempts the cookie login. A
|
/// `<config>/crabidy/rustypipe/`), attempts the cookie login, and
|
||||||
/// client that cannot be built fails init (the orchestrator disables
|
/// probes the `yt-dlp` stream-URL sidecar. A rustypipe client that
|
||||||
/// the provider non-fatally); a failed login degrades to logged-out
|
/// cannot be built fails init (the orchestrator disables the
|
||||||
/// with a warning, never an error.
|
/// provider non-fatally); a failed login or a missing sidecar
|
||||||
|
/// degrades with a warning, never an error.
|
||||||
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
async fn init(raw_toml_settings: &str) -> Result<Self, ProviderError> {
|
||||||
let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
|
let settings: Settings = toml::from_str(raw_toml_settings).unwrap_or_else(|_| {
|
||||||
warn!("could not parse toml settings, using defaults");
|
warn!("could not parse toml settings, using defaults");
|
||||||
|
|
@ -280,17 +304,36 @@ impl ProviderClient for Client {
|
||||||
let storage_dir = dirs::config_dir()
|
let storage_dir = dirs::config_dir()
|
||||||
.map(|dir| dir.join("crabidy").join("rustypipe"))
|
.map(|dir| dir.join("crabidy").join("rustypipe"))
|
||||||
.ok_or_else(|| ProviderError::Config("no config directory".to_string()))?;
|
.ok_or_else(|| ProviderError::Config("no config directory".to_string()))?;
|
||||||
let extractor = RustyPipeExtractor::new(storage_dir, timeout).map_err(|err| {
|
let extractor =
|
||||||
warn!("cannot build the youtube client: {err}");
|
RustyPipeExtractor::new(storage_dir, timeout, settings.botguard_bin.as_deref())
|
||||||
ProviderError::Config(err.to_string())
|
.map_err(|err| {
|
||||||
})?;
|
warn!("cannot build the youtube client: {err}");
|
||||||
|
ProviderError::Config(err.to_string())
|
||||||
|
})?;
|
||||||
let logged_in = extractor.login(settings.cookies.as_deref()).await;
|
let logged_in = extractor.login(settings.cookies.as_deref()).await;
|
||||||
|
let binary = settings
|
||||||
|
.binary
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("yt-dlp"));
|
||||||
|
let ytdlp = YtDlp::new(binary, timeout.max(Duration::from_secs(60)));
|
||||||
|
let ytdlp = match ytdlp.probe().await {
|
||||||
|
Ok(version) => {
|
||||||
|
debug!(version, "yt-dlp stream sidecar ready");
|
||||||
|
Some(ytdlp)
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
"yt-dlp not usable ({err}); youtube streams will stop after \
|
||||||
|
their first ~1 MiB (see architecture/youtube-rustypipe.md)"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
debug!(logged_in, "youtube extractor ready");
|
debug!(logged_in, "youtube extractor ready");
|
||||||
Ok(Self::with_extractor(
|
Ok(Self {
|
||||||
Box::new(extractor),
|
ytdlp,
|
||||||
settings,
|
..Self::with_extractor(Box::new(extractor), settings, logged_in)
|
||||||
logged_in,
|
})
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn settings(&self) -> String {
|
fn settings(&self) -> String {
|
||||||
|
|
@ -307,11 +350,32 @@ impl ProviderClient for Client {
|
||||||
/// The video's audio stream URL — `audio/mp4` (AAC) preferred, the
|
/// The video's audio stream URL — `audio/mp4` (AAC) preferred, the
|
||||||
/// format the local player decodes
|
/// format the local player decodes
|
||||||
/// (architecture/youtube-rustypipe.md D2).
|
/// (architecture/youtube-rustypipe.md D2).
|
||||||
|
///
|
||||||
|
/// Resolved through the `yt-dlp` sidecar when available (its cipher
|
||||||
|
/// solving yields fully streamable URLs); rustypipe is the fallback,
|
||||||
|
/// whose tokenless URLs YouTube currently caps at ~1 MiB.
|
||||||
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
async fn get_urls_for_track(&self, track_path: &str) -> Result<Vec<String>, ProviderError> {
|
||||||
let video = match parse_path(track_path)? {
|
let video = match parse_path(track_path)? {
|
||||||
YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video,
|
YtPath::SearchTrack { video, .. } | YtPath::PlaylistTrack { video, .. } => video,
|
||||||
_ => return Err(ProviderError::MalformedPath),
|
_ => return Err(ProviderError::MalformedPath),
|
||||||
};
|
};
|
||||||
|
if let Some(ytdlp) = &self.ytdlp {
|
||||||
|
let watch_url = format!("https://www.youtube.com/watch?v={video}");
|
||||||
|
match ytdlp.stream_url(&watch_url).await {
|
||||||
|
Ok(url) => return Ok(vec![url]),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(
|
||||||
|
video,
|
||||||
|
"yt-dlp stream resolution failed, trying rustypipe: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!(
|
||||||
|
video,
|
||||||
|
"no yt-dlp sidecar; this stream will stop after its first ~1 MiB"
|
||||||
|
);
|
||||||
|
}
|
||||||
let url = self
|
let url = self
|
||||||
.extractor
|
.extractor
|
||||||
.audio_stream_url(video)
|
.audio_stream_url(video)
|
||||||
|
|
@ -769,11 +833,56 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_tolerate_the_retired_binary_key() {
|
fn settings_round_trip_old_and_new_keys() {
|
||||||
// Old ytdy.toml files carry `binary = "yt-dlp"`; parsing must
|
// Pre-rustypipe ytdy.toml files carry `binary`; it kept its
|
||||||
// ignore it instead of resetting the whole config.
|
// meaning (now stream-URL-only). New keys parse alongside.
|
||||||
let settings: Settings =
|
let settings: Settings =
|
||||||
toml::from_str("binary = \"yt-dlp\"\nsearch_results = 7\n").expect("parses");
|
toml::from_str("binary = \"yt-dlp\"\nsearch_results = 7\nbotguard_bin = \"/opt/bg\"\n")
|
||||||
|
.expect("parses");
|
||||||
assert_eq!(settings.search_results, Some(7));
|
assert_eq!(settings.search_results, Some(7));
|
||||||
|
assert_eq!(settings.binary, Some(PathBuf::from("yt-dlp")));
|
||||||
|
assert_eq!(settings.botguard_bin, Some(PathBuf::from("/opt/bg")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stream_urls_prefer_the_ytdlp_sidecar() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||||
|
let script = dir.path().join("fake-yt-dlp");
|
||||||
|
std::fs::write(
|
||||||
|
&script,
|
||||||
|
"#!/bin/sh\ncase \"$*\" in\n*watch?v=vid1*) echo \"https://sidecar.test/a.m4a\"; exit 0 ;;\n*) exit 1 ;;\nesac\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
let mut perms = std::fs::metadata(&script).expect("meta").permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
std::fs::set_permissions(&script, perms).expect("chmod");
|
||||||
|
|
||||||
|
let mut client = client();
|
||||||
|
client.ytdlp = Some(YtDlp::new(script, Duration::from_secs(30)));
|
||||||
|
// The sidecar wins over the fake extractor's URL. (Retried: under
|
||||||
|
// parallel test load, exec of a just-written script can race a
|
||||||
|
// concurrent fork and fall back spuriously.)
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
for _ in 0..5 {
|
||||||
|
urls = client
|
||||||
|
.get_urls_for_track("/youtube/search/lofi/vid1")
|
||||||
|
.await
|
||||||
|
.expect("stream url");
|
||||||
|
if urls == vec!["https://sidecar.test/a.m4a".to_string()] {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(urls, vec!["https://sidecar.test/a.m4a".to_string()]);
|
||||||
|
// ...and a failing sidecar falls back to the extractor's answer.
|
||||||
|
client.ytdlp = Some(YtDlp::new(
|
||||||
|
dir.path().join("missing-binary"),
|
||||||
|
Duration::from_secs(5),
|
||||||
|
));
|
||||||
|
let urls = client
|
||||||
|
.get_urls_for_track("/youtube/search/lofi/vid1")
|
||||||
|
.await
|
||||||
|
.expect("fallback");
|
||||||
|
assert_eq!(urls, vec!["https://example.test/a.m4a".to_string()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
//! Minimal `yt-dlp` sidecar for **stream URLs only**
|
||||||
|
//! (see `architecture/youtube-rustypipe.md` D2-revised).
|
||||||
|
//!
|
||||||
|
//! All metadata comes from the pure-Rust rustypipe extractor; this
|
||||||
|
//! module exists because YouTube currently rejects sustained tokenless
|
||||||
|
//! stream fetching (every URL serves exactly its leading ~1 MiB), and
|
||||||
|
//! the token-capable Innertube clients need signature deciphering that
|
||||||
|
//! is broken in rustypipe upstream right now. `yt-dlp` solves the
|
||||||
|
//! cipher challenges and its URLs stream fully (throttled, but well
|
||||||
|
//! above audio bitrate). When upstream recovers, this sidecar can be
|
||||||
|
//! dropped without touching the provider: it is only consulted by
|
||||||
|
//! `get_urls_for_track`.
|
||||||
|
//!
|
||||||
|
//! Subprocess discipline: argv-only (never a shell), one bounded call
|
||||||
|
//! (`kill_on_drop`), stdout capped, stderr summarized to a short
|
||||||
|
//! suffix. Errors carry the video id and exit status — never stream
|
||||||
|
//! URLs (they may embed tokens).
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Cap on captured stdout; `-g` prints a handful of URLs.
|
||||||
|
const MAX_STDOUT_BYTES: usize = 512 * 1024;
|
||||||
|
|
||||||
|
/// Errors from the sidecar.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum YtDlpError {
|
||||||
|
#[error("cannot run yt-dlp: {0}")]
|
||||||
|
Spawn(String),
|
||||||
|
#[error("yt-dlp timed out after {0:?}")]
|
||||||
|
Timeout(Duration),
|
||||||
|
#[error("yt-dlp failed ({status}): {stderr}")]
|
||||||
|
Failed { status: String, stderr: String },
|
||||||
|
#[error("yt-dlp printed no stream url")]
|
||||||
|
NoUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A configured `yt-dlp` binary used for stream URL resolution.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct YtDlp {
|
||||||
|
binary: PathBuf,
|
||||||
|
timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YtDlp {
|
||||||
|
pub fn new(binary: PathBuf, timeout: Duration) -> Self {
|
||||||
|
Self { binary, timeout }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes `--version`; used at init to decide whether the sidecar
|
||||||
|
/// is available (absence degrades to rustypipe URLs, never fails
|
||||||
|
/// the provider).
|
||||||
|
pub async fn probe(&self) -> Result<String, YtDlpError> {
|
||||||
|
let stdout = self.run(&["--version"]).await?;
|
||||||
|
Ok(stdout.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audio stream URL for a watch URL: `audio/mp4` (AAC)
|
||||||
|
/// preferred — the format the local player decodes.
|
||||||
|
pub async fn stream_url(&self, watch_url: &str) -> Result<String, YtDlpError> {
|
||||||
|
let stdout = self
|
||||||
|
.run(&[
|
||||||
|
"-f",
|
||||||
|
"bestaudio[ext=m4a]/bestaudio/best",
|
||||||
|
"-g",
|
||||||
|
"--no-playlist",
|
||||||
|
watch_url,
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
stdout
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| !line.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or(YtDlpError::NoUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one bounded call and returns its stdout.
|
||||||
|
async fn run(&self, args: &[&str]) -> Result<String, YtDlpError> {
|
||||||
|
debug!(binary = %self.binary.display(), ?args, "running yt-dlp");
|
||||||
|
let mut command = tokio::process::Command::new(&self.binary);
|
||||||
|
command
|
||||||
|
.args(["--no-warnings"])
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
let child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| YtDlpError::Spawn(err.to_string()))?;
|
||||||
|
let output = tokio::time::timeout(self.timeout, child.wait_with_output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| YtDlpError::Timeout(self.timeout))?
|
||||||
|
.map_err(|err| YtDlpError::Spawn(err.to_string()))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let summary: String = stderr.trim().chars().take(200).collect();
|
||||||
|
return Err(YtDlpError::Failed {
|
||||||
|
status: output.status.to_string(),
|
||||||
|
stderr: summary,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut stdout = output.stdout;
|
||||||
|
stdout.truncate(MAX_STDOUT_BYTES);
|
||||||
|
Ok(String::from_utf8_lossy(&stdout).into_owned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// A fake yt-dlp: a script dispatching on its argv.
|
||||||
|
fn fake_binary(dir: &Path, body: &str) -> PathBuf {
|
||||||
|
let path = dir.join("fake-yt-dlp");
|
||||||
|
fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write fake binary");
|
||||||
|
let mut perms = fs::metadata(&path).expect("metadata").permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(&path, perms).expect("chmod");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retries a call while exec races a concurrent fork (ETXTBSY under
|
||||||
|
/// parallel test load).
|
||||||
|
async fn retry_spawn<T, F, Fut>(mut call: F) -> Result<T, YtDlpError>
|
||||||
|
where
|
||||||
|
F: FnMut() -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<T, YtDlpError>>,
|
||||||
|
{
|
||||||
|
let mut last = call().await;
|
||||||
|
for _ in 0..4 {
|
||||||
|
if !matches!(last, Err(YtDlpError::Spawn(_))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
last = call().await;
|
||||||
|
}
|
||||||
|
last
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolves_stream_urls_and_probes() {
|
||||||
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||||
|
let binary = fake_binary(
|
||||||
|
dir.path(),
|
||||||
|
r#"
|
||||||
|
case "$*" in
|
||||||
|
*--version*) echo "2026.07.04"; exit 0 ;;
|
||||||
|
*watch?v=vid1*) printf '%s\n' "https://example.test/a.m4a"; exit 0 ;;
|
||||||
|
*) echo boom >&2; exit 1 ;;
|
||||||
|
esac"#,
|
||||||
|
);
|
||||||
|
let ytdlp = YtDlp::new(binary, Duration::from_secs(30));
|
||||||
|
assert_eq!(
|
||||||
|
retry_spawn(|| ytdlp.probe()).await.expect("probe"),
|
||||||
|
"2026.07.04"
|
||||||
|
);
|
||||||
|
let url = retry_spawn(|| ytdlp.stream_url("https://www.youtube.com/watch?v=vid1"))
|
||||||
|
.await
|
||||||
|
.expect("url");
|
||||||
|
assert_eq!(url, "https://example.test/a.m4a");
|
||||||
|
// Failures are typed and carry a bounded stderr summary.
|
||||||
|
let err = ytdlp
|
||||||
|
.stream_url("https://www.youtube.com/watch?v=nope")
|
||||||
|
.await
|
||||||
|
.expect_err("fails");
|
||||||
|
assert!(err.to_string().contains("boom"), "{err}");
|
||||||
|
// A missing binary is a typed spawn error.
|
||||||
|
let gone = YtDlp::new(dir.path().join("gone"), Duration::from_secs(5));
|
||||||
|
assert!(matches!(gone.probe().await, Err(YtDlpError::Spawn(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hung_calls_hit_the_timeout() {
|
||||||
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||||
|
let binary = fake_binary(dir.path(), "sleep 30");
|
||||||
|
let ytdlp = YtDlp::new(binary, Duration::from_millis(200));
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let mut saw_timeout = false;
|
||||||
|
for _ in 0..5 {
|
||||||
|
match ytdlp.probe().await {
|
||||||
|
Err(YtDlpError::Timeout(_)) => {
|
||||||
|
saw_timeout = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Under parallel test load, exec of a just-written
|
||||||
|
// script can race a concurrent fork (ETXTBSY) — retry.
|
||||||
|
Err(YtDlpError::Spawn(_)) => continue,
|
||||||
|
other => panic!("expected a timeout, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(saw_timeout, "no timeout within 5 attempts");
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue