A play's trace holds the playlist, audio and saved place it fetched (#192)
hls.js sends traceparent on its own requests (xhrSetup); a beacon, which cannot set a header, and Safari's player, fetching the playlist itself, carry it in the query, which the server reads where there is no header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace (#190).
|
- The page reports its errors, the player's, how long play takes to start and how long it stalls to the server's log, and the requests one tap sets off share a trace, the playlist, audio and saved place of what it played included (#190, #192).
|
||||||
- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186).
|
- Saving an episode from a show with DeadAir on saves it without its dead air, at your breathing room (#186).
|
||||||
- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183).
|
- Choose the pause DeadAir leaves where it cuts a silence, from 0.2 to 0.5 seconds, for each show in its settings, as its breathing room, on a slider from brisk to relaxed. It is 0.3 seconds unless you change it, up from 0.2, which made speech sound rushed (#182, #183).
|
||||||
|
|
||||||
@@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177).
|
- The container is built on Debian 13 (trixie) instead of 12, so DeadAir uses ffmpeg 7.1 instead of 5.1 (#177).
|
||||||
- DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space (#181).
|
- DeadAir no longer makes a shortened copy of each episode. It plays the original with its long silences left out, ready a few seconds after the download, with no gap where a silence was cut and no extra disk space (#181).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- All Subscriptions, and every item list, comes up faster, most of all the first time after a quiet spell: it took half a second to two (#188).
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Cloudflare no longer keeps a copy of the audio you play, which it served to anyone it let in without checking they subscribe (#189).
|
- Cloudflare no longer keeps a copy of the audio you play, which it served to anyone it let in without checking they subscribe (#189).
|
||||||
|
|||||||
21
src/web.rs
21
src/web.rs
@@ -1329,7 +1329,7 @@ mod tests {
|
|||||||
let with = |v: &str| {
|
let with = |v: &str| {
|
||||||
let mut h = HeaderMap::new();
|
let mut h = HeaderMap::new();
|
||||||
h.insert("traceparent", v.parse().unwrap());
|
h.insert("traceparent", v.parse().unwrap());
|
||||||
remote_parent(&h)
|
remote_parent(&h, None)
|
||||||
};
|
};
|
||||||
let cx = with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").expect("parsed");
|
let cx = with("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01").expect("parsed");
|
||||||
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
assert_eq!(cx.span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
@@ -1339,7 +1339,11 @@ mod tests {
|
|||||||
"00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736"] {
|
"00-00000000000000000000000000000000-00f067aa0ba902b7-01", "00-4bf92f3577b34da6a3ce929d0e0e4736"] {
|
||||||
assert!(with(bad).is_none(), "{bad}");
|
assert!(with(bad).is_none(), "{bad}");
|
||||||
}
|
}
|
||||||
assert!(remote_parent(&HeaderMap::new()).is_none());
|
assert!(remote_parent(&HeaderMap::new(), None).is_none());
|
||||||
|
// From the query, where a beacon or Safari's player cannot send a header (#192).
|
||||||
|
let q = remote_parent(&HeaderMap::new(), Some("x=1&traceparent=00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"));
|
||||||
|
assert_eq!(q.unwrap().span().span_context().trace_id().to_string(), "4bf92f3577b34da6a3ce929d0e0e4736");
|
||||||
|
assert!(remote_parent(&HeaderMap::new(), Some("traceparent=nonsense")).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2456,10 +2460,15 @@ async fn client_event(user: crate::db::User, Json(e): Json<ClientEvent>) -> Stat
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The trace a request from the page belongs to, from its `traceparent` (#190), so the requests
|
/// The trace a request from the page belongs to, from its `traceparent` (#190), so the requests
|
||||||
/// one action sets off are one trace. None for a missing or malformed one.
|
/// one action sets off are one trace. None for a missing or malformed one. In the query where no
|
||||||
fn remote_parent(headers: &HeaderMap) -> Option<opentelemetry::Context> {
|
/// header can be set: a position saved by sendBeacon, and the playlist Safari's player fetches
|
||||||
|
/// itself (#192).
|
||||||
|
fn remote_parent(headers: &HeaderMap, query: Option<&str>) -> Option<opentelemetry::Context> {
|
||||||
use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
|
use opentelemetry::trace::{SpanContext, SpanId, TraceContextExt, TraceFlags, TraceId, TraceState};
|
||||||
let v = headers.get("traceparent")?.to_str().ok()?;
|
let v = match headers.get("traceparent") {
|
||||||
|
Some(h) => h.to_str().ok()?,
|
||||||
|
None => query?.split('&').find_map(|kv| kv.strip_prefix("traceparent="))?,
|
||||||
|
};
|
||||||
let mut parts = v.split('-');
|
let mut parts = v.split('-');
|
||||||
let (version, trace, span) = (parts.next()?, parts.next()?, parts.next()?);
|
let (version, trace, span) = (parts.next()?, parts.next()?, parts.next()?);
|
||||||
if version != "00" || trace.len() != 32 || span.len() != 16 {
|
if version != "00" || trace.len() != 32 || span.len() != 16 {
|
||||||
@@ -2514,7 +2523,7 @@ async fn access_log(req: Request, next: Next) -> Response {
|
|||||||
http.response.status_code = tracing::field::Empty,
|
http.response.status_code = tracing::field::Empty,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if let Some(parent) = remote_parent(req.headers()) {
|
if let Some(parent) = remote_parent(req.headers(), req.uri().query()) {
|
||||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
let _ = span.set_parent(parent);
|
let _ = span.set_parent(parent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1806,12 +1806,16 @@ test('DeadAir is off until turned on for a show, then plays it shortened, with y
|
|||||||
// speed pressed. The element's own src is then hls.js's blob: address.
|
// speed pressed. The element's own src is then hls.js's blob: address.
|
||||||
// How long it took to start goes to the server's log, under the trace of the tap (#190).
|
// How long it took to start goes to the server's log, under the trace of the tap (#190).
|
||||||
const started = page.waitForRequest(r => r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'play_start');
|
const started = page.waitForRequest(r => r.url().endsWith('/api/client') && r.postDataJSON()?.ev === 'play_start');
|
||||||
|
const piece = page.waitForRequest(r => r.url().endsWith('/deadair.mp3'));
|
||||||
await row.locator('[data-a=play]').click();
|
await row.locator('[data-a=play]').click();
|
||||||
await expect.poll(() => page.evaluate(() => player.src)).toMatch(/\/deadair\.m3u8$/);
|
await expect.poll(() => page.evaluate(() => player.src)).toMatch(/\/deadair\.m3u8$/);
|
||||||
const report = await started;
|
const report = await started;
|
||||||
expect(report.postDataJSON()).toMatchObject({ deadair: true });
|
expect(report.postDataJSON()).toMatchObject({ deadair: true });
|
||||||
expect(report.postDataJSON().ms).toBeGreaterThan(0);
|
expect(report.postDataJSON().ms).toBeGreaterThan(0);
|
||||||
expect(report.headers().traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
expect(report.headers().traceparent).toMatch(/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
|
||||||
|
// hls.js's own requests carry the same trace as the tap that played them (#192).
|
||||||
|
const traceOf = h => h.traceparent?.split('-')[1];
|
||||||
|
expect(traceOf((await piece).headers())).toBe(traceOf(report.headers()));
|
||||||
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').currentTime), { timeout: 15_000 }).toBeGreaterThan(0.5);
|
await expect.poll(() => page.evaluate(() => document.querySelector('#audio').currentTime), { timeout: 15_000 }).toBeGreaterThan(0.5);
|
||||||
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
|
await expect(page.locator('#pdead')).toHaveAttribute('aria-pressed', 'true');
|
||||||
// Off from the player: the original, from the same place.
|
// Off from the player: the original, from the same place.
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ function dropHls(){ if(hls){ hls.destroy(); hls=null; } }
|
|||||||
function setSrc(url: string): Promise<void>|null{
|
function setSrc(url: string): Promise<void>|null{
|
||||||
dropHls();
|
dropHls();
|
||||||
const native=navigator.vendor.startsWith('Apple')&&!!audio.canPlayType('application/vnd.apple.mpegurl');
|
const native=navigator.vendor.startsWith('Apple')&&!!audio.canPlayType('application/vnd.apple.mpegurl');
|
||||||
if(!url.endsWith('.m3u8')||native){ audio.src=url; return null; }
|
// Safari fetches the playlist itself, with no header to carry the tap's trace: in the address
|
||||||
|
// instead (#192). Only the playlist, which is never cached.
|
||||||
|
if(!url.endsWith('.m3u8')){ audio.src=url; return null; }
|
||||||
|
if(native){ audio.src=`${url}?traceparent=${traceparent()}`; return null; }
|
||||||
const w=window as any;
|
const w=window as any;
|
||||||
const ready=w.Hls?Promise.resolve():new Promise<void>((ok,fail)=>{
|
const ready=w.Hls?Promise.resolve():new Promise<void>((ok,fail)=>{
|
||||||
const s=document.createElement('script');
|
const s=document.createElement('script');
|
||||||
@@ -26,7 +29,8 @@ function setSrc(url: string): Promise<void>|null{
|
|||||||
document.head.append(s);
|
document.head.append(s);
|
||||||
});
|
});
|
||||||
return ready.then(()=>{
|
return ready.then(()=>{
|
||||||
hls=new w.Hls();
|
// Its playlist and audio requests join the trace of the tap that played them (#192).
|
||||||
|
hls=new w.Hls({xhrSetup:(xhr: XMLHttpRequest)=>xhr.setRequestHeader('traceparent',traceparent())});
|
||||||
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
hls.on(w.Hls.Events.ERROR,(_,d)=>{ if(d.fatal){ toast('Playback failed: '+d.details,true);
|
||||||
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
|
report('player_error',{msg:`hls.js: ${d.type} ${d.details}`,feed:player.feed,enclosure:player.enc}); } });
|
||||||
hls.loadSource(url); hls.attachMedia(audio);
|
hls.loadSource(url); hls.attachMedia(audio);
|
||||||
@@ -112,7 +116,8 @@ function savePos(){
|
|||||||
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
|
// cannot tell a finished episode from a started one. NaN before metadata, Infinity on a stream.
|
||||||
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
const duration=isFinite(audio.duration)?Math.floor(audio.duration):null;
|
||||||
navigator.sendBeacon?.(
|
navigator.sendBeacon?.(
|
||||||
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position`,
|
// A beacon sends no headers, so the trace goes in the address (#192).
|
||||||
|
`/api/entries/${encodeURIComponent(player.feed)}/${encodeURIComponent(player.guid)}/position?traceparent=${traceparent()}`,
|
||||||
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
new Blob([JSON.stringify({secs:Math.floor(audio.currentTime),duration})],{type:'application/json'}));
|
||||||
}
|
}
|
||||||
audio.addEventListener('pause',savePos);
|
audio.addEventListener('pause',savePos);
|
||||||
|
|||||||
Reference in New Issue
Block a user