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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Security audit

on:
schedule:
- cron: "0 0 * * *"
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [core] Fix default permissions on credentials file and warn user if file is world readable
- [core] Try all resolved addresses for the dealer connection instead of failing after the first one.
- [audio] Try the next CDN URL when a fetch returns a non-206 status instead of only retrying on transport errors, fixing playback failures when the first CDN URL is reachable but does not stream audio.
- [connect] Keep the Spirc running after a transient connection-id update failure instead of shutting it down, so the device no longer silently disappears from Spotify Connect until a manual restart.
- [discovery] Return an HTTP error response instead of panicking on malformed discovery login requests.

## [0.8.0] - 2025-11-10

Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions TICKER_RELIABILITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Ticker reliability fork

This fork is the Spotify transport used by the Ticker bar jukebox. It stays
close to `librespot-org/librespot:dev`; Ticker's Node audio supervisor remains
the product-level authority for retries, verified PCM, make-before-break
handoffs, local safety copies, and rollback.

## Patch policy

- Carry only failures reproduced by Ticker's automated jukebox soak or small,
independently reviewable upstream recovery fixes.
- Preserve the upstream author and commit when importing an existing pull
request.
- Keep speculative recovery proposals on separate candidate branches.
- Never promote a binary because it merely emits `playing`; fresh PCM and room
continuity are the acceptance signals.

Current carried changes:

- Upstream PR #1716: keep SPIRC alive after a transient connection-ID update
failure instead of leaving a healthy-looking but undiscoverable process.
- Tagged transfer/context/connect-state diagnostics for correlating Spotify
control-plane failures with Ticker's PCM timeline.

## Promotion gate

Build the Linux release binary, record its Git SHA, and install it beside—not
over—the production binary. Then run from the Ticker checkout:

```bash
node scripts/jukebox-soak.js --profile=live --allow-live-audio --low-volume=.18 --rapid --no-wall
```

Promotion requires repeated cold Spotify fixtures, zero backend timeouts, no
audio-engine gap over two seconds, correct last-request-wins behavior, and no
regression in first-track time to verified PCM. Restore the previous binary
immediately if any gate fails.

## Upstream sync

```bash
git fetch upstream
git rebase upstream/dev
```

Resolve and test locally, push the fork branch, and let GitHub Actions finish
before a candidate reaches the MasterServer.
46 changes: 41 additions & 5 deletions connect/src/spirc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,6 @@ impl SpircTask {
connection_id_update,
match |connection_id| if let Err(why) = self.handle_connection_id_update(connection_id).await {
error!("failed handling connection id update: {why}");
break;
}
},
// main dealer update of any remote device updates
Expand Down Expand Up @@ -1165,6 +1164,29 @@ impl SpircTask {
}

