6 Commits
Author SHA1 Message Date
ab 7d6af59e97 fix lock 2026-09-07 12:41:27 +03:00
ab 32c976beb8 Added self updater 2026-09-07 12:36:48 +03:00
Ultradesu 2607314da3 Fix lock 2026-09-02 15:50:41 +01:00
Ultradesu f901836929 fix federation recovery after sleep 2026-09-02 15:10:20 +01:00
Ultradesu 0c30cdfcc6 Added ogg opus support 2026-08-14 11:42:54 +01:00
Ultradesu cfc0bfa0e5 Moved library to FRID 2026-08-11 18:13:02 +01:00
24 changed files with 1792 additions and 5539 deletions
+6 -1
View File
@@ -98,7 +98,12 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
gh release create "$RELEASE_TAG" artifacts/*/* \ # Hash archive bytes; manifest entries use the release asset basenames.
for archive in artifacts/*/*; do
(cd "$(dirname "$archive")" && sha256sum "$(basename "$archive")")
done > SHA256SUMS
gh release create "$RELEASE_TAG" artifacts/*/* SHA256SUMS \
--title "furumi ${RELEASE_TAG}" \ --title "furumi ${RELEASE_TAG}" \
--notes "Release ${RELEASE_TAG}" \ --notes "Release ${RELEASE_TAG}" \
--verify-tag --verify-tag
+7 -2
View File
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.2.8] - 2026-09-02
### Added ### Added
- Optional offline music-similarity search for local tracks, backed by - Optional offline music-similarity search for local tracks, backed by
@@ -27,7 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Similarity wire types, bounds, validation, and stream framing now come from - Similarity wire types, bounds, validation, and stream framing now come from
the shared `music-dht 0.4.0` API so native, web, and future clients can the shared `music-dht 0.4` API so native, web, and future clients can
interoperate without sharing an embedding implementation. interoperate without sharing an embedding implementation.
- Existing SQLite embeddings are backfilled once with compact 256-bit routing - Existing SQLite embeddings are backfilled once with compact 256-bit routing
signatures; new embeddings store them immediately without changing exact signatures; new embeddings store them immediately without changing exact
@@ -41,6 +43,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Federation now recovers automatically after sleep, prolonged idle, or a
degraded rendezvous transport while preserving local playback and state.
- Current-track information (`Shift+I`) now uses the enriched queue entry, so - Current-track information (`Shift+I`) now uses the enriched queue entry, so
it shows the same complete metadata and similarity action as `I` on that it shows the same complete metadata and similarity action as `I` on that
track in the queue. track in the queue.
@@ -74,5 +78,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Music-directory validation and migration now reject overlapping changes, - Music-directory validation and migration now reject overlapping changes,
resolve canonical paths, and produce Windows-portable managed filenames. resolve canonical paths, and produce Windows-portable managed filenames.
[Unreleased]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.5...HEAD [Unreleased]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.8...HEAD
[0.2.8]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.7...v0.2.8
[0.2.5]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.4...v0.2.5 [0.2.5]: https://gt.hexor.cy/ab/furumi_tui/compare/v0.2.4...v0.2.5
Generated
+451 -236
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumi_tui" name = "furumi_tui"
version = "0.2.6" version = "0.2.9"
edition = "2024" edition = "2024"
rust-version = "1.97" rust-version = "1.97"
description = "A federated P2P player for personal music libraries" description = "A federated P2P player for personal music libraries"
@@ -11,27 +11,40 @@ name = "furumi"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
semver = "1"
self-replace = "1.5"
tempfile = "3"
fs2 = "0.4"
flate2 = "1"
tar = "0.4"
zip = { version = "4", default-features = false, features = ["deflate"] }
object = { version = "0.36", default-features = false, features = ["read", "std"] }
anyhow = "1.0.102" anyhow = "1.0.102"
blake3 = "1" blake3 = "1"
crokey = "1.4.0" crokey = "1.4.0"
crossterm = { version = "0.29.0", features = ["event-stream"] } crossterm = { version = "0.29.0", features = ["event-stream"] }
directories = "6.0.0" directories = "6.0.0"
futures-util = "0.3.32" futures-util = "0.3.32"
furumi-library = "0.1.0"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] } image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
lofty = "0.22" lofty = "0.22"
# P2P federation: library index in a shared DHT + audio streaming between # P2P federation: library index in a shared DHT + audio streaming between
# peers (same protocol as furumi-fd). # peers (same protocol as furumi-fd).
music-dht = "0.4.0" music-dht = "0.4.1"
ratatui = "0.30.1" ratatui = "0.30.1"
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] } reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream", "blocking"] }
rhai = { version = "1", features = ["sync"] } rhai = { version = "1", features = ["sync"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] } rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
rusty-opus = "0.9.1"
rustfft = "6.4.1" rustfft = "6.4.1"
rusqlite = { version = "0.32", features = ["bundled", "functions"] } rusqlite = { version = "0.32", features = ["bundled", "functions"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150" serde_json = "1.0.150"
sha2 = "0.10.9" sha2 = "0.10.9"
souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] } souvlaki = { version = "0.8.3", default-features = false, features = ["use_zbus"] }
symphonia = { version = "0.5.5", default-features = false, features = ["ogg"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] } tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
toml = "1.1.2" toml = "1.1.2"
@@ -53,3 +66,6 @@ windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_Wi
[target."cfg(unix)".dependencies] [target."cfg(unix)".dependencies]
libc = "0.2.186" libc = "0.2.186"
[dev-dependencies]
base64 = "0.22.1"
+18
View File
@@ -124,6 +124,24 @@ Import a music directory from Furumi's command line:
Federation, trusted-device pairing, and key bindings are configured directly Federation, trusted-device pairing, and key bindings are configured directly
inside the player. inside the player.
### Manual updates
In **Settings → Updates**, select **Check for updates**, then **Install update**
when a newer stable GitHub release is available. Downloads run in the background.
After installation, restart `furumi` to use the new version; playback is not
restarted automatically. Wait for an active update operation to finish before
quitting.
Updates replace the running executable in its installation directory, which
must be writable by your user. Release archives must include a matching entry
in the release's `SHA256SUMS` asset. Older releases without it cannot be installed
through this feature. The updater checks SHA-256 and the executable's format
and architecture before replacing it. Checksums provide integrity checking,
not publisher signatures. Settings and the local library are preserved.
There are no automatic startup checks. Only the existing release asset naming
scheme is supported; missing or incompatible platform builds are rejected.
### Now playing in tmux ### Now playing in tmux
While Furumi is running, a second invocation can print a cheap, single-line While Furumi is running, a second invocation can print a cheap, single-line
+3
View File
@@ -10,6 +10,9 @@ use crate::library::models::{
/// the playback engine, imports). Tasks never touch AppState directly. /// the playback engine, imports). Tasks never touch AppState directly.
#[derive(Debug)] #[derive(Debug)]
pub enum AppEvent { pub enum AppEvent {
UpdateChecked(Result<Option<crate::updater::Update>, String>),
UpdateProgress(String),
UpdateInstalled(Result<(), String>),
StatusMessage(String), StatusMessage(String),
/// A page of the artists list arrived (or failed). /// A page of the artists list arrived (or failed).
ArtistsLoaded(Result<ArtistsPage, String>), ArtistsLoaded(Result<ArtistsPage, String>),
+64
View File
@@ -312,6 +312,7 @@ pub async fn run(
Arc::clone(&similarity), Arc::clone(&similarity),
settings.music_dir.clone(), settings.music_dir.clone(),
); );
federation.start_supervisor();
state.music_dir = federation.media_dir(); state.music_dir = federation.media_dir();
state.federation.settings = federation.settings(); state.federation.settings = federation.settings();
state.federation.devices = Some(devices.status()); state.federation.devices = Some(devices.status());
@@ -430,6 +431,13 @@ pub async fn run(
} }
if state.should_quit { if state.should_quit {
// A replacement must finish before the runtime/process is torn down.
if state.updater.busy {
state.should_quit = false;
state.status_message =
Some("Please wait for the update operation to finish".into());
continue;
}
if runtime.plain_text_mode { if runtime.plain_text_mode {
leave_plain_text_mode()?; leave_plain_text_mode()?;
runtime.plain_text_mode = false; runtime.plain_text_mode = false;
@@ -1414,6 +1422,31 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
return; return;
} }
match effect { match effect {
Effect::CheckUpdate => {
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = tokio::task::spawn_blocking(crate::updater::check)
.await
.map_err(|err| format!("update worker failed: {err}"))
.and_then(|result| result.map_err(|err| format!("{err:#}")));
let _ = tx.send(AppEvent::UpdateChecked(result));
});
}
Effect::InstallUpdate(update) => {
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let progress_tx = tx.clone();
let result = tokio::task::spawn_blocking(move || {
crate::updater::install(&update, |message| {
let _ = progress_tx.send(AppEvent::UpdateProgress(message));
})
})
.await
.map_err(|err| format!("update worker failed: {err}"))
.and_then(|result| result.map_err(|err| format!("{err:#}")));
let _ = tx.send(AppEvent::UpdateInstalled(result));
});
}
Effect::PlayCurrent => { Effect::PlayCurrent => {
play_current(state, runtime); play_current(state, runtime);
push_media_metadata(state, runtime); push_media_metadata(state, runtime);
@@ -3097,6 +3130,37 @@ fn handle_playback_command(
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) { fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
match event { match event {
AppEvent::UpdateChecked(result) => {
state.updater.busy = false;
state.updater.message = match result {
Ok(Some(update)) => {
let message = format!("v{} available", update.version);
state.updater.available = Some(update);
message
}
Ok(None) => "No newer stable release".into(),
Err(error) => format!("Check failed: {error}"),
};
state.status_message = Some(state.updater.message.clone());
}
AppEvent::UpdateProgress(message) => state.updater.message = message,
AppEvent::UpdateInstalled(result) => {
state.updater.busy = false;
state.updater.message = match result {
Ok(()) => {
state.updater.installed = true;
let version = state
.updater
.available
.as_ref()
.map(|u| u.version.as_str())
.unwrap_or("new version");
format!("Installed {version}; restart furumi")
}
Err(error) => format!("Update failed: {error}"),
};
state.status_message = Some(state.updater.message.clone());
}
AppEvent::StatusMessage(message) => state.status_message = Some(message), AppEvent::StatusMessage(message) => state.status_message = Some(message),
AppEvent::ListenHistoryLoaded(result) => { AppEvent::ListenHistoryLoaded(result) => {
state.listen_history = Some(match result { state.listen_history = Some(match result {
+8 -1
View File
@@ -926,6 +926,8 @@ impl FedRow {
/// the config directory. /// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow { pub enum SettingsRow {
CheckUpdate,
InstallUpdate,
MusicDirectory, MusicDirectory,
Similarity(SimilarityRow), Similarity(SimilarityRow),
Federation(FedRow), Federation(FedRow),
@@ -1065,7 +1067,11 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
} }
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> { pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = vec![SettingsRow::MusicDirectory]; let mut rows = vec![
SettingsRow::MusicDirectory,
SettingsRow::CheckUpdate,
SettingsRow::InstallUpdate,
];
rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity)); rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity));
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation)); rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::DeviceName); rows.push(SettingsRow::DeviceName);
@@ -1571,6 +1577,7 @@ impl DevicePlaybackState {
/// event handlers in the main loop; views render from `&AppState`. /// event handlers in the main loop; views render from `&AppState`.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct AppState { pub struct AppState {
pub updater: crate::updater::State,
pub active_tab: Tab, pub active_tab: Tab,
pub should_quit: bool, pub should_quit: bool,
pub shutting_down: bool, pub shutting_down: bool,
+20
View File
@@ -18,6 +18,8 @@ pub const QUIT_CONFIRM_HINT: &str = "press quit again to exit";
/// owns the Runtime (audio controller, API client). Keeps update() pure. /// owns the Runtime (audio controller, API client). Keeps update() pure.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect { pub enum Effect {
CheckUpdate,
InstallUpdate(crate::updater::Update),
/// (Re)start playback of `queue[queue_pos]`. /// (Re)start playback of `queue[queue_pos]`.
PlayCurrent, PlayCurrent,
TogglePause, TogglePause,
@@ -2815,6 +2817,24 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
fn federation_select(state: &mut AppState) -> Option<Effect> { fn federation_select(state: &mut AppState) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup, SettingsRow, SimilarityRow}; use super::state::{FedInputField, FedRow, Popup, SettingsRow, SimilarityRow};
match settings_rows(state).get(state.settings_cursor).copied()? { match settings_rows(state).get(state.settings_cursor).copied()? {
SettingsRow::CheckUpdate => {
if state.updater.busy || state.updater.installed {
return None;
}
state.updater.busy = true;
state.updater.available = None;
state.updater.message = "Checking GitHub Releases...".into();
Some(Effect::CheckUpdate)
}
SettingsRow::InstallUpdate => {
if state.updater.busy || state.updater.installed {
return None;
}
let update = state.updater.available.clone()?;
state.updater.busy = true;
state.updater.message = "Downloading update...".into();
Some(Effect::InstallUpdate(update))
}
SettingsRow::MusicDirectory => { SettingsRow::MusicDirectory => {
if state.music_dir_changing { if state.music_dir_changing {
state.status_message = Some("music directory change is already running".into()); state.status_message = Some("music directory change is already running".into());
+21
View File
@@ -1,6 +1,27 @@
use super::*; use super::*;
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem}; use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
#[test]
fn manual_update_check_is_single_flight_and_disabled_after_install() {
let mut state = AppState::default();
state.settings_cursor = settings_rows(&state)
.iter()
.position(|row| *row == crate::app::state::SettingsRow::CheckUpdate)
.unwrap();
assert_eq!(federation_select(&mut state), Some(Effect::CheckUpdate));
assert!(state.updater.busy);
assert_eq!(federation_select(&mut state), None);
state.updater.busy = false;
state.updater.installed = true;
assert_eq!(federation_select(&mut state), None);
state.updater.installed = false;
state.settings_cursor = settings_rows(&state)
.iter()
.position(|row| *row == crate::app::state::SettingsRow::InstallUpdate)
.unwrap();
assert_eq!(federation_select(&mut state), None);
}
fn with_artists(n: usize) -> AppState { fn with_artists(n: usize) -> AppState {
let mut state = AppState::default(); let mut state = AppState::default();
state.global.artists = (0..n) state.global.artists = (0..n)
+1 -42
View File
@@ -2,48 +2,7 @@ use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub use furumi_library::{LibraryFilters, LibrarySourceMode};
#[serde(rename_all = "snake_case")]
pub enum LibrarySourceMode {
Local,
My,
#[default]
Global,
}
impl LibrarySourceMode {
pub fn label(self) -> &'static str {
match self {
LibrarySourceMode::Local => "Local",
LibrarySourceMode::My => "My",
LibrarySourceMode::Global => "Global",
}
}
pub fn includes_network(self) -> bool {
!matches!(self, LibrarySourceMode::Local)
}
pub fn includes_global_peers(self) -> bool {
matches!(self, LibrarySourceMode::Global)
}
pub fn next(self) -> Self {
match self {
LibrarySourceMode::Local => LibrarySourceMode::My,
LibrarySourceMode::My => LibrarySourceMode::Global,
LibrarySourceMode::Global => LibrarySourceMode::Local,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LibraryFilters {
#[serde(default)]
pub hide_featured_only: bool,
#[serde(default)]
pub source_mode: LibrarySourceMode,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SimilaritySettings { pub struct SimilaritySettings {
+122 -63
View File
@@ -21,8 +21,8 @@ use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use music_dht::similarity_dht::SimilarityDht; use music_dht::similarity_dht::SimilarityDht;
@@ -46,6 +46,11 @@ pub use similarity::SIMILARITY_ALPN;
/// How often the published library is re-synchronized with the local index. /// How often the published library is re-synchronized with the local index.
const SYNC_INTERVAL: Duration = Duration::from_secs(60); const SYNC_INTERVAL: Duration = Duration::from_secs(60);
/// How often the application checks whether the shared transport needs a
/// full restart so all application-owned protocol acceptors are recreated.
const SUPERVISOR_INTERVAL: Duration = Duration::from_secs(15);
/// Prevents repeated restarts during a prolonged external network outage.
const RECOVERY_COOLDOWN: Duration = Duration::from_secs(5 * 60);
/// How many times a share-link content lookup is retried before the label /// How many times a share-link content lookup is retried before the label
/// fallback kicks in. /// fallback kicks in.
@@ -360,43 +365,7 @@ pub struct FedSimilaritySearchResults {
} }
/// A track found through federated search. /// A track found through federated search.
#[derive(Debug, Clone, PartialEq, Eq)] pub use furumi_library::FederatedTrack as FedTrack;
pub struct FedTrack {
/// Hex item id in the DHT (the key audio is requested by).
pub item_id: String,
/// Hex endpoint id of the owning peer.
pub owner: String,
/// The item is published by this very instance.
pub own: bool,
pub title: String,
pub artist_names: Vec<String>,
pub featured_artist_names: Vec<String>,
pub year: Option<i32>,
pub duration_seconds: Option<i64>,
/// Stable audio content id (`b3:<64 hex>`) when the owner published it.
pub content_id: Option<String>,
/// Release context, known when the track came from an artist card.
pub release_title: Option<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
}
impl FedTrack {
pub fn artist_line(&self) -> String {
artist_line(&self.artist_names, &self.featured_artist_names)
}
pub fn owner_short(&self) -> String {
self.owner.chars().take(10).collect()
}
pub fn duration_label(&self) -> String {
match self.duration_seconds {
Some(total) => format!("{}:{:02}", total / 60, total % 60),
None => String::new(),
}
}
}
/// Live status snapshot rendered on the Federation tab. /// Live status snapshot rendered on the Federation tab.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
@@ -406,6 +375,12 @@ pub struct FedStatus {
pub endpoint_id: String, pub endpoint_id: String,
pub dht_node_id: String, pub dht_node_id: String,
pub connected_peers: Vec<String>, pub connected_peers: Vec<String>,
pub network_health: String,
pub rendezvous_failures: u32,
pub peer_dial_failures: u32,
pub rendezvous_restarts: u64,
pub recovery_count: u64,
pub last_rendezvous_error: Option<String>,
pub known_contacts: usize, pub known_contacts: usize,
pub stored_dht_records: Option<usize>, pub stored_dht_records: Option<usize>,
pub stored_dht_bytes: Option<u64>, pub stored_dht_bytes: Option<u64>,
@@ -455,6 +430,10 @@ pub struct Federation {
metadata_cache: std::sync::Mutex<std::collections::HashMap<String, CachedTrackMetadata>>, metadata_cache: std::sync::Mutex<std::collections::HashMap<String, CachedTrackMetadata>>,
settings: std::sync::Mutex<FedSettings>, settings: std::sync::Mutex<FedSettings>,
running: tokio::sync::Mutex<Option<Running>>, running: tokio::sync::Mutex<Option<Running>>,
supervisor_task: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
supervisor_shutdown: tokio::sync::Notify,
shutting_down: AtomicBool,
recovery_count: AtomicU64,
last_sync: std::sync::Mutex<Option<String>>, last_sync: std::sync::Mutex<Option<String>>,
last_error: std::sync::Mutex<Option<String>>, last_error: std::sync::Mutex<Option<String>>,
transport_stats: Arc<TransportStats>, transport_stats: Arc<TransportStats>,
@@ -585,6 +564,10 @@ impl Federation {
metadata_cache: std::sync::Mutex::new(Default::default()), metadata_cache: std::sync::Mutex::new(Default::default()),
settings: std::sync::Mutex::new(load_settings()), settings: std::sync::Mutex::new(load_settings()),
running: tokio::sync::Mutex::new(None), running: tokio::sync::Mutex::new(None),
supervisor_task: std::sync::Mutex::new(None),
supervisor_shutdown: tokio::sync::Notify::new(),
shutting_down: AtomicBool::new(false),
recovery_count: AtomicU64::new(0),
last_sync: std::sync::Mutex::new(None), last_sync: std::sync::Mutex::new(None),
last_error: std::sync::Mutex::new(initial_error), last_error: std::sync::Mutex::new(initial_error),
transport_stats: Arc::new(TransportStats::default()), transport_stats: Arc::new(TransportStats::default()),
@@ -596,6 +579,18 @@ impl Federation {
lock(&self.settings).clone() lock(&self.settings).clone()
} }
/// Starts the application-level federation supervisor once.
pub fn start_supervisor(self: &Arc<Self>) {
let mut task = lock(&self.supervisor_task);
if task.is_some() || self.shutting_down.load(Ordering::SeqCst) {
return;
}
let federation = Arc::clone(self);
*task = Some(tokio::spawn(async move {
federation.supervisor_loop().await;
}));
}
pub fn media_dir(&self) -> PathBuf { pub fn media_dir(&self) -> PathBuf {
lock(&self.media_dir).clone() lock(&self.media_dir).clone()
} }
@@ -701,12 +696,43 @@ impl Federation {
network_id: NetworkId, network_id: NetworkId,
network_name: String, network_name: String,
) -> Result<()> { ) -> Result<()> {
self.start_with_network_id_mode(network_id, network_name, false)
.await
.map(|_| ())
}
async fn start_with_network_id_mode(
self: &Arc<Self>,
network_id: NetworkId,
network_name: String,
recovery_only: bool,
) -> Result<bool> {
let mut guard = self.running.lock().await; let mut guard = self.running.lock().await;
if recovery_only {
let current = self.settings();
if !current.enabled
|| current.network_id.trim() != network_name
|| NetworkId::from_name(current.network_id.trim()) != network_id
{
return Ok(false);
}
}
if let Some(running) = guard.as_ref() { if let Some(running) = guard.as_ref() {
if running.network_id == network_id { if running.network_id == network_id {
return Ok(()); if !recovery_only || !running.service.network_health().restart_recommended {
return Ok(false);
}
tracing::warn!(
network = %network_name,
health = %running.service.network_health().state,
"restarting degraded federation service"
);
} else if recovery_only {
return Ok(false);
} }
stop_running(guard.take()).await; stop_running(guard.take()).await;
} else if recovery_only {
tracing::warn!(network = %network_name, "retrying stopped federation service");
} }
std::fs::create_dir_all(&self.data_dir) std::fs::create_dir_all(&self.data_dir)
.with_context(|| format!("creating {}", self.data_dir.display()))?; .with_context(|| format!("creating {}", self.data_dir.display()))?;
@@ -864,7 +890,7 @@ impl Federation {
], ],
}); });
self.set_error(None); self.set_error(None);
Ok(()) Ok(true)
} }
async fn stop(&self) { async fn stop(&self) {
@@ -873,9 +899,57 @@ impl Federation {
} }
pub async fn shutdown(&self) { pub async fn shutdown(&self) {
self.shutting_down.store(true, Ordering::SeqCst);
self.supervisor_shutdown.notify_one();
let supervisor = lock(&self.supervisor_task).take();
if let Some(supervisor) = supervisor {
let _ = supervisor.await;
}
self.stop().await; self.stop().await;
} }
async fn supervisor_loop(self: Arc<Self>) {
let mut interval = tokio::time::interval(SUPERVISOR_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
interval.tick().await;
let mut last_attempt = None;
loop {
tokio::select! {
_ = self.supervisor_shutdown.notified() => return,
_ = interval.tick() => {}
}
if self.shutting_down.load(Ordering::SeqCst) {
return;
}
if last_attempt.is_some_and(|attempt: Instant| attempt.elapsed() < RECOVERY_COOLDOWN) {
continue;
}
match self.recover_if_needed().await {
Ok(false) => {}
Ok(true) => {
last_attempt = Some(Instant::now());
self.recovery_count.fetch_add(1, Ordering::Relaxed);
self.spawn_sync_soon().await;
}
Err(err) => {
last_attempt = Some(Instant::now());
tracing::error!(error = %err, "federation recovery failed");
self.set_error(Some(format!("network recovery failed: {err}")));
}
}
}
}
async fn recover_if_needed(self: &Arc<Self>) -> Result<bool> {
let settings = self.settings();
if !settings.enabled || settings.network_id.trim().is_empty() {
return Ok(false);
}
let network_name = settings.network_id.trim().to_string();
self.start_with_network_id_mode(NetworkId::from_name(&network_name), network_name, true)
.await
}
async fn service(&self) -> Result<Arc<MusicDhtService>> { async fn service(&self) -> Result<Arc<MusicDhtService>> {
self.running self.running
.lock() .lock()
@@ -1011,6 +1085,7 @@ impl Federation {
let guard = self.running.lock().await; let guard = self.running.lock().await;
let mut status = FedStatus { let mut status = FedStatus {
network: settings.network_id, network: settings.network_id,
recovery_count: self.recovery_count.load(Ordering::Relaxed),
last_sync: lock(&self.last_sync).clone(), last_sync: lock(&self.last_sync).clone(),
last_error: lock(&self.last_error).clone(), last_error: lock(&self.last_error).clone(),
protocols: ProtocolVersions::snapshot(&self.observed_protocols), protocols: ProtocolVersions::snapshot(&self.observed_protocols),
@@ -1018,6 +1093,7 @@ impl Federation {
}; };
if let Some(running) = guard.as_ref() { if let Some(running) = guard.as_ref() {
let service = &running.service; let service = &running.service;
let health = service.network_health();
status.running = true; status.running = true;
status.network = running.network_name.clone(); status.network = running.network_name.clone();
status.endpoint_id = service.endpoint_id().to_string(); status.endpoint_id = service.endpoint_id().to_string();
@@ -1027,6 +1103,11 @@ impl Federation {
.iter() .iter()
.map(|p| p.to_string()) .map(|p| p.to_string())
.collect(); .collect();
status.network_health = health.state.to_string();
status.rendezvous_failures = health.consecutive_rendezvous_failures;
status.peer_dial_failures = health.consecutive_peer_dial_failures;
status.rendezvous_restarts = health.rendezvous_restarts;
status.last_rendezvous_error = health.last_rendezvous_error;
status.known_contacts = service.known_peers().len(); status.known_contacts = service.known_peers().len();
status.stored_dht_records = service.dht_record_count().await.ok(); status.stored_dht_records = service.dht_record_count().await.ok();
status.stored_dht_bytes = status.stored_dht_bytes =
@@ -2600,28 +2681,6 @@ fn push_artist_once(names: &mut Vec<String>, name: &str) {
names.push(name.to_string()); names.push(name.to_string());
} }
fn artist_line(artists: &[String], featured_artists: &[String]) -> String {
let mut main = Vec::new();
for artist in artists {
push_artist_once(&mut main, artist);
}
let mut featured = Vec::new();
for artist in featured_artists {
if !main
.iter()
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
{
push_artist_once(&mut featured, artist);
}
}
match (main.is_empty(), featured.is_empty()) {
(false, false) => format!("{} feat. {}", main.join(", "), featured.join(", ")),
(false, true) => main.join(", "),
(true, false) => format!("feat. {}", featured.join(", ")),
(true, true) => String::new(),
}
}
fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) { fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) {
appearances.sort_by(|a, b| { appearances.sort_by(|a, b| {
b.year b.year
-561
View File
@@ -1,561 +0,0 @@
//! Importing audio files into the library: directory scanning, tag reading
//! (via lofty) and cover extraction. Importing the same file again updates
//! its metadata instead of duplicating it.
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use lofty::file::{AudioFile as _, TaggedFileExt as _};
use lofty::picture::MimeType;
use lofty::tag::{Accessor as _, ItemKey};
use rusqlite::{OptionalExtension as _, params};
use super::{Library, audio_content_id, find_or_create_artist};
/// Extensions the playback engine can decode (rodio/symphonia feature set).
const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"];
/// Everything known about one audio file, ready to be written to the DB.
#[derive(Debug)]
pub struct TrackImport {
pub file_path: String,
pub title: String,
pub artists: Vec<String>,
pub featured_artists: Vec<String>,
pub album_artists: Vec<String>,
pub release_title: String,
/// Release type ("album", "single", ...) when known from a richer
/// source than file tags (e.g. federation metadata); None = "album".
pub release_type: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: f64,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
pub file_size_bytes: Option<i64>,
/// Embedded cover art (bytes, file extension), if any.
pub cover: Option<(Vec<u8>, &'static str)>,
}
#[derive(Debug, Default)]
pub struct ImportOutcome {
pub added: usize,
pub updated: usize,
pub failed: Vec<(PathBuf, String)>,
}
impl ImportOutcome {
pub fn summary(&self) -> String {
let mut message = format!("imported {} track(s)", self.added);
if self.updated > 0 {
message.push_str(&format!(", updated {}", self.updated));
}
if !self.failed.is_empty() {
message.push_str(&format!(", {} failed", self.failed.len()));
}
message
}
}
/// Import a file or a directory (recursively). `progress(done, total, name)`
/// is called after every file.
pub fn import_path(
library: &Library,
path: &Path,
mut progress: impl FnMut(usize, usize, &str),
) -> Result<ImportOutcome> {
let path = path
.canonicalize()
.with_context(|| format!("{} does not exist", path.display()))?;
let mut files = Vec::new();
collect_audio_files(&path, &mut files);
anyhow::ensure!(
!files.is_empty(),
"no audio files found at {} (supported: {})",
path.display(),
AUDIO_EXTENSIONS.join(", ")
);
files.sort();
let total = files.len();
let mut outcome = ImportOutcome::default();
for (index, file) in files.iter().enumerate() {
match read_file(file).and_then(|import| upsert_track(library, &import)) {
Ok((_, created)) => {
if created {
outcome.added += 1;
} else {
outcome.updated += 1;
}
}
Err(err) => {
tracing::warn!(file = %file.display(), %err, "import failed");
outcome.failed.push((file.clone(), format!("{err:#}")));
}
}
let name = file
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
progress(index + 1, total, &name);
}
Ok(outcome)
}
fn collect_audio_files(path: &Path, files: &mut Vec<PathBuf>) {
if path.is_dir() {
let Ok(entries) = std::fs::read_dir(path) else {
return;
};
for entry in entries.flatten() {
collect_audio_files(&entry.path(), files);
}
return;
}
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase());
if extension.is_some_and(|ext| AUDIO_EXTENSIONS.contains(&ext.as_str())) {
files.push(path.to_path_buf());
}
}
/// Read tags and audio properties from one file.
pub fn read_file(path: &Path) -> Result<TrackImport> {
let tagged = lofty::read_from_path(path).context("cannot read tags")?;
let properties = tagged.properties();
let tag = tagged.primary_tag().or_else(|| tagged.first_tag());
let fallback_title = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "Unknown".to_string());
let (mut title, artist_raw, album, year, track_number, disc_number, album_artist_raw, cover) =
match tag {
Some(tag) => (
tag.title()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or(fallback_title),
tag.artist().map(|value| value.into_owned()),
tag.album()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty()),
tag.year().and_then(|value| i32::try_from(value).ok()),
tag.track().and_then(|value| i32::try_from(value).ok()),
tag.disk().and_then(|value| i32::try_from(value).ok()),
tag.get_string(&ItemKey::AlbumArtist)
.map(|value| value.to_string()),
tag.pictures().first().map(|picture| {
let extension = match picture.mime_type() {
Some(MimeType::Png) => "png",
Some(MimeType::Gif) => "gif",
Some(MimeType::Bmp) => "bmp",
_ => "jpg",
};
(picture.data().to_vec(), extension)
}),
),
None => (fallback_title, None, None, None, None, None, None, None),
};
let (mut artists, mut featured) = split_artist_tag(artist_raw.as_deref().unwrap_or(""));
// "Song (feat. X)" in the title moves X into the featured list.
if let Some((clean_title, feat)) = extract_title_feat(&title) {
title = clean_title;
for name in feat {
if !featured.iter().any(|f| f.eq_ignore_ascii_case(&name)) {
featured.push(name);
}
}
}
if artists.is_empty() {
artists.push("Unknown Artist".to_string());
}
let album_artists = match album_artist_raw.as_deref().map(split_artist_tag) {
Some((main, _)) if !main.is_empty() => main,
_ => artists.clone(),
};
let metadata = std::fs::metadata(path).ok();
Ok(TrackImport {
file_path: path.to_string_lossy().into_owned(),
title,
artists,
featured_artists: featured,
album_artists,
release_title: album.unwrap_or_else(|| "Unknown Album".to_string()),
release_type: None,
year,
track_number,
disc_number,
duration_seconds: properties.duration().as_secs_f64(),
audio_format: path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase()),
audio_bitrate: properties
.audio_bitrate()
.and_then(|value| i32::try_from(value).ok()),
audio_sample_rate: properties
.sample_rate()
.and_then(|value| i32::try_from(value).ok()),
audio_bit_depth: properties.bit_depth().map(i32::from),
file_size_bytes: metadata.map(|meta| meta.len() as i64),
cover,
})
}
/// Insert or update one track (matching by file path). Returns the track id
/// and whether a new row was created.
pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> {
let content_id = audio_content_id(&import.file_path);
let mut conn = library.lock();
let tx = conn.transaction()?;
// Release, keyed by (title, first album artist).
let album_artist_id = find_or_create_artist(
&tx,
import
.album_artists
.first()
.map(String::as_str)
.unwrap_or("Unknown Artist"),
)?;
let release_id: Option<i64> = tx
.query_row(
"SELECT r.id FROM releases r
JOIN release_artists ra ON ra.release_id = r.id
WHERE r.title = ?1 COLLATE NOCASE AND ra.artist_id = ?2",
params![import.release_title, album_artist_id],
|row| row.get(0),
)
.optional()?;
let release_id = match release_id {
Some(id) => {
// Fill in the year if this file is the first one to know it.
if import.year.is_some() {
tx.execute(
"UPDATE releases SET year = COALESCE(year, ?2) WHERE id = ?1",
params![id, import.year],
)?;
}
id
}
None => {
tx.execute(
"INSERT INTO releases (title, release_type, year) VALUES (?1, ?2, ?3)",
params![
import.release_title,
import.release_type.as_deref().unwrap_or("album"),
import.year,
],
)?;
let id = tx.last_insert_rowid();
for (position, name) in import.album_artists.iter().enumerate() {
let artist_id = find_or_create_artist(&tx, name)?;
tx.execute(
"INSERT OR IGNORE INTO release_artists (release_id, artist_id, position)
VALUES (?1, ?2, ?3)",
params![id, artist_id, position as i64],
)?;
}
id
}
};
let existing: Option<i64> = tx
.query_row(
"SELECT id FROM tracks WHERE file_path = ?1",
[&import.file_path],
|row| row.get(0),
)
.optional()?;
let (track_id, created) = match existing {
Some(id) => {
tx.execute(
"UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4,
duration_seconds = ?5, release_id = ?6, audio_format = ?7,
audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10,
file_size_bytes = ?11, content_id = ?12
WHERE id = ?1",
params![
id,
import.title,
import.track_number,
import.disc_number,
import.duration_seconds,
release_id,
import.audio_format,
import.audio_bitrate,
import.audio_sample_rate,
import.audio_bit_depth,
import.file_size_bytes,
content_id.as_deref(),
],
)?;
tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?;
(id, false)
}
None => {
tx.execute(
"INSERT INTO tracks (title, track_number, disc_number, duration_seconds,
release_id, file_path, audio_format, audio_bitrate, audio_sample_rate,
audio_bit_depth, file_size_bytes, content_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
import.title,
import.track_number,
import.disc_number,
import.duration_seconds,
release_id,
import.file_path,
import.audio_format,
import.audio_bitrate,
import.audio_sample_rate,
import.audio_bit_depth,
import.file_size_bytes,
content_id.as_deref(),
],
)?;
(tx.last_insert_rowid(), true)
}
};
for (position, name) in import.artists.iter().enumerate() {
let artist_id = find_or_create_artist(&tx, name)?;
tx.execute(
"INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position)
VALUES (?1, ?2, 'main', ?3)",
params![track_id, artist_id, position as i64],
)?;
}
for (position, name) in import.featured_artists.iter().enumerate() {
let artist_id = find_or_create_artist(&tx, name)?;
tx.execute(
"INSERT OR IGNORE INTO track_artists (track_id, artist_id, role, position)
VALUES (?1, ?2, 'featured', ?3)",
params![track_id, artist_id, position as i64],
)?;
}
// Cover: a release keeps the first cover found — an image file next to
// the audio, or the embedded picture saved into the covers directory.
let has_cover: bool = tx
.query_row(
"SELECT cover_path IS NOT NULL FROM releases WHERE id = ?1",
[release_id],
|row| row.get(0),
)
.unwrap_or(false);
if !has_cover && let Some(cover_path) = resolve_cover(library, release_id, import) {
tx.execute(
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
params![release_id, cover_path],
)?;
}
tx.commit()?;
Ok((track_id, created))
}
/// Find a cover image for the release: a cover/folder/front image in the
/// audio file's directory, or the embedded picture written to disk.
fn resolve_cover(library: &Library, release_id: i64, import: &TrackImport) -> Option<String> {
let directory = Path::new(&import.file_path).parent()?;
if let Ok(entries) = std::fs::read_dir(directory) {
for entry in entries.flatten() {
let path = entry.path();
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.to_ascii_lowercase());
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase());
let is_image = matches!(
extension.as_deref(),
Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif")
);
if is_image
&& matches!(
stem.as_deref(),
Some("cover" | "folder" | "front" | "album")
)
{
return Some(path.to_string_lossy().into_owned());
}
}
}
let (data, extension) = import.cover.as_ref()?;
let covers_dir = library.covers_dir();
if let Err(err) = std::fs::create_dir_all(covers_dir) {
tracing::warn!(%err, "cannot create covers directory");
return None;
}
let path = covers_dir.join(format!("release_{release_id}.{extension}"));
match std::fs::write(&path, data) {
Ok(()) => Some(path.to_string_lossy().into_owned()),
Err(err) => {
tracing::warn!(%err, path = %path.display(), "cannot save embedded cover");
None
}
}
}
/// Split an artist tag into (main artists, featured artists).
/// Separators: ";" and "/" between main artists; "feat."/"ft."/"featuring"
/// starts the featured list.
pub fn split_artist_tag(raw: &str) -> (Vec<String>, Vec<String>) {
let raw = raw.trim();
if raw.is_empty() {
return (Vec::new(), Vec::new());
}
let (main_part, feat_part) = match find_feat_marker(raw) {
Some((at, marker_len)) => {
let main = raw[..at].trim_end_matches(['(', '[', ' ', ',', '-']);
let feat = raw[at + marker_len..].trim_end_matches([')', ']']);
(main, feat)
}
None => (raw, ""),
};
(split_names(main_part), split_names(feat_part))
}
/// The earliest "feat."/"ft."/"featuring" marker that stands as its own
/// word — preceded by a separator and followed by a space — so artist names
/// like "Daft Punk" are not split on the "ft" inside them.
fn find_feat_marker(raw: &str) -> Option<(usize, usize)> {
let lowered = raw.to_lowercase();
let mut best: Option<(usize, usize)> = None;
for marker in ["featuring", "feat.", "feat", "ft.", "ft"] {
for (at, _) in lowered.match_indices(marker) {
let before_ok = raw[..at]
.chars()
.next_back()
.is_some_and(|c| matches!(c, ' ' | '(' | '[' | ',' | '-'));
let after_ok = raw[at + marker.len()..].starts_with(' ');
if before_ok && after_ok && best.is_none_or(|(current, _)| at < current) {
best = Some((at, marker.len()));
}
}
}
best
}
fn split_names(raw: &str) -> Vec<String> {
raw.split([';', '/'])
.flat_map(|part| part.split(" & "))
.map(|name| name.trim().trim_matches(',').trim().to_string())
.filter(|name| !name.is_empty())
.collect()
}
/// Extract "(feat. X)" / "[ft. Y]" from a track title.
fn extract_title_feat(title: &str) -> Option<(String, Vec<String>)> {
let lowered = title.to_lowercase();
for marker in ["(feat.", "(feat ", "(ft.", "[feat.", "[ft."] {
if let Some(start) = lowered.find(marker) {
let closer = if marker.starts_with('(') { ')' } else { ']' };
let rest = &title[start + marker.len()..];
let end = rest.find(closer)?;
let names = split_names(&rest[..end]);
if names.is_empty() {
return None;
}
let mut clean = title[..start].trim_end().to_string();
clean.push_str(rest[end + 1..].trim_end());
return Some((clean.trim().to_string(), names));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal valid WAV file: 0.1s of silence at 8kHz mono 16-bit.
fn write_test_wav(path: &Path) {
let samples: u32 = 800;
let data_len = samples * 2;
let mut bytes = Vec::new();
bytes.extend_from_slice(b"RIFF");
bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
bytes.extend_from_slice(b"WAVEfmt ");
bytes.extend_from_slice(&16u32.to_le_bytes());
bytes.extend_from_slice(&1u16.to_le_bytes()); // PCM
bytes.extend_from_slice(&1u16.to_le_bytes()); // mono
bytes.extend_from_slice(&8000u32.to_le_bytes()); // sample rate
bytes.extend_from_slice(&16000u32.to_le_bytes()); // byte rate
bytes.extend_from_slice(&2u16.to_le_bytes()); // block align
bytes.extend_from_slice(&16u16.to_le_bytes()); // bits per sample
bytes.extend_from_slice(b"data");
bytes.extend_from_slice(&data_len.to_le_bytes());
bytes.resize(bytes.len() + data_len as usize, 0);
std::fs::write(path, bytes).unwrap();
}
#[test]
fn imports_a_real_audio_file_end_to_end() {
let dir = std::env::temp_dir().join(format!("furumi-import-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let wav = dir.join("My Song.wav");
write_test_wav(&wav);
let db = dir.join("library.db");
let library = Library::open(&db).unwrap();
let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap();
assert_eq!(outcome.added, 1);
assert!(outcome.failed.is_empty());
// Untagged files fall back to the file name and placeholder names.
let results = library.search("My Song", 10).unwrap();
assert_eq!(results.tracks.len(), 1);
let track = &results.tracks[0];
assert_eq!(track.title, "My Song");
assert_eq!(track.artists[0].name, "Unknown Artist");
assert_eq!(track.release_title, "Unknown Album");
assert!(track.duration_seconds > 0.05);
assert_eq!(track.audio_sample_rate, Some(8000));
assert!(std::fs::File::open(&track.file_path).is_ok());
// Re-importing the same directory only updates.
let outcome = import_path(&library, &dir, |_, _, _| {}).unwrap();
assert_eq!((outcome.added, outcome.updated), (0, 1));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn splits_plain_artist() {
let (main, feat) = split_artist_tag("Daft Punk");
assert_eq!(main, vec!["Daft Punk"]);
assert!(feat.is_empty());
}
#[test]
fn splits_multiple_and_featured() {
let (main, feat) = split_artist_tag("A; B feat. C & D");
assert_eq!(main, vec!["A", "B"]);
assert_eq!(feat, vec!["C", "D"]);
}
#[test]
fn keeps_commas_inside_names() {
let (main, _) = split_artist_tag("Tyler, The Creator");
assert_eq!(main, vec!["Tyler, The Creator"]);
}
#[test]
fn extracts_feat_from_title() {
let (title, names) = extract_title_feat("Song (feat. X & Y)").unwrap();
assert_eq!(title, "Song");
assert_eq!(names, vec!["X", "Y"]);
assert!(extract_title_feat("Plain Song").is_none());
}
}
+2 -3553
View File
File diff suppressed because it is too large Load Diff
-255
View File
@@ -1,255 +0,0 @@
//! Data shapes the views render. They mirror what the furumusic API used to
//! return, but every field is now filled from the local SQLite library.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Availability {
#[default]
Local,
Mixed,
Remote,
}
impl Availability {
pub fn is_remoteish(self) -> bool {
matches!(self, Availability::Mixed | Availability::Remote)
}
}
#[derive(Debug, Clone)]
pub struct ArtistCard {
pub id: i64,
pub name: String,
/// Path to a local image file, if one is set for the artist.
pub image_path: Option<String>,
pub release_count: i64,
pub track_count: i64,
pub availability: Availability,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtistRef {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone)]
pub struct TrackItem {
pub id: i64,
pub title: String,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: f64,
pub artists: Vec<ArtistRef>,
pub featured_artists: Vec<ArtistRef>,
pub release_id: i64,
pub release_title: String,
pub release_year: Option<i32>,
/// Absolute path to the local audio file.
pub file_path: String,
/// Stable audio content id (`b3:<64 hex>`) when known.
pub content_id: Option<String>,
/// Path to a local cover image (the release cover).
pub cover_path: Option<String>,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
pub file_size_bytes: Option<i64>,
/// Completed local plays, from the history table.
pub play_count: i64,
/// Set for federated tracks that are not in the local library (yet):
/// carries everything needed to download them from the owning peer.
/// With an empty `file_path` the player resolves the track on demand.
pub fed: Option<crate::federation::FedTrack>,
}
impl TrackItem {
/// A federated track that still needs downloading before playback.
pub fn is_fed_pending(&self) -> bool {
self.fed.is_some() && self.file_path.is_empty()
}
pub fn artist_line(&self) -> String {
let artists = self
.artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let featured = self
.featured_artists
.iter()
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
match (artists.is_empty(), featured.is_empty()) {
(false, false) => format!("{artists} feat. {featured}"),
(false, true) => artists,
(true, false) => format!("feat. {featured}"),
(true, true) => String::new(),
}
}
pub fn duration_label(&self) -> String {
let total = self.duration_seconds.round() as i64;
format!("{}:{:02}", total / 60, total % 60)
}
/// Full tech line for the status bar, including the sample rate.
pub fn tech_label_full(&self) -> String {
let mut parts = Vec::new();
if let Some(format) = &self.audio_format {
parts.push(format.to_uppercase());
}
if let Some(bitrate) = self.audio_bitrate {
parts.push(format!("{bitrate}kbps"));
}
if let Some(rate) = self.audio_sample_rate {
parts.push(format!("{:.1}kHz", f64::from(rate) / 1000.0));
}
if let Some(bytes) = self.file_size_bytes {
parts.push(format!("{:.1}MB", bytes as f64 / 1_048_576.0));
}
parts.join(" · ")
}
}
#[derive(Debug, Clone)]
pub struct ReleaseCard {
pub id: i64,
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub cover_path: Option<String>,
pub track_count: i64,
pub availability: Availability,
}
#[derive(Debug)]
pub struct ArtistDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub name: String,
pub image_path: Option<String>,
pub total_track_count: i64,
pub total_play_count: i64,
pub top_tracks: Vec<TrackItem>,
pub releases: Vec<ReleaseCard>,
/// Tracks where this artist is featured (the only content for artists
/// without own releases).
pub featured_tracks: Vec<TrackItem>,
}
#[derive(Debug)]
pub struct ReleaseDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub cover_path: Option<String>,
pub artists: Vec<ArtistRef>,
pub tracks: Vec<TrackItem>,
}
#[derive(Debug, Clone)]
pub struct PlaylistCard {
pub id: i64,
pub title: String,
pub track_count: i64,
/// "normal" for user playlists, "likes" for the virtual Likes playlist.
pub kind: String,
}
#[derive(Debug)]
pub struct PlaylistDetail {
#[allow(dead_code, reason = "cache key is held by the caller")]
pub id: i64,
pub title: String,
#[allow(dead_code, reason = "shown in a detail header later")]
pub description: Option<String>,
pub tracks: Vec<TrackItem>,
}
#[derive(Debug, Default)]
pub struct SearchResults {
pub artists: Vec<ArtistCard>,
pub releases: Vec<ReleaseCard>,
pub tracks: Vec<TrackItem>,
}
impl SearchResults {
pub fn len(&self) -> usize {
self.artists.len() + self.releases.len() + self.tracks.len()
}
}
#[derive(Debug)]
pub struct ArtistsPage {
pub items: Vec<ArtistCard>,
pub total: i64,
pub page: i64,
pub has_more: bool,
}
/// Edited values submitted from the track edit form. `None` numbers clear
/// the column.
#[derive(Debug, Clone)]
pub struct TrackEdit {
pub title: String,
pub artists: Vec<String>,
pub featured_artists: Vec<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
/// Cover image path; the cover lives on the track's release (the same
/// image every view shows for the track). None clears it.
pub cover_path: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ReleaseEdit {
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub artists: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
fn artist(name: &str) -> ArtistRef {
ArtistRef {
id: 1,
name: name.to_string(),
}
}
#[test]
fn artist_line_formats_featured_artists() {
let track = TrackItem {
id: 1,
title: "Track".into(),
track_number: None,
disc_number: None,
duration_seconds: 1.0,
artists: vec![artist("Main")],
featured_artists: vec![artist("Guest"), artist("Other")],
release_id: 1,
release_title: "Release".into(),
release_year: None,
file_path: "/tmp/track.mp3".into(),
content_id: None,
cover_path: None,
audio_format: None,
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: None,
play_count: 0,
fed: None,
};
assert_eq!(track.artist_line(), "Main feat. Guest, Other");
}
}
-786
View File
@@ -1,786 +0,0 @@
use super::*;
fn test_library() -> Library {
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
register_norm_function(&conn).unwrap();
conn.execute_batch(SCHEMA).unwrap();
Library {
conn: Mutex::new(conn),
db_path: std::env::temp_dir().join("furumi-test-library.db"),
covers_dir: std::env::temp_dir().join("furumi-test-covers-unused"),
}
}
fn add_track(lib: &Library, title: &str, artist: &str, album: &str) -> i64 {
add_track_with_featured(lib, title, artist, &[], album)
}
fn add_track_with_featured(
lib: &Library,
title: &str,
artist: &str,
featured: &[&str],
album: &str,
) -> i64 {
let import = import::TrackImport {
release_type: None,
file_path: format!("/music/{artist}/{album}/{title}.mp3"),
title: title.to_string(),
artists: vec![artist.to_string()],
featured_artists: featured.iter().map(|name| (*name).to_string()).collect(),
album_artists: vec![artist.to_string()],
release_title: album.to_string(),
year: Some(2020),
track_number: None,
disc_number: None,
duration_seconds: 60.0,
audio_format: Some("mp3".into()),
audio_bitrate: Some(320),
audio_sample_rate: Some(44100),
audio_bit_depth: None,
file_size_bytes: Some(1),
cover: None,
};
let id = import::upsert_track(lib, &import).unwrap().0;
let content_id = format!("b3:{}", blake3::hash(import.file_path.as_bytes()).to_hex());
lib.lock()
.execute(
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
params![id, content_id],
)
.unwrap();
id
}
fn artist_filters(hide_featured_only: bool) -> crate::config::settings::LibraryFilters {
crate::config::settings::LibraryFilters {
hide_featured_only,
..Default::default()
}
}
fn unique_test_dir(label: &str) -> std::path::PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("furumi-{label}-{}-{unique}", std::process::id()))
}
#[test]
fn managed_music_relocation_builds_artist_release_tree_and_updates_paths() {
let root = unique_test_dir("music-relocation");
let old = root.join("old");
let new = root.join("new");
let covers = root.join("covers");
std::fs::create_dir_all(&old).unwrap();
std::fs::create_dir_all(&covers).unwrap();
let audio = old.join("legacy.flac");
let cover = covers.join("release.jpg");
let artist_image = covers.join("artist.png");
std::fs::write(&audio, b"audio").unwrap();
std::fs::write(&cover, b"cover").unwrap();
std::fs::write(&artist_image, b"artist").unwrap();
let conn = Connection::open_in_memory().unwrap();
conn.pragma_update(None, "foreign_keys", "ON").unwrap();
register_norm_function(&conn).unwrap();
conn.execute_batch(SCHEMA).unwrap();
let library = Library {
conn: Mutex::new(conn),
db_path: root.join("library.db"),
covers_dir: covers,
};
let track_id = import::upsert_track(
&library,
&import::TrackImport {
file_path: audio.to_string_lossy().into_owned(),
title: "Song".into(),
artists: vec!["Artist".into()],
featured_artists: vec![],
album_artists: vec!["Artist".into()],
release_title: "Release".into(),
release_type: Some("album".into()),
year: Some(2026),
track_number: Some(1),
disc_number: Some(1),
duration_seconds: 1.0,
audio_format: Some("flac".into()),
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: Some(5),
cover: None,
},
)
.unwrap()
.0;
let (release_id, artist_id): (i64, i64) = library
.lock()
.query_row(
"SELECT t.release_id, ta.artist_id
FROM tracks t JOIN track_artists ta ON ta.track_id = t.id
WHERE t.id = ?1 AND ta.role = 'main'",
[track_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
library
.lock()
.execute(
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
params![release_id, cover.to_string_lossy()],
)
.unwrap();
library
.lock()
.execute(
"UPDATE artists SET image_path = ?2 WHERE id = ?1",
params![artist_id, artist_image.to_string_lossy()],
)
.unwrap();
let stats = library.relocate_managed_music(&old, &new).unwrap();
assert_eq!(stats.tracks, 1);
assert_eq!(stats.images, 2);
let track = library.tracks_by_ids(&[track_id]).unwrap().remove(0);
assert_eq!(
track.file_path,
new.join("Artist/Release/legacy.flac").to_string_lossy()
);
assert_eq!(
track.cover_path.as_deref(),
Some(
new.join("Artist/Release/cover.jpg")
.to_string_lossy()
.as_ref()
)
);
let image: String = library
.lock()
.query_row(
"SELECT image_path FROM artists WHERE id = ?1",
[artist_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(image, new.join("Artist/artist.png").to_string_lossy());
assert!(!audio.exists());
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn music_directory_validation_rejects_a_file_without_touching_it() {
let root = unique_test_dir("music-validation");
std::fs::create_dir_all(&root).unwrap();
let file = root.join("not-a-directory");
std::fs::write(&file, b"keep").unwrap();
assert!(Library::validate_music_directory(&file).is_err());
assert_eq!(std::fs::read(&file).unwrap(), b"keep");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn managed_music_names_are_portable_to_windows() {
assert_eq!(storage_name("Artist/Name", "fallback"), "Artist_Name");
assert_eq!(storage_name("CON", "fallback"), "_CON");
assert_eq!(storage_name("lpt9.live", "fallback"), "_lpt9.live");
assert_eq!(storage_name("...", "fallback"), "fallback");
}
#[test]
fn local_stats_counts_library_rows_and_audio_bytes() {
let lib = test_library();
add_track(&lib, "One", "Artist", "First");
add_track(&lib, "Two", "Artist", "Second");
let stats = lib.local_stats().unwrap();
assert_eq!(stats.artist_count, 1);
assert_eq!(stats.release_count, 2);
assert_eq!(stats.track_count, 2);
assert_eq!(stats.audio_bytes, 2);
assert_eq!(stats.tracks_without_size, 0);
}
#[test]
fn artists_page_prioritizes_releases_then_tracks() {
let lib = test_library();
add_track(&lib, "Solo", "Zed", "Zed Album");
add_track_with_featured(&lib, "Guest One", "A Host", &["Guest"], "A Host Album");
add_track_with_featured(&lib, "Guest Two", "B Host", &["Guest"], "B Host Album");
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
let zed_pos = page
.items
.iter()
.position(|artist| artist.name == "Zed")
.unwrap();
let guest_pos = page
.items
.iter()
.position(|artist| artist.name == "Guest")
.unwrap();
let guest = &page.items[guest_pos];
assert_eq!(guest.release_count, 0);
assert_eq!(guest.track_count, 2);
assert!(zed_pos < guest_pos);
let filtered = lib.artists(1, 10, artist_filters(true)).unwrap();
assert!(filtered.items.iter().all(|artist| artist.release_count > 0));
assert!(!filtered.items.iter().any(|artist| artist.name == "Guest"));
}
#[test]
fn network_artist_image_hint_becomes_local_image_after_fetch() {
let lib = test_library();
let artist_key = music_dht::normalize_name("Remote Artist");
lib.replace_network_artist_cache(
"peer-a",
"personal",
&[NetworkArtistPreview {
artist_key: artist_key.clone(),
name: "Remote Artist".into(),
image_path: Some("peer-local/image.jpg".into()),
release_count: 1,
track_count: 3,
}],
true,
)
.unwrap();
let filters = crate::config::settings::LibraryFilters {
source_mode: crate::config::settings::LibrarySourceMode::My,
..Default::default()
};
let page = lib.artists(1, 10, filters).unwrap();
assert_eq!(page.items[0].image_path, None);
let requests = lib
.network_artist_image_requests(filters, &["Remote Artist".into()], 8)
.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].source_id, "peer-a");
assert_eq!(requests[0].artist_key, artist_key);
lib.set_network_artist_image("peer-a", &artist_key, "/tmp/remote-artist.jpg")
.unwrap();
let page = lib.artists(1, 10, filters).unwrap();
assert_eq!(
page.items[0].image_path.as_deref(),
Some("/tmp/remote-artist.jpg")
);
}
#[test]
fn import_creates_artist_release_track() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
assert_eq!(page.total, 1);
assert_eq!(page.items[0].name, "Artist");
assert_eq!(page.items[0].track_count, 1);
let detail = lib.artist(page.items[0].id).unwrap();
assert_eq!(detail.releases.len(), 1);
assert_eq!(detail.top_tracks.len(), 1);
let release = lib.release(detail.releases[0].id).unwrap();
assert_eq!(release.tracks.len(), 1);
assert_eq!(release.tracks[0].id, track_id);
assert_eq!(release.tracks[0].artists[0].name, "Artist");
}
#[test]
fn reimport_updates_instead_of_duplicating() {
let lib = test_library();
let first = add_track(&lib, "Song", "Artist", "Album");
let second = add_track(&lib, "Song", "Artist", "Album");
assert_eq!(first, second);
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
assert_eq!(page.items[0].track_count, 1);
}
#[test]
fn content_id_backfill_hashes_missing_track_ids() {
let lib = test_library();
let path = std::env::temp_dir().join(format!(
"furumi-content-id-test-{}-{}.bin",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, b"portable content id").unwrap();
let file_path = path.to_string_lossy().into_owned();
let import = import::TrackImport {
release_type: None,
file_path: file_path.clone(),
title: "Portable".to_string(),
artists: vec!["Artist".to_string()],
featured_artists: Vec::new(),
album_artists: vec!["Artist".to_string()],
release_title: "Album".to_string(),
year: Some(2026),
track_number: None,
disc_number: None,
duration_seconds: 60.0,
audio_format: Some("bin".into()),
audio_bitrate: None,
audio_sample_rate: None,
audio_bit_depth: None,
file_size_bytes: Some(19),
cover: None,
};
let track_id = import::upsert_track(&lib, &import).unwrap().0;
let expected = audio_content_id(&file_path).unwrap();
{
let conn = lib.lock();
conn.execute(
"UPDATE tracks SET content_id = NULL WHERE id = ?1",
[track_id],
)
.unwrap();
}
let stats = lib.backfill_missing_content_ids().unwrap();
assert_eq!(stats.hashed, 1);
assert_eq!(stats.updated(), 1);
assert_eq!(
lib.track_content_id_by_id(track_id).unwrap().as_deref(),
Some(expected.as_str())
);
let _ = std::fs::remove_file(path);
}
#[test]
fn search_finds_all_kinds() {
let lib = test_library();
add_track(&lib, "Neon Lights", "Neon Artist", "Neon Album");
let results = lib.search("neon", 10).unwrap();
assert_eq!(results.artists.len(), 1);
assert_eq!(results.releases.len(), 1);
assert_eq!(results.tracks.len(), 1);
// LIKE wildcards in the query must not match everything.
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
}
#[test]
fn search_ranks_exact_names_first() {
let lib = test_library();
add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album");
add_track(&lib, "Needle", "Needle", "Needle");
let results = lib.search("needle", 10).unwrap();
assert_eq!(results.artists[0].name, "Needle");
assert_eq!(results.releases[0].title, "Needle");
assert_eq!(results.tracks[0].title, "Needle");
}
#[test]
fn search_folds_case_beyond_ascii() {
let lib = test_library();
add_track(&lib, "Nothing Else Matters", "Металлика", "Чёрный альбом");
// SQLite's LIKE/NOCASE only fold ASCII; norm() folds every script.
assert_eq!(lib.search("металлика", 10).unwrap().artists.len(), 1);
assert_eq!(lib.search("МЕТАЛЛИКА", 10).unwrap().artists.len(), 1);
assert_eq!(lib.search("чёрный", 10).unwrap().releases.len(), 1);
assert_eq!(lib.search("matters", 10).unwrap().tracks.len(), 1);
}
#[test]
fn playlists_and_likes_round_trip() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
let playlist = lib.create_playlist("Mix").unwrap();
lib.add_tracks_to_playlist(playlist.id, &[track_id])
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
let content_id = lib.track_content_id_by_id(track_id).unwrap().unwrap();
assert!(lib.toggle_like_by_content_id(&content_id).unwrap());
assert_eq!(lib.liked_content_ids().unwrap(), vec![content_id.clone()]);
assert_eq!(lib.playlist(LIKES_PLAYLIST_ID).unwrap().tracks.len(), 1);
assert!(!lib.toggle_like_by_content_id(&content_id).unwrap());
lib.remove_tracks_from_playlist(playlist.id, &[track_id])
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
lib.delete_playlist(playlist.id).unwrap();
// Only the virtual Likes playlist remains.
assert_eq!(lib.playlists().unwrap().len(), 1);
}
#[test]
fn likes_playlist_orders_local_and_federated_by_liked_at() {
let lib = test_library();
let old_id = add_track(&lib, "Old Local", "Artist", "Album");
let new_id = add_track(&lib, "New Local", "Artist", "Album");
let old_content_id = lib.track_content_id_by_id(old_id).unwrap().unwrap();
let new_content_id = lib.track_content_id_by_id(new_id).unwrap().unwrap();
let content_id = format!("b3:{}", "c".repeat(64));
let fed = crate::federation::FedTrack {
item_id: "fed_item_order".to_string(),
owner: "fed_owner_order".to_string(),
own: false,
title: "Middle Fed".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: Vec::new(),
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id),
release_title: Some("Remote Release".to_string()),
track_number: Some(1),
disc_number: Some(1),
};
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
assert!(lib.toggle_like_by_content_id(&new_content_id).unwrap());
assert!(lib.toggle_fed_like(&fed).unwrap());
{
let conn = lib.lock();
conn.execute(
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
params![old_id, "2026-01-01 00:00:00"],
)
.unwrap();
conn.execute(
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
params![new_id, "2026-01-02 00:00:00"],
)
.unwrap();
conn.execute(
"UPDATE fed_likes SET liked_at = ?2 WHERE item_id = ?1",
params![fed.item_id, "2026-01-03 00:00:00"],
)
.unwrap();
}
let titles: Vec<String> = lib
.playlist(LIKES_PLAYLIST_ID)
.unwrap()
.tracks
.into_iter()
.map(|track| track.title)
.collect();
assert_eq!(titles, vec!["Middle Fed", "New Local", "Old Local"]);
assert!(!lib.toggle_like_by_content_id(&old_content_id).unwrap());
assert!(lib.toggle_like_by_content_id(&old_content_id).unwrap());
{
let conn = lib.lock();
conn.execute(
"UPDATE likes SET liked_at = ?2 WHERE track_id = ?1",
params![old_id, "2026-01-04 00:00:00"],
)
.unwrap();
}
let titles: Vec<String> = lib
.playlist(LIKES_PLAYLIST_ID)
.unwrap()
.tracks
.into_iter()
.map(|track| track.title)
.collect();
assert_eq!(titles, vec!["Old Local", "Middle Fed", "New Local"]);
}
#[test]
fn synced_playlist_can_show_federated_pending_tracks() {
let lib = test_library();
let playlist = lib.create_playlist("Remote Mix").unwrap();
let sync_id = lib.ensure_playlist_sync_id(playlist.id).unwrap();
let content_id = format!("b3:{}", "a".repeat(64));
let fed = crate::federation::FedTrack {
item_id: "fed_item_1".to_string(),
owner: "fed_owner_1".to_string(),
own: false,
title: "Remote Song".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: vec!["Remote Guest".to_string()],
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id.clone()),
release_title: Some("Remote Release".to_string()),
track_number: Some(2),
disc_number: Some(1),
};
assert!(lib.upsert_fed_playlist_track(&sync_id, &fed, 4).unwrap());
assert!(
lib.has_playlist_content_reference(&sync_id, &content_id)
.unwrap()
);
let detail = lib.playlist(playlist.id).unwrap();
assert_eq!(detail.tracks.len(), 1);
let track = &detail.tracks[0];
assert!(track.is_fed_pending());
assert_eq!(track.title, "Remote Song");
assert_eq!(track.artist_line(), "Remote Artist feat. Remote Guest");
assert_eq!(track.release_title, "Remote Release");
assert_eq!(track.content_id.as_deref(), Some(content_id.as_str()));
let card = lib
.playlists()
.unwrap()
.into_iter()
.find(|card| card.id == playlist.id)
.unwrap();
assert_eq!(card.track_count, 1);
lib.remove_content_ids_from_playlist(playlist.id, std::slice::from_ref(&content_id))
.unwrap();
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 0);
assert!(
lib.fed_playlist_track_by_content_id(&sync_id, &content_id)
.unwrap()
.is_none()
);
}
#[test]
fn add_federated_pending_track_to_playlist_records_position() {
let lib = test_library();
let local_id = add_track(&lib, "Local Song", "Artist", "Album");
let playlist = lib.create_playlist("Remote Mix").unwrap();
let content_id = format!("b3:{}", "b".repeat(64));
let fed = crate::federation::FedTrack {
item_id: "fed_item_2".to_string(),
owner: "fed_owner_2".to_string(),
own: false,
title: "Remote Song".to_string(),
artist_names: vec!["Remote Artist".to_string()],
featured_artist_names: Vec::new(),
year: Some(2026),
duration_seconds: Some(123),
content_id: Some(content_id.clone()),
release_title: Some("Remote Release".to_string()),
track_number: Some(2),
disc_number: Some(1),
};
lib.add_tracks_to_playlist(playlist.id, &[local_id])
.unwrap();
lib.add_fed_tracks_to_playlist(playlist.id, std::slice::from_ref(&fed))
.unwrap();
let position = lib
.playlist_content_position(playlist.id, &content_id)
.unwrap();
assert_eq!(position, Some(1));
let detail = lib.playlist(playlist.id).unwrap();
assert_eq!(
detail
.tracks
.into_iter()
.map(|track| track.title)
.collect::<Vec<_>>(),
vec!["Local Song", "Remote Song"]
);
}
#[test]
fn track_edit_relinks_artists() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.update_track(
track_id,
&TrackEdit {
title: "Renamed".into(),
artists: vec!["Other".into()],
featured_artists: vec!["Guest".into()],
track_number: Some(2),
disc_number: None,
cover_path: None,
},
)
.unwrap();
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
assert_eq!(track.title, "Renamed");
assert_eq!(track.artists[0].name, "Other");
assert_eq!(track.featured_artists[0].name, "Guest");
assert_eq!(track.track_number, Some(2));
}
#[test]
fn deleting_artist_cleans_up_own_content() {
let lib = test_library();
add_track(&lib, "Song", "Solo", "Solo Album");
let page = lib.artists(1, 10, artist_filters(false)).unwrap();
lib.delete_artist(page.items[0].id).unwrap();
assert_eq!(lib.artists(1, 10, artist_filters(false)).unwrap().total, 0);
assert_eq!(lib.search("Song", 10).unwrap().len(), 0);
}
#[test]
fn delete_track_drops_empty_release() {
let lib = test_library();
let track_id = add_track(&lib, "Only", "Artist", "Album");
lib.delete_track(track_id).unwrap();
let detail = lib
.artist(lib.artists(1, 10, artist_filters(false)).unwrap().items[0].id)
.unwrap();
assert!(detail.releases.is_empty());
}
#[test]
fn history_counts_completed_plays() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
let content_id = lib
.tracks_by_ids(&[track_id])
.unwrap()
.remove(0)
.content_id
.unwrap();
let event = music_dht::device_sync::ListenEvent {
listen_id: "listen-1".to_string(),
content_id,
started_at_ms: 1_700_000_000_000,
listened_ms: 60_000,
track_duration_ms: Some(60_000),
ended_reason: music_dht::device_sync::ListenEndReason::Finished,
track: music_dht::device_sync::ListenTrackMetadata {
title: "Song".to_string(),
artist_names: vec!["Artist".to_string()],
featured_artist_names: Vec::new(),
release_title: Some("Album".to_string()),
},
};
assert!(lib.apply_listen_event(&event, "device-a").unwrap());
assert!(!lib.apply_listen_event(&event, "device-a").unwrap());
let track = lib.tracks_by_ids(&[track_id]).unwrap().remove(0);
assert_eq!(track.play_count, 1);
let history = lib.listen_history(20).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].listen_id, "listen-1");
assert_eq!(history[0].title, "Song");
assert_eq!(history[0].artist, "Artist");
assert_eq!(history[0].origin_device_id, "device-a");
}
#[test]
fn listen_history_hides_unqualified_events_and_keeps_remote_metadata() {
let lib = test_library();
let event = music_dht::device_sync::ListenEvent {
listen_id: "remote-listen".to_string(),
content_id: format!("b3:{}", "a".repeat(64)),
started_at_ms: 1_700_000_000_000,
listened_ms: 10_000,
track_duration_ms: Some(120_000),
ended_reason: music_dht::device_sync::ListenEndReason::Skipped,
track: music_dht::device_sync::ListenTrackMetadata {
title: "Remote song".to_string(),
artist_names: vec!["Remote artist".to_string()],
featured_artist_names: vec!["Guest".to_string()],
release_title: None,
},
};
assert!(lib.apply_listen_event(&event, "remote-device").unwrap());
assert!(lib.listen_history(20).unwrap().is_empty());
}
#[test]
fn similarity_embeddings_round_trip_and_keep_profiles_separate() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
for profile in ["profile-a", "profile-b"] {
lib.ensure_similarity_profile(profile, "model", "1", "sha", "prep", 3)
.unwrap();
}
let track = lib
.pending_similarity_tracks("profile-a")
.unwrap()
.into_iter()
.find(|track| track.id == track_id)
.unwrap();
let first = [0.26726124, 0.5345225, 0.8017837];
let second = [0.8017837, 0.5345225, 0.26726124];
lib.store_similarity_embedding(&track, "profile-a", &first)
.unwrap();
lib.store_similarity_embedding(&track, "profile-b", &second)
.unwrap();
assert_eq!(
lib.similarity_embedding(track_id, "profile-a").unwrap(),
Some(first.to_vec())
);
assert_eq!(
lib.similarity_embedding(track_id, "profile-b").unwrap(),
Some(second.to_vec())
);
let stats = lib.similarity_storage_stats("profile-a").unwrap();
assert_eq!(stats.total_tracks, 1);
assert_eq!(stats.embedded_tracks, 1);
assert_eq!(stats.stored_vectors, 2);
assert_eq!(stats.stored_bytes, 24);
lib.lock()
.execute(
"UPDATE track_embeddings SET routing_signature = NULL WHERE profile_id = 'profile-a'",
[],
)
.unwrap();
assert_eq!(
lib.similarity_routing_signatures("profile-a")
.unwrap()
.len(),
1
);
let stored_signature_bytes: i64 = lib
.lock()
.query_row(
"SELECT length(routing_signature) FROM track_embeddings WHERE profile_id = 'profile-a'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stored_signature_bytes, 32);
}
#[test]
fn changed_content_id_invalidates_only_the_stale_embedding() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
.unwrap();
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
.unwrap();
lib.lock()
.execute(
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
params![track_id, format!("b3:{}", "f".repeat(64))],
)
.unwrap();
assert_eq!(lib.pending_similarity_tracks("profile").unwrap().len(), 1);
assert_eq!(lib.similarity_embedding(track_id, "profile").unwrap(), None);
assert!(lib.load_similarity_index("profile").unwrap().is_empty());
}
#[test]
fn clearing_embeddings_preserves_the_library() {
let lib = test_library();
let track_id = add_track(&lib, "Song", "Artist", "Album");
lib.ensure_similarity_profile("profile", "model", "1", "sha", "prep", 2)
.unwrap();
let track = lib.pending_similarity_tracks("profile").unwrap().remove(0);
lib.store_similarity_embedding(&track, "profile", &[0.6, 0.8])
.unwrap();
lib.clear_similarity_embeddings().unwrap();
assert_eq!(lib.tracks_by_ids(&[track_id]).unwrap().len(), 1);
assert!(
lib.similarity_embedding(track_id, "profile")
.unwrap()
.is_none()
);
}
+1
View File
@@ -12,6 +12,7 @@ mod similarity;
mod status; mod status;
mod streaming; mod streaming;
mod ui; mod ui;
mod updater;
mod visualizer; mod visualizer;
use std::io; use std::io;
+43 -24
View File
@@ -5,13 +5,14 @@
//! the UI or app state. //! the UI or app state.
mod analyzer; mod analyzer;
mod opus;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender}; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration; use std::time::Duration;
use rodio::{Decoder, DeviceSinkBuilder, Player, stream::MixerDeviceSink}; use rodio::{Decoder, DeviceSinkBuilder, Player, Source, stream::MixerDeviceSink};
pub use analyzer::AudioAnalysisSnapshot; pub use analyzer::AudioAnalysisSnapshot;
@@ -226,23 +227,13 @@ fn handle(
} }
let out = output.as_ref().expect("output opened above"); let out = output.as_ref().expect("output opened above");
let mut builder = Decoder::builder() match decode_source(reader, byte_len, mime_type.as_deref(), seekable) {
.with_data(reader) Ok(source) => {
.with_seekable(seekable)
.with_gapless(true);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
if let Some(mime_type) = mime_type.as_deref() {
builder = builder.with_mime_type(mime_type);
}
match builder.build() {
Ok(decoder) => {
shared.analysis.clear(); shared.analysis.clear();
out.player.stop(); out.player.stop();
out.player.set_volume(volume); out.player.set_volume(volume);
out.player.append(analyzer::AnalyzedSource::new( out.player.append(analyzer::AnalyzedSource::new(
decoder, source,
Arc::clone(&shared.analysis), Arc::clone(&shared.analysis),
)); ));
out.player.play(); out.player.play();
@@ -258,16 +249,9 @@ fn handle(
let Some(out) = output.as_ref() else { let Some(out) = output.as_ref() else {
return; return;
}; };
let mut builder = Decoder::builder() match decode_source(reader, byte_len, None, true) {
.with_data(reader) Ok(source) => out.player.append(analyzer::AnalyzedSource::new(
.with_seekable(true) source,
.with_gapless(true);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
match builder.build() {
Ok(decoder) => out.player.append(analyzer::AnalyzedSource::new(
decoder,
Arc::clone(&shared.analysis), Arc::clone(&shared.analysis),
)), )),
Err(err) => { Err(err) => {
@@ -309,3 +293,38 @@ fn handle(
} }
} }
} }
pub(crate) type DecodedSource = Box<dyn Source + Send>;
pub(crate) fn decode_source(
mut reader: TrackReader,
byte_len: Option<u64>,
mime_type: Option<&str>,
seekable: bool,
) -> Result<DecodedSource, String> {
let mime_is_opus = mime_type.is_some_and(|mime| {
let mime = mime.to_ascii_lowercase();
mime == "audio/opus" || mime.contains("codecs=opus") || mime.contains("codecs=\"opus\"")
});
let ogg_is_opus = opus::is_ogg_opus(&mut reader)
.map_err(|error| format!("cannot inspect audio stream: {error}"))?;
if mime_is_opus || ogg_is_opus {
return opus::OggOpusSource::new(reader, byte_len, seekable)
.map(|source| Box::new(source) as DecodedSource);
}
let mut builder = Decoder::builder()
.with_data(reader)
.with_seekable(seekable)
.with_gapless(true);
if let Some(len) = byte_len {
builder = builder.with_byte_len(len);
}
if let Some(mime_type) = mime_type {
builder = builder.with_mime_type(mime_type);
}
builder
.build()
.map(|decoder| Box::new(decoder) as DecodedSource)
.map_err(|error| error.to_string())
}
+433
View File
@@ -0,0 +1,433 @@
//! Ogg/Opus decoding for rodio.
//!
//! Rodio 0.22 can demux Ogg and decode Vorbis, but its Symphonia version does
//! not include an Opus decoder. This source keeps Symphonia's mature Ogg
//! demuxing and feeds the packets into the pure-Rust `rusty-opus` decoder.
use std::io::{self, Read, Seek, SeekFrom};
use std::sync::Arc;
use std::time::Duration;
use rodio::source::SeekError;
use rodio::{ChannelCount, SampleRate, Source};
use rusty_opus::OpusDecoder;
use symphonia::core::codecs::{CODEC_TYPE_OPUS, CodecParameters};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
use symphonia::core::io::{MediaSource, MediaSourceStream};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use super::TrackReader;
const OPUS_SAMPLE_RATE: u32 = 48_000;
const OPUS_SEEK_PREROLL_FRAMES: u64 = OPUS_SAMPLE_RATE as u64 * 80 / 1_000;
const SNIFF_BYTES: usize = 512;
/// Inspect the Ogg identification page without changing the reader position.
pub(super) fn is_ogg_opus(reader: &mut TrackReader) -> io::Result<bool> {
let position = reader.stream_position()?;
let mut header = [0; SNIFF_BYTES];
let read_result = reader.read(&mut header);
let rewind_result = reader.seek(SeekFrom::Start(position));
let read = read_result?;
rewind_result?;
Ok(header[..read].starts_with(b"OggS")
&& header[..read]
.windows(b"OpusHead".len())
.any(|window| window == b"OpusHead"))
}
struct ReaderMediaSource {
reader: TrackReader,
byte_len: Option<u64>,
seekable: bool,
}
impl Read for ReaderMediaSource {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
self.reader.read(buffer)
}
}
impl Seek for ReaderMediaSource {
fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
self.reader.seek(position)
}
}
impl MediaSource for ReaderMediaSource {
fn is_seekable(&self) -> bool {
self.seekable
}
fn byte_len(&self) -> Option<u64> {
self.byte_len
}
}
pub(super) struct OggOpusSource {
format: Box<dyn FormatReader>,
decoder: OpusDecoder,
track_id: u32,
channels: u16,
pre_skip: u64,
playable_frames: Option<u64>,
output_position_frames: u64,
discard_frames: u64,
output_gain: f32,
buffer: Vec<f32>,
scratch: Vec<f32>,
buffer_position: usize,
seekable: bool,
done: bool,
}
impl OggOpusSource {
pub(super) fn new(
reader: TrackReader,
byte_len: Option<u64>,
seekable: bool,
) -> Result<Self, String> {
let source = ReaderMediaSource {
reader,
byte_len,
seekable,
};
let stream = MediaSourceStream::new(Box::new(source), Default::default());
let mut hint = Hint::new();
hint.with_extension("ogg");
hint.mime_type("audio/ogg");
let format_options = FormatOptions {
enable_gapless: false,
..Default::default()
};
let probed = symphonia::default::get_probe()
.format(&hint, stream, &format_options, &MetadataOptions::default())
.map_err(|error| format!("cannot read Ogg container: {error}"))?;
let format = probed.format;
let (track_id, channels, pre_skip, playable_frames, output_gain) = {
let track = format
.default_track()
.ok_or_else(|| "Ogg container has no audio track".to_string())?;
if track.codec_params.codec != CODEC_TYPE_OPUS {
return Err("Ogg track is not Opus".to_string());
}
let channels = track
.codec_params
.channels
.map(|channels| channels.count() as u16)
.ok_or_else(|| "Opus track has no channel layout".to_string())?;
if !(1..=2).contains(&channels) {
return Err(format!(
"Opus track has {channels} channels; only mono and stereo are supported"
));
}
let extra_data = track.codec_params.extra_data.as_deref();
let pre_skip = opus_pre_skip(extra_data);
let playable_frames = opus_playable_frames(&track.codec_params, pre_skip);
let output_gain = opus_output_gain(extra_data);
(track.id, channels, pre_skip, playable_frames, output_gain)
};
let decoder = OpusDecoder::new(OPUS_SAMPLE_RATE as i32, usize::from(channels))
.map_err(|error| format!("cannot initialize Opus decoder: {error}"))?;
let mut source = Self {
format,
decoder,
track_id,
channels,
pre_skip,
playable_frames,
output_position_frames: 0,
discard_frames: pre_skip,
output_gain,
buffer: Vec::new(),
scratch: Vec::new(),
buffer_position: 0,
seekable,
done: false,
};
source.fill_buffer();
if source.buffer.is_empty() {
return Err("Opus track contains no decodable audio".to_string());
}
Ok(source)
}
fn fill_buffer(&mut self) {
self.buffer.clear();
self.buffer_position = 0;
while self.buffer.is_empty() && !self.done {
let packet = match self.format.next_packet() {
Ok(packet) => packet,
Err(SymphoniaError::IoError(error))
if error.kind() == io::ErrorKind::UnexpectedEof =>
{
self.done = true;
break;
}
Err(error) => {
tracing::warn!(%error, "Ogg/Opus demux failed");
self.done = true;
break;
}
};
if packet.track_id() != self.track_id {
continue;
}
let Ok(decoded_frames) = usize::try_from(packet.dur) else {
tracing::warn!("Ogg/Opus packet duration is too large");
self.done = true;
break;
};
if decoded_frames == 0 {
continue;
}
let sample_count = match decoded_frames.checked_mul(usize::from(self.channels)) {
Some(sample_count) => sample_count,
None => {
tracing::warn!("Ogg/Opus packet sample count overflow");
self.done = true;
break;
}
};
self.scratch.resize(sample_count, 0.0);
let frames = match self
.decoder
.decode(&packet.data, decoded_frames, &mut self.scratch)
{
Ok(frames) => frames,
Err(error) => {
tracing::warn!(%error, "Opus packet decode failed");
continue;
}
};
let discarded = self.discard_frames.min(frames as u64) as usize;
self.discard_frames -= discarded as u64;
let start_frame = discarded;
let remaining = self
.playable_frames
.map(|total| total.saturating_sub(self.output_position_frames))
.unwrap_or(u64::MAX);
let end_frame = frames
.min(start_frame.saturating_add(usize::try_from(remaining).unwrap_or(usize::MAX)));
if start_frame >= end_frame {
if self
.playable_frames
.is_some_and(|total| self.output_position_frames >= total)
{
self.done = true;
}
continue;
}
let channels = usize::from(self.channels);
let start = start_frame * channels;
let end = end_frame.saturating_mul(channels).min(self.scratch.len());
self.output_position_frames = self
.output_position_frames
.saturating_add((end_frame - start_frame) as u64);
self.buffer.extend(
self.scratch[start..end]
.iter()
.map(|sample| (sample * self.output_gain).clamp(-1.0, 1.0)),
);
if self
.playable_frames
.is_some_and(|total| self.output_position_frames >= total)
{
self.done = true;
}
}
}
fn reset_decoder(&mut self) -> Result<(), SeekError> {
self.decoder = OpusDecoder::new(OPUS_SAMPLE_RATE as i32, usize::from(self.channels))
.map_err(|error| seek_error(format!("cannot reset Opus decoder: {error}")))?;
Ok(())
}
}
impl Iterator for OggOpusSource {
type Item = f32;
fn next(&mut self) -> Option<Self::Item> {
loop {
if self.buffer_position < self.buffer.len() {
let sample = self.buffer[self.buffer_position];
self.buffer_position += 1;
return Some(sample);
}
if self.done {
return None;
}
self.fill_buffer();
}
}
fn size_hint(&self) -> (usize, Option<usize>) {
(self.buffer.len().saturating_sub(self.buffer_position), None)
}
}
impl Source for OggOpusSource {
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.len())
}
fn channels(&self) -> ChannelCount {
ChannelCount::new(self.channels).expect("Opus channel count was validated")
}
fn sample_rate(&self) -> SampleRate {
SampleRate::new(OPUS_SAMPLE_RATE).expect("Opus sample rate is non-zero")
}
fn total_duration(&self) -> Option<Duration> {
self.playable_frames
.map(|frames| Duration::from_secs_f64(frames as f64 / f64::from(OPUS_SAMPLE_RATE)))
}
fn try_seek(&mut self, position: Duration) -> Result<(), SeekError> {
if !self.seekable {
return Err(SeekError::NotSupported {
underlying_source: std::any::type_name::<Self>(),
});
}
let requested = duration_frames(position);
let target = self
.playable_frames
.map_or(requested, |total| requested.min(total));
let raw_target = target.saturating_add(self.pre_skip);
let preroll = raw_target.saturating_sub(OPUS_SEEK_PREROLL_FRAMES);
let seeked = self
.format
.seek(
SeekMode::Accurate,
SeekTo::TimeStamp {
ts: preroll,
track_id: self.track_id,
},
)
.map_err(|error| seek_error(format!("Ogg seek failed: {error}")))?;
self.reset_decoder()?;
self.buffer.clear();
self.buffer_position = 0;
self.output_position_frames = target;
self.discard_frames = raw_target.saturating_sub(seeked.actual_ts);
self.done = false;
self.fill_buffer();
Ok(())
}
}
fn duration_frames(duration: Duration) -> u64 {
let frames = duration.as_secs_f64() * f64::from(OPUS_SAMPLE_RATE);
frames.round().clamp(0.0, u64::MAX as f64) as u64
}
fn opus_pre_skip(extra_data: Option<&[u8]>) -> u64 {
extra_data
.filter(|header| header.len() >= 12)
.map(|header| u64::from(u16::from_le_bytes([header[10], header[11]])))
.unwrap_or(0)
}
fn opus_playable_frames(params: &CodecParameters, pre_skip: u64) -> Option<u64> {
let encoded_frames = params.n_frames?;
let padding = u64::from(params.padding.unwrap_or(0));
// Symphonia normally leaves the OpusHead pre-skip in `delay`. For a very
// short stream whose first audio page is also its last page, it instead
// reports the page's trailing padding there. Preserve that information so
// both one-page and ordinary Ogg/Opus streams end on the correct sample.
let one_page_padding = params
.delay
.map(u64::from)
.filter(|delay| *delay != pre_skip)
.unwrap_or(0);
Some(
encoded_frames
.saturating_sub(pre_skip)
.saturating_sub(padding)
.saturating_sub(one_page_padding),
)
}
fn opus_output_gain(extra_data: Option<&[u8]>) -> f32 {
let Some(header) = extra_data.filter(|header| header.len() >= 18) else {
return 1.0;
};
let gain_q8 = i16::from_le_bytes([header[16], header[17]]);
10.0_f32.powf(f32::from(gain_q8) / (20.0 * 256.0))
}
fn seek_error(message: String) -> SeekError {
SeekError::Other(Arc::new(io::Error::other(message)))
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use base64::Engine as _;
use super::*;
// 30 ms mono Ogg/Opus sine, encoded by FFmpeg/libopus. Keeping a real
// interoperable stream here catches container and codec regressions.
const OGG_OPUS: &str = "T2dnUwACAAAAAAAAAABkiBtVAAAAAFZb1toBE09wdXNIZWFkAQE4AYC7AAAAAABPZ2dTAAAAAAAAAAAAAGSIG1UBAAAAq+b2jQE+T3B1c1RhZ3MNAAAATGF2ZjYyLjEyLjEwMgEAAAAdAAAAZW5jb2Rlcj1MYXZjNjIuMjguMTAyIGxpYm9wdXNPZ2dTAATYBgAAAAAAAGSIG1UCAAAAEkNnrgT/NP8x+HJJRycQ5MhbeCeLhvfY79vodePsNuYR8hAn2iaItTjKzvL0UULTvai7BFd4FJ2BZdtZQn4K2vCS9rjadnLmp+u4QMCAJiL3MXbVJBVHjtDhATQq5rg5ZlscpBXWk/NqnQE/QaT/nhMi/VqnLKwRCFcpFnH/NqPx7RhjpvzicAu/blC/sRygALOf6blR6HYkOb7BLn/Vn3ijqdTVwCzwtcpWU2hbCXUOWnTEuXUOWaOmQKy8zmqYgzM7aOueo/2siHPpomLeiHPp4NH0cpD63qkSv4/XxX8HWdBtg9IvLpQ42ch0PrqI7BR4ZhvJoDmg45q/177KfOUxlfoG/8GpJWKTCpaN1u64it+1vkpE7sfaLIuSVNbz6BDOIREzM5pPkdIyBzqThkbRkGJr8KDGTyQyJfi0JXhIR5fGM2RptXd5mTRUZHmCz9dCMyKay/nzCir7oSDyEbWNlfaV7qzrxPXTNJRpJ/6GgfI3Ht2Y9jKPV8WkGzGO/Pyz+hznJTgJdGOpxRmzAWXmcwcSdI0TjPVLIs8SIBtipLhr0R+yDe2ar7ZQR3hxQzoIn5ydLO7mk8QZvr+4gAAAAAAAAAAAAAAAAAAAAAAAAE+e11+HmY5uncnz1m153WYm1urhAnMTa3qa3G/MIezSH+urKDn4eZAnEgz8PMnPgAOL22PlLS7647fYrYbJfnfYvLgwLpXSlHeh7VHf+GQd99vAzM/086lHsksCvwXkd12GFX2BTQbtW/3wPMmi4zL6VsKJUH/Q5ZQ00cvMtFDgSw5CLIg8nHONgH2/XVeZC+VwngKBeuzHHK4=";
fn fixture() -> Vec<u8> {
base64::engine::general_purpose::STANDARD
.decode(OGG_OPUS)
.unwrap()
}
#[test]
fn detects_and_decodes_ogg_opus() {
let bytes = fixture();
let len = bytes.len() as u64;
let mut reader: TrackReader = Box::new(Cursor::new(bytes));
assert!(is_ogg_opus(&mut reader).unwrap());
let source = OggOpusSource::new(reader, Some(len), true).unwrap();
assert_eq!(source.channels().get(), 1);
assert_eq!(source.sample_rate().get(), OPUS_SAMPLE_RATE);
let samples: Vec<_> = source.collect();
assert_eq!(samples.len(), 1_440);
assert!(samples.iter().any(|sample| sample.abs() > 0.001));
}
#[test]
fn seeks_within_ogg_opus() {
let bytes = fixture();
let len = bytes.len() as u64;
let reader: TrackReader = Box::new(Cursor::new(bytes));
let mut source = OggOpusSource::new(reader, Some(len), true).unwrap();
assert_eq!(source.by_ref().count(), 1_440);
source.try_seek(Duration::from_millis(15)).unwrap();
let samples: Vec<_> = source.collect();
assert_eq!(samples.len(), 720);
assert!(samples.iter().any(|sample| sample.abs() > 0.001));
}
#[test]
fn ogg_vorbis_header_is_not_misclassified_as_opus() {
let mut bytes =
b"OggS\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\x01\x1e\x01vorbis".to_vec();
bytes.resize(SNIFF_BYTES, 0);
let mut reader: TrackReader = Box::new(Cursor::new(bytes));
assert!(!is_ogg_opus(&mut reader).unwrap());
}
}
+10 -3
View File
@@ -13,7 +13,7 @@ use std::time::Instant;
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use futures_util::StreamExt as _; use futures_util::StreamExt as _;
use rodio::{Decoder, Source as _}; use rodio::Source as _;
use rustfft::FftPlanner; use rustfft::FftPlanner;
use rustfft::num_complex::Complex; use rustfft::num_complex::Complex;
use sha2::{Digest as _, Sha256}; use sha2::{Digest as _, Sha256};
@@ -847,8 +847,15 @@ fn decode_mono_window(
length_seconds: Option<f64>, length_seconds: Option<f64>,
) -> Result<Vec<f32>> { ) -> Result<Vec<f32>> {
let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut decoder = let byte_len = file.metadata().ok().map(|metadata| metadata.len());
Decoder::try_from(file).with_context(|| format!("decoding {}", path.display()))?; let mut decoder = crate::player::decode_source(
Box::new(std::io::BufReader::new(file)),
byte_len,
None,
true,
)
.map_err(anyhow::Error::msg)
.with_context(|| format!("decoding {}", path.display()))?;
let channels = decoder.channels().get() as usize; let channels = decoder.channels().get() as usize;
let source_rate = decoder.sample_rate().get() as usize; let source_rate = decoder.sample_rate().get() as usize;
if start_seconds > 0.0 { if start_seconds > 0.0 {
+30 -5
View File
@@ -101,11 +101,30 @@ impl Read for GrowingFileReader {
} }
impl Seek for GrowingFileReader { impl Seek for GrowingFileReader {
fn seek(&mut self, _: SeekFrom) -> io::Result<u64> { fn seek(&mut self, position: SeekFrom) -> io::Result<u64> {
Err(io::Error::new( // Random access beyond the downloaded prefix is intentionally not
io::ErrorKind::Unsupported, // exposed, but decoders may inspect and rewind the available header.
"streaming playback is not seekable yet", let available = self
)) .shared
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.available;
let target = match position {
SeekFrom::Start(position) => i128::from(position),
SeekFrom::Current(offset) => i128::from(self.pos) + i128::from(offset),
SeekFrom::End(offset) => i128::from(available) + i128::from(offset),
};
if target < 0 || target > i128::from(available) {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
"cannot seek outside the downloaded audio prefix",
));
}
let target = target as u64;
self.file.seek(SeekFrom::Start(target))?;
self.pos = target;
Ok(target)
} }
} }
@@ -129,6 +148,12 @@ mod tests {
reader.read_exact(&mut first).unwrap(); reader.read_exact(&mut first).unwrap();
assert_eq!(&first, b"fur"); assert_eq!(&first, b"fur");
reader.seek(SeekFrom::Start(0)).unwrap();
let mut rewind = [0u8; 3];
reader.read_exact(&mut rewind).unwrap();
assert_eq!(&rewind, b"fur");
assert!(reader.seek(SeekFrom::Start(4)).is_err());
output.write_all(b"umi").unwrap(); output.write_all(b"umi").unwrap();
writer.add_available(3); writer.add_available(3);
writer.finish(); writer.finish();
+100 -3
View File
@@ -4,7 +4,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph}; use ratatui::widgets::{Block, Paragraph, Wrap};
use super::theme; use super::theme;
use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows}; use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows};
@@ -33,7 +33,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
} }
let rows_height = let rows_height =
(settings_rows(state).len() + 10 + device_presence_sections(state).len()) as u16; (settings_rows(state).len() + 15 + device_presence_sections(state).len()) as u16;
let [rows_area, _, status_area] = Layout::vertical([ let [rows_area, _, status_area] = Layout::vertical([
Constraint::Length(rows_height.min(inner.height)), Constraint::Length(rows_height.min(inner.height)),
Constraint::Length(1), Constraint::Length(1),
@@ -88,6 +88,48 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
y = y.saturating_add(1); y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Updates");
draw_row_enabled(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Check for updates",
format!("v{} | enter", env!("CARGO_PKG_VERSION")),
!state.updater.busy && !state.updater.installed,
);
cursor += 1;
draw_row_enabled(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Install update",
state
.updater
.available
.as_ref()
.map(|update| format!("v{} | enter", update.version))
.unwrap_or_else(|| "check for updates first".into()),
state.updater.available.is_some() && !state.updater.busy && !state.updater.installed,
);
cursor += 1;
if y < area.bottom() {
let height = 3.min(area.bottom() - y);
frame.render_widget(
Paragraph::new(state.updater.message.as_str())
.style(theme::dim())
.wrap(Wrap { trim: true }),
Rect::new(area.x, y, area.width, height),
);
y += height;
}
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Similarity Search"); draw_section(frame, area, state, &mut y, "Similarity Search");
let similarity = &state.similarity.settings; let similarity = &state.similarity.settings;
for row in SimilarityRow::ALL { for row in SimilarityRow::ALL {
@@ -961,7 +1003,10 @@ fn node_summary_lines(state: &AppState) -> Vec<Line<'static>> {
] ]
} }
Some(status) => vec![ Some(status) => vec![
summary_line("Node", format!("running on {}", status.network)), summary_line(
"Node",
format!("running on {} · {}", status.network, status.network_health),
),
summary_line( summary_line(
"Peers", "Peers",
format!( format!(
@@ -1215,6 +1260,17 @@ fn status_detail_status_lines(state: &AppState, status_cursor: usize) -> Vec<Lin
status.known_contacts status.known_contacts
), ),
)); ));
lines.push(status_line(
"Discovery",
format!(
"{} · {} DHT / {} dial failures · {} rebuilds · {} full recoveries",
status.network_health,
status.rendezvous_failures,
status.peer_dial_failures,
status.rendezvous_restarts,
status.recovery_count
),
));
if !status.connected_peers.is_empty() { if !status.connected_peers.is_empty() {
let mut peers: Vec<String> = status let mut peers: Vec<String> = status
.connected_peers .connected_peers
@@ -1260,6 +1316,9 @@ fn status_detail_status_lines(state: &AppState, status_cursor: usize) -> Vec<Lin
if let Some(error) = &status.last_error { if let Some(error) = &status.last_error {
lines.push(status_line("Error", first_line(error))); lines.push(status_line("Error", first_line(error)));
} }
if let Some(error) = &status.last_rendezvous_error {
lines.push(status_line("Discovery error", first_line(error)));
}
push_transport_summary_status(&mut lines, state, status); push_transport_summary_status(&mut lines, state, status);
} }
} }
@@ -1561,3 +1620,41 @@ fn relative_time_label(value_ms: Option<i64>, now_ms: i64) -> String {
format!("{}d ago", seconds / 60 / 60 / 24) format!("{}d ago", seconds / 60 / 60 / 24)
} }
} }
#[cfg(test)]
mod update_ui_tests {
use super::*;
#[test]
fn update_controls_and_existing_sections_render_in_both_layouts() {
for width in [80, 160] {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 55)).unwrap();
let mut state = AppState::default();
state.updater.message = "No newer stable release".into();
terminal
.draw(|frame| draw(frame, frame.area(), &state))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
for expected in [
"Music save directory",
"Check for updates",
"Install update",
"No newer stable release",
"Similarity Search",
"Federation",
] {
assert!(
text.contains(expected),
"missing {expected} at width {width}"
);
}
}
}
}
+1 -1
View File
@@ -927,7 +927,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
return centered_line(frame, inner, line); return centered_line(frame, inner, line);
} }
}; };
if results.len() == 0 if results.is_empty()
&& state.search.fed_tracks.is_empty() && state.search.fed_tracks.is_empty()
&& state.search.fed_artists.is_empty() && state.search.fed_artists.is_empty()
&& !state.search.fed_loading && !state.search.fed_loading
+432
View File
@@ -0,0 +1,432 @@
//! Manual portable-binary updates. All network/disk work runs off the UI thread.
use std::{
fs::File,
io::{Read, Write},
path::Path,
time::Duration,
};
use anyhow::{Context, Result, bail, ensure};
use object::Object;
use reqwest::blocking::Client;
use semver::Version;
use serde::Deserialize;
use sha2::{Digest, Sha256};
const REPOSITORY: &str = "https://api.github.com/repos/house-of-vanity/furumi_tui";
const MAX_ARCHIVE: u64 = 512 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Update {
pub version: String,
asset: Asset,
checksums: Asset,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
draft: bool,
prerelease: bool,
assets: Vec<Asset>,
}
#[derive(Debug, Default)]
pub struct State {
pub busy: bool,
pub installed: bool,
pub available: Option<Update>,
pub message: String,
}
fn client() -> Result<Client> {
// Reuse the ring backend already used by federation; respect an existing provider.
let _ = rustls::crypto::ring::default_provider().install_default();
Ok(Client::builder()
.user_agent(concat!("furumi/", env!("CARGO_PKG_VERSION")))
.https_only(true)
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(300))
.build()?)
}
fn read_limited(mut reader: impl Read, limit: u64) -> Result<Vec<u8>> {
let mut data = Vec::new();
reader.by_ref().take(limit + 1).read_to_end(&mut data)?;
ensure!(data.len() as u64 <= limit, "download exceeds size limit");
Ok(data)
}
fn select_release(release: Release, current: &str, os: &str, arch: &str) -> Result<Option<Update>> {
let version = release
.tag_name
.strip_prefix('v')
.unwrap_or(&release.tag_name);
let next = Version::parse(version).context("invalid release version")?;
if release.draft
|| release.prerelease
|| !next.pre.is_empty()
|| next <= Version::parse(current)?
{
return Ok(None);
}
let platform = match os {
"linux" => "linux",
"macos" => "macos",
"windows" => "windows",
_ => bail!("self-update is unsupported on {os}"),
};
let extension = if os == "windows" { "zip" } else { "tar.gz" };
let name = format!("furumi-{platform}-{arch}-{version}.{extension}");
let find = |name: &str| -> Result<Asset> {
let matches: Vec<_> = release
.assets
.iter()
.filter(|asset| asset.name == name)
.collect();
ensure!(matches.len() == 1, "release has no unique {name} asset");
Ok(matches[0].clone())
};
Ok(Some(Update {
version: version.to_owned(),
asset: find(&name)?,
checksums: find("SHA256SUMS")?,
}))
}
pub fn check() -> Result<Option<Update>> {
let response = client()?
.get(format!("{REPOSITORY}/releases/latest"))
.timeout(Duration::from_secs(20))
.send()?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let release = serde_json::from_slice(&read_limited(
response.error_for_status()?,
2 * 1024 * 1024,
)?)?;
select_release(
release,
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
)
}
fn checksum(text: &str, name: &str) -> Result<String> {
let mut found = None;
for line in text.lines() {
let Some((hash, filename)) = line.split_once(' ') else {
continue;
};
if filename.trim_start().trim_start_matches('*') != name {
continue;
}
ensure!(found.is_none(), "duplicate checksum for {name}");
ensure!(
hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()),
"invalid SHA-256 for {name}"
);
found = Some(hash.to_ascii_lowercase());
}
found.context("release does not contain a checksum for the selected archive")
}
fn extract(archive: &Path, name: &str, output: &mut File) -> Result<()> {
let binary = if cfg!(windows) {
"furumi.exe"
} else {
"furumi"
};
let root = name
.strip_suffix(".tar.gz")
.or_else(|| name.strip_suffix(".zip"))
.context("unsupported archive")?;
// Existing release archives omit the version in their inner directory.
let root = root.rsplit_once('-').context("invalid archive name")?.0;
let expected = format!("{root}/{binary}");
let mut count = 0;
if name.ends_with(".zip") {
let mut archive = zip::ZipArchive::new(File::open(archive)?)?;
for index in 0..archive.len() {
let mut entry = archive.by_index(index)?;
if entry.name() != expected {
continue;
}
ensure!(
entry.is_file() && !entry.is_symlink(),
"binary is not a regular file"
);
count += 1;
ensure!(count == 1, "duplicate binary in archive");
output.write_all(&read_limited(&mut entry, MAX_ARCHIVE)?)?;
}
} else {
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(File::open(archive)?));
for entry in archive.entries()? {
let mut entry = entry?;
if entry.path()?.as_ref() != Path::new(&expected) {
continue;
}
ensure!(
entry.header().entry_type().is_file(),
"binary is not a regular file"
);
count += 1;
ensure!(count == 1, "duplicate binary in archive");
output.write_all(&read_limited(&mut entry, MAX_ARCHIVE)?)?;
}
}
ensure!(count == 1, "archive does not contain {expected}");
output.sync_all()?;
Ok(())
}
fn validate_binary(current: &[u8], candidate: &[u8]) -> Result<()> {
let current = object::File::parse(current).context("cannot inspect installed binary")?;
let candidate = object::File::parse(candidate).context("invalid downloaded binary")?;
ensure!(
candidate.kind() == object::ObjectKind::Executable
|| candidate.kind() == object::ObjectKind::Dynamic,
"download is not an executable"
);
ensure!(
candidate.format() == current.format()
&& candidate.architecture() == current.architecture()
&& candidate.is_64() == current.is_64()
&& candidate.is_little_endian() == current.is_little_endian(),
"downloaded binary has incompatible platform or architecture"
);
Ok(())
}
pub fn install(update: &Update, mut progress: impl FnMut(String)) -> Result<()> {
let exe = std::env::current_exe()?.canonicalize()?;
let parent = exe.parent().context("executable has no parent directory")?;
let lock = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(parent.join(".furumi-update.lock"))
.context("cannot write to installation directory")?;
fs2::FileExt::try_lock_exclusive(&lock).context("another furumi instance is updating")?;
// Keep staging on the destination filesystem and never overwrite a running image.
let staging = tempfile::Builder::new()
.prefix(".furumi-update-")
.tempdir_in(parent)
.context("cannot create update staging directory")?;
let client = client()?;
let sums = read_limited(
client
.get(&update.checksums.browser_download_url)
.send()?
.error_for_status()?,
1024 * 1024,
)?;
let expected = checksum(std::str::from_utf8(&sums)?, &update.asset.name)?;
let mut response = client
.get(&update.asset.browser_download_url)
.send()?
.error_for_status()?;
let total = response.content_length();
ensure!(
total.is_none_or(|size| size <= MAX_ARCHIVE),
"archive exceeds size limit"
);
let archive = staging.path().join("download");
let mut file = File::create(&archive)?;
let mut hash = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
let mut downloaded = 0u64;
let mut reported = u64::MAX;
loop {
let count = response.read(&mut buffer)?;
if count == 0 {
break;
}
downloaded += count as u64;
ensure!(downloaded <= MAX_ARCHIVE, "archive exceeds size limit");
file.write_all(&buffer[..count])?;
hash.update(&buffer[..count]);
let mb = downloaded / (1024 * 1024);
if mb != reported {
progress(format!("Downloading: {mb} MiB"));
reported = mb;
}
}
file.sync_all()?;
drop(file);
ensure!(
format!("{:x}", hash.finalize()) == expected,
"SHA-256 mismatch; update was not installed"
);
progress("Verifying binary...".into());
let binary = staging.path().join(if cfg!(windows) {
"furumi.exe"
} else {
"furumi"
});
let mut output = File::create(&binary)?;
extract(&archive, &update.asset.name, &mut output)?;
drop(output);
validate_binary(&std::fs::read(&exe)?, &std::fs::read(&binary)?)?;
std::fs::set_permissions(&binary, std::fs::metadata(&exe)?.permissions())?;
progress("Installing...".into());
self_replace::self_replace(&binary).context("could not replace executable")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_client_can_be_constructed() {
client().unwrap();
}
#[test]
fn extracts_only_expected_binary_from_release_archives() {
let temp = tempfile::tempdir().unwrap();
let binary = if cfg!(windows) {
"furumi.exe"
} else {
"furumi"
};
let expected = format!("furumi-windows-x86_64/{binary}");
let payload = b"test executable";
let zip_path = temp.path().join("test.zip");
let mut zip = zip::ZipWriter::new(File::create(&zip_path).unwrap());
zip.start_file("README.md", zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(b"readme").unwrap();
zip.start_file(&expected, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(payload).unwrap();
zip.finish().unwrap();
let output = temp.path().join("output");
extract(
&zip_path,
"furumi-windows-x86_64-0.1.6.zip",
&mut File::create(&output).unwrap(),
)
.unwrap();
assert_eq!(std::fs::read(&output).unwrap(), payload);
assert!(
extract(
&zip_path,
"furumi-linux-x86_64-0.1.6.zip",
&mut File::create(&output).unwrap()
)
.is_err()
);
let tar_path = temp.path().join("test.tar.gz");
let gzip = flate2::write::GzEncoder::new(
File::create(&tar_path).unwrap(),
flate2::Compression::default(),
);
let mut tar = tar::Builder::new(gzip);
let mut header = tar::Header::new_gnu();
header.set_size(payload.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append_data(&mut header, &expected, payload.as_slice())
.unwrap();
tar.into_inner().unwrap().finish().unwrap();
extract(
&tar_path,
"furumi-windows-x86_64-0.1.6.tar.gz",
&mut File::create(&output).unwrap(),
)
.unwrap();
assert_eq!(std::fs::read(&output).unwrap(), payload);
}
#[test]
fn binary_validation_rejects_wrong_architecture() {
let current = std::fs::read(std::env::current_exe().unwrap()).unwrap();
let mut other = current.clone();
// Change only the machine field, preserving an otherwise valid executable.
if current.starts_with(b"MZ") {
let pe = u32::from_le_bytes(current[0x3c..0x40].try_into().unwrap()) as usize;
let machine: u16 = if current[pe + 4..pe + 6] == [0x64, 0x86] {
0xaa64
} else {
0x8664
};
other[pe + 4..pe + 6].copy_from_slice(&machine.to_le_bytes());
} else if current.starts_with(b"\x7fELF") {
let machine: u16 = if current[18..20] == [62, 0] { 183 } else { 62 };
other[18..20].copy_from_slice(&machine.to_le_bytes());
} else if current.starts_with(&[0xcf, 0xfa, 0xed, 0xfe]) {
let cpu: u32 = if current[4] == 7 {
0x0100000c
} else {
0x01000007
};
other[4..8].copy_from_slice(&cpu.to_le_bytes());
} else {
panic!("unsupported test executable format");
}
assert!(validate_binary(&current, &other).is_err());
}
#[test]
fn checksums_require_exact_unique_valid_entry() {
let hash = "ab".repeat(32);
assert_eq!(
checksum(&format!("{hash} app.zip\n"), "app.zip").unwrap(),
hash
);
assert!(checksum(&format!("{hash} other.zip"), "app.zip").is_err());
assert!(checksum("broken app.zip", "app.zip").is_err());
assert!(checksum(&format!("{hash} app.zip\n{hash} app.zip"), "app.zip").is_err());
}
#[test]
fn release_selection_respects_semver_and_assets() {
let release = |version: &str| Release {
tag_name: version.into(),
draft: false,
prerelease: false,
assets: vec![
Asset {
name: "furumi-linux-x86_64-0.1.10.tar.gz".into(),
browser_download_url: String::new(),
},
Asset {
name: "SHA256SUMS".into(),
browser_download_url: String::new(),
},
],
};
assert!(
select_release(release("v0.1.10"), "0.1.9", "linux", "x86_64")
.unwrap()
.is_some()
);
assert!(
select_release(release("v0.1.8"), "0.1.9", "linux", "x86_64")
.unwrap()
.is_none()
);
assert!(
select_release(release("v0.2.0-beta.1"), "0.1.9", "linux", "x86_64")
.unwrap()
.is_none()
);
assert!(select_release(release("v0.1.10"), "0.1.9", "linux", "aarch64").is_err());
}
#[test]
fn binary_validation_rejects_corrupt_download() {
let current = std::fs::read(std::env::current_exe().unwrap()).unwrap();
validate_binary(&current, &current).unwrap();
assert!(validate_binary(&current, b"not a binary").is_err());
}
}