Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b624443d5 | |||
| ec777f956a | |||
| 654d8ad750 | |||
| d13b0c8085 | |||
| e30ad8be36 |
@@ -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
@@ -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
@@ -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]]
|
||||||
|
|||||||
@@ -1,44 +1,186 @@
|
|||||||
# furumi
|
# furumi
|
||||||
|
|
||||||
Terminal client (TUI) for the furumusic server. Cross-platform: Linux,
|

|
||||||
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
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
+54
-6
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user