fn handle_transfer(&mut self, mut transfer: TransferState) -> Result<(), Error> {
let incoming_context = transfer
.current_session
.context
.uri
.as_deref()
.unwrap_or("");
let incoming_pages = transfer.current_session.context.pages.len();
let incoming_tracks = transfer
.current_session
.context
.pages
.iter()
.map(|page| page.tracks.len())
.sum::<usize>();
info!(
"[ticker-reliability] transfer context=<{}> pages={} tracks={} paused={} position_ms={:?}",
incoming_context,
incoming_pages,
incoming_tracks,
transfer.playback.is_paused(),
transfer.playback.position_as_of_timestamp
);

let mut ctx_uri = match transfer.current_session.context.uri {
None => Err(SpircError::NoUri("transfer context"))?,
// can apparently happen when a state is transferred and was started with "uris" via the api
Expand Down Expand Up @@ -1903,10 +1925,24 @@ impl SpircTask {

self.connect_state.set_now(self.now_ms() as u64);

self.connect_state
.send_state(&self.session)
.await
.map(|_| ())
let context_uri = self.connect_state.context_uri().clone();
let context_ready = self.connect_state.get_context(ContextType::Default).is_ok();
let track_uri = self
.connect_state
.player()
.track
.as_ref()
.map(|track| track.uri.as_str())
.unwrap_or("")
.to_string();
let result = self.connect_state.send_state(&self.session).await;
if let Err(ref why) = result {
error!(
"[ticker-reliability] connect-state notify failed: {why}; status={:?} context_ready={} context=<{}> track=<{}>",
self.play_status, context_ready, context_uri, track_uri
);
}
result.map(|_| ())
}

fn set_volume(&mut self, volume: u16) {
Expand Down
15 changes: 14 additions & 1 deletion connect/src/state/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,20 @@ impl ConnectState {

let ctx = match self.get_context(new_context) {
Err(why) => {
warn!("couldn't load context info because: {why}");
// reset_context() intentionally selects Default before a Web
// API `uris` transfer has rebuilt its synthetic context. That
// short-lived state is expected and used to produce a noisy
// warning immediately before otherwise healthy playback.
// Keep a tagged debug breadcrumb for Ticker correlation while
// reserving WARN for a missing context with a real URI.
if self.context_uri().is_empty() {
debug!("[ticker-reliability] context not ready during activation: {why}");
} else {
warn!(
"[ticker-reliability] couldn't activate context uri=<{}>: {why}",
self.context_uri()
);
}
return;
}
Ok(ctx) => ctx,
Expand Down
42 changes: 30 additions & 12 deletions discovery/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use aes::cipher::{KeyIvInit, StreamCipher};
use base64::engine::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use bytes::Bytes;
use futures_util::{FutureExt, TryFutureExt};
use hmac::{Hmac, Mac};
use http_body_util::{BodyExt, Full};
use hyper::{Method, Request, Response, StatusCode, body::Incoming};
Expand All @@ -24,7 +23,7 @@ use super::{DiscoveryError, DiscoveryEvent};

use crate::{
core::config::DeviceType,
core::{Error, authentication::Credentials, diffie_hellman::DhLocalKeys},
core::{Error, authentication::Credentials, diffie_hellman::DhLocalKeys, error::ErrorKind},
};

type Aes128Ctr = ctr::Ctr128BE<aes::Aes128>;
Expand Down Expand Up @@ -234,10 +233,28 @@ impl RequestHandler {
res
}

fn error_response(&self, err: &Error) -> Response<Full<Bytes>> {
let status = match err.kind {
ErrorKind::InvalidArgument | ErrorKind::FailedPrecondition => StatusCode::BAD_REQUEST,
_ => StatusCode::SERVICE_UNAVAILABLE,
};

let body = json!({
"status": 102,
"spotifyError": 0,
"statusString": status.canonical_reason().unwrap_or("ERROR"),
})
.to_string();

let mut res = Response::new(Full::new(Bytes::from(body)));
*res.status_mut() = status;
res
}

async fn handle(
self: Arc<Self>,
request: Request<Incoming>,
) -> Result<hyper::Result<Response<Full<Bytes>>>, Error> {
) -> hyper::Result<Response<Full<Bytes>>> {
let mut params = Params::new();

let (parts, body) = request.into_parts();
Expand All @@ -257,11 +274,17 @@ impl RequestHandler {

let action = params.get("action").map(Cow::as_ref);

Ok(Ok(match (parts.method, action) {
Ok(match (parts.method, action) {
(Method::GET, Some("getInfo")) => self.handle_get_info(),
(Method::POST, Some("addUser")) => self.handle_add_user(&params)?,
(Method::POST, Some("addUser")) => match self.handle_add_user(&params) {
Ok(response) => response,
Err(err) => {
error!("could not handle discovery request: {err}");
self.error_response(&err)
}
},
_ => self.not_found(),
}))
})
}
}

Expand Down Expand Up @@ -325,12 +348,7 @@ impl DiscoveryServer {
let discovery = discovery.clone();

let svc = hyper::service::service_fn(move |request| {
discovery
.clone()
.handle(request)
.inspect_err(|e| error!("could not handle discovery request: {e}"))
.and_then(|x| async move { Ok(x) })
.map(Result::unwrap) // guaranteed by `and_then` above
discovery.clone().handle(request)
});

let conn = server.serve_connection(io, svc);
Expand Down
Loading