5 Commits

Author SHA1 Message Date
Ultradesu 5b624443d5 Fixed token refresh. 2026-06-12 15:05:36 +01:00
ab ec777f956a Fixed ci 2026-06-11 00:56:34 +01:00
ab 654d8ad750 Fixed ci 2026-06-11 00:42:02 +01:00
ab d13b0c8085 Fixed macos runner image 2026-06-11 00:35:17 +01:00
ab e30ad8be36 Fixed readme
Build and Release / Build furumi-linux-x86_64 (push) Failing after 14s
Build and Release / Build furumi-macos-x86_64 (push) Has been cancelled
Build and Release / Build furumi-windows-x86_64 (push) Has been cancelled
Build and Release / Publish release assets (push) Has been cancelled
2026-06-11 00:28:08 +01:00
8 changed files with 537 additions and 60 deletions
+99
View File
@@ -0,0 +1,99 @@
name: Build and Release
on:
push:
tags:
- "v*"
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: Build ${{ matrix.asset_name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
asset_name: furumi-linux-x86_64
archive_ext: tar.gz
binary_name: furumi
- os: macos-latest
asset_name: furumi-macos-x86_64
archive_ext: tar.gz
binary_name: furumi
- os: windows-latest
asset_name: furumi-windows-x86_64
archive_ext: zip
binary_name: furumi.exe
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev pkg-config
- name: Show Rust version
run: rustc --version && cargo --version
- name: Build
run: cargo build --release --locked
- name: Package
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
archive_name="${{ matrix.asset_name }}-${version}.${{ matrix.archive_ext }}"
package_dir="dist/${{ matrix.asset_name }}"
mkdir -p "$package_dir"
cp "target/release/${{ matrix.binary_name }}" "$package_dir/"
cp README.md "$package_dir/"
if [[ "${{ runner.os }}" == "Windows" ]]; then
(cd dist && 7z a "../${archive_name}" "${{ matrix.asset_name }}")
else
tar -C dist -czf "${archive_name}" "${{ matrix.asset_name }}"
fi
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}-*
if-no-files-found: error
publish:
name: Publish release assets
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Create release and upload assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
gh release create "$RELEASE_TAG" artifacts/*/* \
--title "furumi ${RELEASE_TAG}" \
--notes "Release ${RELEASE_TAG}" \
--verify-tag
Generated
+1 -1
View File
@@ -1180,7 +1180,7 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "furumi_tui" name = "furumi_tui"
version = "0.1.1" version = "0.1.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard", "arboard",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumi_tui" name = "furumi_tui"
version = "0.1.1" version = "0.1.2"
edition = "2024" edition = "2024"
[[bin]] [[bin]]
+162 -20
View File
@@ -1,44 +1,186 @@
# furumi # furumi
Terminal client (TUI) for the furumusic server. Cross-platform: Linux, ![furumi TUI screenshot](furumi.png)
macOS, Windows.
## Building `furumi` is a cross-platform terminal client for a furumusic server. It
provides a fast TUI for browsing the library, playing music, managing the
queue and playlists, controlling devices, and inspecting logs without leaving
the terminal.
Rust 1.88+ (edition 2024). ## Features
- Browse the full artist library in tile or table view.
- Open artist pages, releases, and track lists from inside the TUI.
- Search artists, releases, and tracks with `/`.
- Play local audio with seek, volume, shuffle, repeat, and like controls.
- Add tracks next in queue, append them to the queue, or clear the queue.
- Browse playlists, liked tracks, and add tracks to playlists.
- Pick the active playback device and control remote devices.
- Use OS media keys through MPRIS/system media controls.
- Inspect live in-app logs and a persistent log file.
- Customize key bindings with a TOML keymap.
## Installation
Requires Rust 1.88+.
```bash ```bash
cargo build --release # binary: target/release/furumi cargo build --release
./target/release/furumi
```
The release binary is named `furumi`:
```bash
cargo run --release --bin furumi
``` ```
### Linux ### Linux
Sound output needs the system ALSA library — the one and only system Audio output needs the system ALSA library. PipeWire and PulseAudio are used
build dependency (PipeWire/PulseAudio are reached through the ALSA through the ALSA compatibility layer at runtime.
compatibility layer at runtime):
```bash ```bash
# Debian/Ubuntu # Debian / Ubuntu
sudo apt install libasound2-dev pkg-config sudo apt install libasound2-dev pkg-config
# Fedora # Fedora
sudo dnf install alsa-lib-devel pkgconf-pkg-config sudo dnf install alsa-lib-devel pkgconf-pkg-config
# Arch # Arch
sudo pacman -S alsa-lib pkgconf sudo pacman -S alsa-lib pkgconf
``` ```
Everything else is pure Rust: TLS is rustls, MPRIS media keys go through Everything else is handled by Rust dependencies: TLS uses `rustls`, MPRIS uses
zbus (no libdbus), images and audio decoding are Rust crates. `zbus`, and image/audio decoding is provided by Rust crates.
### macOS / Windows ### macOS and Windows
No system packages required. No extra system packages are required.
## First Run
On startup, `furumi` opens the login screen:
1. Enter your furumusic server URL.
2. Sign in with username/password or SSO.
3. After a successful login, the session is saved locally.
The SSO flow opens your browser automatically. If the loopback callback is not
available, `furumi` shows the URL and accepts either a pasted `furumi://...`
callback link or the short `furu_mx_...` code.
## Controls
Common key bindings:
| Key | Action |
| --- | --- |
| `?` | Show key binding help |
| `q`, `Ctrl-C` | Quit |
| `Tab`, `Shift-Tab` | Next / previous tab |
| `1`...`4` | Jump to a tab |
| `j` / `k`, arrows | Move down / up |
| `h` / `l`, arrows | Move left / right |
| `Enter` | Open or select item |
| `Esc`, `Backspace` | Go back |
| `Space` | Play / pause |
| `n`, `p` | Next / previous track |
| `.`, `,` | Seek 10 seconds forward / backward |
| `+`, `-` | Volume up / down |
| `s` | Toggle shuffle |
| `r` | Cycle repeat mode |
| `x` | Like / unlike |
| `a` | Add track next |
| `Shift-A` | Add track to the end of the queue |
| `Shift-P` | Add track to a playlist |
| `Shift-D` | Open device picker |
| `v` | Toggle tile/table view |
| `/` | Search |
| `:` | Open command line |
Command line examples:
```text
:q
:logout
:volume 40
:seek +30
:seek -10
:seek 1:30
:shuffle
:repeat off
:repeat one
:repeat all
:clear
:next
:prev
:play
:pause
:devices
:logs debug
```
## Configuration ## Configuration
- `keymap.toml` in the config dir — keybinding overrides, see `furumi` stores configuration in the platform app config directory:
`src/config/default_keymap.toml` for the format and defaults.
Config dir: `~/.config/furumi` on Linux, - Linux: `~/.config/furumi`
`~/Library/Application Support/furumi` on macOS. - macOS: `~/Library/Application Support/furumi`
- `credentials.json` in the same dir — created on login (0600). - Windows: `%APPDATA%\furumi`
- Logs: in-app on the Logs tab (`5`), and in the cache dir
(`furumi-cli.log`), filtered by `RUST_LOG`. Important files:
- `credentials.json` - saved login session. On Unix it is written with `0600`
permissions.
- `device_id` - stable identifier for this TUI client during device sync.
- `keymap.toml` - user key binding overrides.
See [`src/config/default_keymap.toml`](src/config/default_keymap.toml) for the
default format. Example:
```toml
[[keymaps]]
key_sequence = "ctrl-n"
command = "NextTrack"
[[keymaps]]
key_sequence = "ctrl-f"
command = { SeekForward = { seconds = 30 } }
```
A user binding replaces the default binding with the same key sequence and
context.
## Logs
The Logs tab shows a live in-memory ring buffer inside the TUI. You can jump
to it and set the level filter with:
```text
:logs error
:logs warn
:logs info
:logs debug
:logs trace
```
The persistent log file is written to the platform cache directory as
`furumi-cli.log`. File logging is filtered by `RUST_LOG`:
```bash
RUST_LOG=furumi_tui=debug cargo run --release --bin furumi
```
## Architecture
At a glance:
- UI: `ratatui` + `crossterm`.
- Runtime: `tokio`.
- HTTP: `reqwest` + `rustls`.
- Audio: `rodio` + `stream-download`.
- Keymap config: `crokey` + TOML.
- State model: one `AppState`, events, and an update loop.
See [`ARCHITECTURE.md`](ARCHITECTURE.md) for more detail.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

+54 -6
View File
@@ -1,4 +1,5 @@
use std::fs; use std::fs;
use std::io::ErrorKind;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -44,6 +45,10 @@ impl AuthSession {
pub fn access_token_expired(&self) -> bool { pub fn access_token_expired(&self) -> bool {
now_epoch_seconds() + EXPIRY_SKEW_SECONDS >= self.expires_at_epoch_seconds now_epoch_seconds() + EXPIRY_SKEW_SECONDS >= self.expires_at_epoch_seconds
} }
pub fn seconds_until_access_expiry(&self) -> i64 {
self.expires_at_epoch_seconds - now_epoch_seconds()
}
} }
pub fn now_epoch_seconds() -> i64 { pub fn now_epoch_seconds() -> i64 {
@@ -58,10 +63,34 @@ pub fn session_path() -> Option<PathBuf> {
} }
pub fn load_session() -> Option<AuthSession> { pub fn load_session() -> Option<AuthSession> {
let path = session_path()?; let Some(path) = session_path() else {
let text = fs::read_to_string(&path).ok()?; tracing::warn!("cannot determine config directory; no stored auth session loaded");
match serde_json::from_str(&text) { return None;
Ok(session) => Some(session), };
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == ErrorKind::NotFound => {
tracing::debug!(path = %path.display(), "credentials file not found");
return None;
}
Err(err) => {
tracing::warn!(path = %path.display(), %err, "failed to read credentials file");
return None;
}
};
match serde_json::from_str::<AuthSession>(&text) {
Ok(session) => {
tracing::info!(
path = %path.display(),
user_id = session.user.id,
user = %session.user.name,
server = %session.server_base_url,
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
seconds_until_expiry = session.seconds_until_access_expiry(),
"loaded stored auth session"
);
Some(session)
}
Err(err) => { Err(err) => {
tracing::warn!(path = %path.display(), %err, "ignoring unreadable credentials file"); tracing::warn!(path = %path.display(), %err, "ignoring unreadable credentials file");
None None
@@ -75,12 +104,31 @@ pub fn save_session(session: &AuthSession) -> Result<()> {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
} }
let text = serde_json::to_string_pretty(session)?; let text = serde_json::to_string_pretty(session)?;
write_private(&path, &text).with_context(|| format!("writing {}", path.display())) tracing::info!(
path = %path.display(),
user_id = session.user.id,
user = %session.user.name,
server = %session.server_base_url,
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
seconds_until_expiry = session.seconds_until_access_expiry(),
"persisting auth session"
);
write_private(&path, &text).with_context(|| format!("writing {}", path.display()))?;
tracing::debug!(path = %path.display(), "auth session persisted");
Ok(())
} }
pub fn delete_session() { pub fn delete_session() {
if let Some(path) = session_path() { if let Some(path) = session_path() {
let _ = fs::remove_file(path); match fs::remove_file(&path) {
Ok(()) => tracing::info!(path = %path.display(), "deleted stored auth session"),
Err(err) if err.kind() == ErrorKind::NotFound => {
tracing::debug!(path = %path.display(), "stored auth session already absent");
}
Err(err) => {
tracing::warn!(path = %path.display(), %err, "failed to delete stored auth session")
}
}
} }
} }
+200 -32
View File
@@ -76,21 +76,42 @@ pub async fn login_password(
username: &str, username: &str,
password: &str, password: &str,
) -> Result<AuthSession, ApiError> { ) -> Result<AuthSession, ApiError> {
let response = http let device_name = device_name();
tracing::info!(%base_url, %device_name, "password login request started");
let response = match http
.post(format!("{base_url}/api/auth/password")) .post(format!("{base_url}/api/auth/password"))
.json(&PasswordLoginRequest { .json(&PasswordLoginRequest {
username, username,
password, password,
device_name: device_name(), device_name,
}) })
.send() .send()
.await?; .await
let login: LoginResponse = parse_response(response).await?; {
Ok(AuthSession::new( Ok(response) => response,
base_url.to_string(), Err(err) => {
login.user, tracing::warn!(%base_url, %err, "password login request failed");
login.tokens, return Err(ApiError::Network(err));
)) }
};
let status = response.status();
let login: LoginResponse = match parse_response(response).await {
Ok(login) => login,
Err(err) => {
tracing::warn!(%base_url, %status, %err, "password login rejected");
return Err(err);
}
};
let session = AuthSession::new(base_url.to_string(), login.user, login.tokens);
tracing::info!(
%base_url,
user_id = session.user.id,
user = %session.user.name,
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
seconds_until_expiry = session.seconds_until_access_expiry(),
"password login succeeded"
);
Ok(session)
} }
pub async fn login_sso_exchange( pub async fn login_sso_exchange(
@@ -98,20 +119,38 @@ pub async fn login_sso_exchange(
base_url: &str, base_url: &str,
code: &str, code: &str,
) -> Result<AuthSession, ApiError> { ) -> Result<AuthSession, ApiError> {
let response = http let device_name = device_name();
tracing::info!(%base_url, %device_name, "SSO exchange request started");
let response = match http
.post(format!("{base_url}/api/auth/sso/exchange")) .post(format!("{base_url}/api/auth/sso/exchange"))
.json(&SsoExchangeRequest { .json(&SsoExchangeRequest { code, device_name })
code,
device_name: device_name(),
})
.send() .send()
.await?; .await
let login: LoginResponse = parse_response(response).await?; {
Ok(AuthSession::new( Ok(response) => response,
base_url.to_string(), Err(err) => {
login.user, tracing::warn!(%base_url, %err, "SSO exchange request failed");
login.tokens, return Err(ApiError::Network(err));
)) }
};
let status = response.status();
let login: LoginResponse = match parse_response(response).await {
Ok(login) => login,
Err(err) => {
tracing::warn!(%base_url, %status, %err, "SSO exchange rejected");
return Err(err);
}
};
let session = AuthSession::new(base_url.to_string(), login.user, login.tokens);
tracing::info!(
%base_url,
user_id = session.user.id,
user = %session.user.name,
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
seconds_until_expiry = session.seconds_until_access_expiry(),
"SSO exchange succeeded"
);
Ok(session)
} }
/// Browser entry point for SSO. redirect_uri is either our loopback /// Browser entry point for SSO. redirect_uri is either our loopback
@@ -130,15 +169,38 @@ async fn refresh_tokens(
base_url: &str, base_url: &str,
refresh_token: &str, refresh_token: &str,
) -> Result<TokensResponse, ApiError> { ) -> Result<TokensResponse, ApiError> {
let response = http tracing::info!(%base_url, "refresh token request started");
let response = match http
.post(format!("{base_url}/api/auth/refresh")) .post(format!("{base_url}/api/auth/refresh"))
.json(&RefreshRequest { refresh_token }) .json(&RefreshRequest { refresh_token })
.send() .send()
.await?; .await
{
Ok(response) => response,
Err(err) => {
tracing::warn!(%base_url, %err, "refresh token request failed");
return Err(ApiError::Network(err));
}
};
let status = response.status();
if response.status() == reqwest::StatusCode::UNAUTHORIZED { if response.status() == reqwest::StatusCode::UNAUTHORIZED {
tracing::warn!(%base_url, %status, "refresh token rejected by server");
return Err(ApiError::SessionExpired); return Err(ApiError::SessionExpired);
} }
parse_response(response).await let tokens: TokensResponse = match parse_response(response).await {
Ok(tokens) => tokens,
Err(err) => {
tracing::warn!(%base_url, %status, %err, "refresh token request returned error");
return Err(err);
}
};
tracing::info!(
%base_url,
%status,
expires_in_seconds = tokens.expires_in_seconds,
"refresh token request succeeded"
);
Ok(tokens)
} }
/// Mirrors the backend's PlaybackStateDto. /// Mirrors the backend's PlaybackStateDto.
@@ -201,6 +263,16 @@ async fn parse_response<T: DeserializeOwned>(response: reqwest::Response) -> Res
Err(ApiError::Server(message)) Err(ApiError::Server(message))
} }
fn stream_error_is_auth_related(err: &ApiError) -> bool {
let ApiError::Server(message) = err else {
return false;
};
let message = message.to_ascii_lowercase();
message.contains("401")
|| message.contains("unauthorized")
|| message.contains("authentication")
}
/// Authenticated API client. Owns the session; refreshes the access token /// Authenticated API client. Owns the session; refreshes the access token
/// proactively (60s skew) and once more on 401, persisting rotated tokens. /// proactively (60s skew) and once more on 401, persisting rotated tokens.
/// The session mutex makes concurrent refreshes single-flight. /// The session mutex makes concurrent refreshes single-flight.
@@ -258,13 +330,43 @@ impl ApiClient {
pub async fn open_stream( pub async fn open_stream(
&self, &self,
path: &str, path: &str,
) -> Result<(crate::player::TrackReader, Option<u64>), ApiError> {
let url: reqwest::Url = format!("{}{path}", self.base_url)
.parse()
.map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?;
tracing::info!(path, "opening authenticated stream");
let token = self.fresh_access_token().await?;
match self.open_stream_with_token(url.clone(), &token).await {
Ok(stream) => {
tracing::debug!(path, "authenticated stream opened");
Ok(stream)
}
Err(err) if stream_error_is_auth_related(&err) => {
tracing::warn!(path, %err, "stream rejected bearer token; refreshing and retrying");
let retry_token = self.refresh_after_rejection(&token).await?;
let result = self.open_stream_with_token(url, &retry_token).await;
if let Err(retry_err) = &result {
tracing::warn!(path, %retry_err, "stream open failed after auth refresh");
}
result
}
Err(err) => {
tracing::warn!(path, %err, "stream open failed");
Err(err)
}
}
}
async fn open_stream_with_token(
&self,
url: reqwest::Url,
token: &str,
) -> Result<(crate::player::TrackReader, Option<u64>), ApiError> { ) -> Result<(crate::player::TrackReader, Option<u64>), ApiError> {
use stream_download::Settings; use stream_download::Settings;
use stream_download::http::HttpStream; use stream_download::http::HttpStream;
use stream_download::source::SourceStream as _; use stream_download::source::SourceStream as _;
use stream_download::storage::temp::TempStorageProvider; use stream_download::storage::temp::TempStorageProvider;
let token = self.fresh_access_token().await?;
let mut headers = reqwest::header::HeaderMap::new(); let mut headers = reqwest::header::HeaderMap::new();
let value = format!("Bearer {token}") let value = format!("Bearer {token}")
.parse() .parse()
@@ -275,9 +377,6 @@ impl ApiClient {
.build() .build()
.map_err(ApiError::Network)?; .map_err(ApiError::Network)?;
let url = format!("{}{path}", self.base_url)
.parse()
.map_err(|err| ApiError::Server(format!("bad stream url: {err}")))?;
let stream = HttpStream::new(client, url) let stream = HttpStream::new(client, url)
.await .await
.map_err(|err| ApiError::Server(format!("stream open failed: {err}")))?; .map_err(|err| ApiError::Server(format!("stream open failed: {err}")))?;
@@ -480,6 +579,7 @@ impl ApiClient {
let session = self.session.lock().await; let session = self.session.lock().await;
(session.access_token.clone(), session.refresh_token.clone()) (session.access_token.clone(), session.refresh_token.clone())
}; };
tracing::info!(base_url = %self.base_url, "logout request started");
let response = self let response = self
.http .http
.post(format!("{}/api/auth/logout", self.base_url)) .post(format!("{}/api/auth/logout", self.base_url))
@@ -489,12 +589,20 @@ impl ApiClient {
}) })
.send() .send()
.await?; .await?;
let status = response.status();
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
struct LogoutResponse { struct LogoutResponse {
revoked: bool, revoked: bool,
} }
let body: LogoutResponse = parse_response(response).await?; let body: LogoutResponse = match parse_response(response).await {
Ok(body) => body,
Err(err) => {
tracing::warn!(base_url = %self.base_url, %status, %err, "logout request failed");
return Err(err);
}
};
tracing::info!(base_url = %self.base_url, %status, revoked = body.revoked, "logout request succeeded");
Ok(body.revoked) Ok(body.revoked)
} }
@@ -564,15 +672,28 @@ impl ApiClient {
let token = self.fresh_access_token().await?; let token = self.fresh_access_token().await?;
let response = build(&self.http, url, &token).send().await?; let response = build(&self.http, url, &token).send().await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED { if response.status() == reqwest::StatusCode::UNAUTHORIZED {
tracing::warn!(%url, "authenticated request returned 401; refreshing token and retrying");
let token = self.refresh_after_rejection(&token).await?; let token = self.refresh_after_rejection(&token).await?;
return Ok(build(&self.http, url, &token).send().await?); let response = build(&self.http, url, &token).send().await?;
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
tracing::warn!(%url, "authenticated request still returned 401 after token refresh");
}
return Ok(response);
} }
Ok(response) Ok(response)
} }
async fn fresh_access_token(&self) -> Result<String, ApiError> { async fn fresh_access_token(&self) -> Result<String, ApiError> {
let mut session = self.session.lock().await; let mut session = self.session.lock().await;
let seconds_until_expiry = session.seconds_until_access_expiry();
if session.access_token_expired() { if session.access_token_expired() {
tracing::info!(
user_id = session.user.id,
user = %session.user.name,
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
seconds_until_expiry,
"access token expired or near expiry; refreshing"
);
self.refresh_locked(&mut session).await?; self.refresh_locked(&mut session).await?;
} }
Ok(session.access_token.clone()) Ok(session.access_token.clone())
@@ -583,28 +704,75 @@ impl ApiClient {
async fn refresh_after_rejection(&self, rejected_token: &str) -> Result<String, ApiError> { async fn refresh_after_rejection(&self, rejected_token: &str) -> Result<String, ApiError> {
let mut session = self.session.lock().await; let mut session = self.session.lock().await;
if session.access_token != rejected_token { if session.access_token != rejected_token {
tracing::info!(
user_id = session.user.id,
user = %session.user.name,
"rejected access token was already rotated by another task"
);
return Ok(session.access_token.clone()); return Ok(session.access_token.clone());
} }
tracing::warn!(
user_id = session.user.id,
user = %session.user.name,
seconds_until_expiry = session.seconds_until_access_expiry(),
"access token was rejected; refreshing"
);
self.refresh_locked(&mut session).await?; self.refresh_locked(&mut session).await?;
Ok(session.access_token.clone()) Ok(session.access_token.clone())
} }
async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> { async fn refresh_locked(&self, session: &mut AuthSession) -> Result<(), ApiError> {
let user_id = session.user.id;
let user = session.user.name.clone();
let previous_expires_at = session.expires_at_epoch_seconds;
let previous_seconds_until_expiry = session.seconds_until_access_expiry();
tracing::info!(
user_id,
user = %user,
server = %self.base_url,
previous_expires_at_epoch_seconds = previous_expires_at,
previous_seconds_until_expiry,
"refreshing access token"
);
let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await; let result = refresh_tokens(&self.http, &self.base_url, &session.refresh_token).await;
match result { match result {
Ok(tokens) => { Ok(tokens) => {
let expires_in_seconds = tokens.expires_in_seconds;
session.apply_tokens(tokens); session.apply_tokens(tokens);
if let Err(err) = auth::save_session(session) { if let Err(err) = auth::save_session(session) {
tracing::warn!(%err, "failed to persist rotated tokens"); tracing::warn!(%err, "failed to persist rotated tokens");
} }
tracing::debug!("access token refreshed"); tracing::info!(
user_id = session.user.id,
user = %session.user.name,
server = %self.base_url,
expires_in_seconds,
expires_at_epoch_seconds = session.expires_at_epoch_seconds,
seconds_until_expiry = session.seconds_until_access_expiry(),
"access token refreshed"
);
Ok(()) Ok(())
} }
Err(ApiError::SessionExpired) => { Err(ApiError::SessionExpired) => {
tracing::warn!(
user_id,
user = %user,
server = %self.base_url,
"refresh token expired or rejected; clearing stored session"
);
auth::delete_session(); auth::delete_session();
Err(ApiError::SessionExpired) Err(ApiError::SessionExpired)
} }
Err(err) => Err(err), Err(err) => {
tracing::warn!(
user_id,
user = %user,
server = %self.base_url,
%err,
"access token refresh failed"
);
Err(err)
}
} }
} }
} }
+20
View File
@@ -637,9 +637,21 @@ fn start_current_audio(state: &mut AppState, runtime: &Runtime, position_secs: f
} }
} }
Err(ApiError::SessionExpired) => { Err(ApiError::SessionExpired) => {
tracing::warn!(
track_id = track.id,
title = %track.title,
"playback stream open reported expired session"
);
let _ = tx.send(AppEvent::SessionExpired); let _ = tx.send(AppEvent::SessionExpired);
} }
Err(err) => { Err(err) => {
tracing::warn!(
track_id = track.id,
title = %track.title,
stream_url = %track.stream_url,
%err,
"playback stream open failed"
);
let _ = tx.send(AppEvent::StatusMessage(format!("playback failed: {err}"))); let _ = tx.send(AppEvent::StatusMessage(format!("playback failed: {err}")));
} }
} }
@@ -682,6 +694,14 @@ fn maybe_prefetch_next(state: &mut AppState, runtime: &Runtime) {
tokio::spawn(async move { tokio::spawn(async move {
match api.open_stream(&next.stream_url).await { match api.open_stream(&next.stream_url).await {
Ok((reader, byte_len)) => controller.enqueue(reader, byte_len), Ok((reader, byte_len)) => controller.enqueue(reader, byte_len),
Err(ApiError::SessionExpired) => {
tracing::warn!(
track_id = next.id,
title = %next.title,
"prefetch reported expired session"
);
let _ = tx.send(AppEvent::SessionExpired);
}
Err(err) => { Err(err) => {
tracing::warn!(%err, "prefetch failed; falling back to a normal switch"); tracing::warn!(%err, "prefetch failed; falling back to a normal switch");
let _ = tx.send(AppEvent::PrefetchFailed { pos: next_pos }); let _ = tx.send(AppEvent::PrefetchFailed { pos: next_pos });