diff --git a/CHANGELOG.md b/CHANGELOG.md index e54113b3..e2f6eed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -308,6 +308,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Analysis — failed tracks no longer block completion forever + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#867](https://github.com/Psychotoxical/psysonic/pull/867)** + +* **Settings → Library:** problematic tracks that cannot complete loudness/enrichment are now persisted as **failed** and excluded from endless retry loops after restart. +* Added failed-track controls in Analytics strategy (count, inspect list with title/path, export, and rescan) so users can review and requeue only when they want. +* Aggressive analysis idle checks now run as a cheap startup + 5-minute recheck against live-track count instead of heavy frequent polling. + + + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs index 335e6c71..bc88f75b 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs @@ -11,6 +11,7 @@ use symphonia::core::meta::MetadataOptions; use symphonia::core::probe::Hint; use symphonia::core::units::Time; use tauri::{Manager, Runtime}; +use psysonic_core::track_enrichment::TrackEnrichmentOutcome; use crate::analysis_perf::AnalysisSeedTimings; @@ -78,12 +79,32 @@ pub fn seed_from_bytes_execute( } let bpm_ms = if !server_id.is_empty() { let bpm_started = Instant::now(); - let _ = crate::track_enrichment::run_track_enrichment_if_needed( + let enrichment_outcome = crate::track_enrichment::run_track_enrichment_if_needed( app, server_id, track_id, bytes, ); + if matches!(enrichment_outcome, TrackEnrichmentOutcome::Failed) { + let key = TrackKey { + server_id: server_id.to_string(), + track_id: track_id.to_string(), + md5_16kb: md5_16kb.clone(), + }; + let _ = cache.touch_track_status(&key, "failed"); + } + if matches!(outcome, SeedFromBytesOutcome::Upserted) { + if let Ok(coverage) = cache.content_cache_coverage(server_id, track_id, &md5_16kb) { + if !coverage.has_loudness { + let key = TrackKey { + server_id: server_id.to_string(), + track_id: track_id.to_string(), + md5_16kb: md5_16kb.clone(), + }; + let _ = cache.touch_track_status(&key, "failed"); + } + } + } bpm_started.elapsed().as_millis() as u64 } else { 0 @@ -199,6 +220,7 @@ pub fn seed_from_bytes_into_cache( ); } Err(e) => { + let _ = cache.touch_track_status(&key, "failed"); crate::app_deprintln!( "[analysis] full-track analysis failed track_id={} elapsed_ms={} err={}", track_id, diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs index b9337050..d863d09e 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/mod.rs @@ -7,5 +7,6 @@ pub use compute::{ seed_from_bytes_execute, seed_from_bytes_into_cache, PcmAnalysisWindow, SeedFromBytesOutcome, }; pub use store::{ - AnalysisCache, AnalysisDeleteServerReport, LoudnessEntry, TrackKey, WaveformEntry, + AnalysisCache, AnalysisDeleteServerReport, FailedTrackEntry, LoudnessEntry, TrackKey, + WaveformEntry, }; diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs index b72a5359..dbe1ff89 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/store.rs @@ -92,6 +92,13 @@ pub struct AnalysisDeleteServerReport { pub loudness: u64, } +#[derive(Debug, Clone)] +pub struct FailedTrackEntry { + pub track_id: String, + pub md5_16kb: String, + pub updated_at: i64, +} + pub struct AnalysisCache { conn: Mutex, } @@ -110,6 +117,10 @@ fn track_id_cache_variants(id: &str) -> Vec { out } +fn normalize_track_id(id: &str) -> String { + id.strip_prefix("stream:").unwrap_or(id).to_string() +} + pub(super) fn now_unix_ts() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -538,6 +549,66 @@ impl AnalysisCache { query_latest_md5_16kb_scoped(&conn, server_id, track_id) } + /// Latest analysis status row for `(server_id, track_id)` (tries bare and + /// `stream:` variants). Used to suppress infinite retries for tracks that + /// repeatedly fail decode/enrichment. + pub fn get_latest_status_for_track( + &self, + server_id: &str, + track_id: &str, + ) -> Result, String> { + let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; + query_latest_status_scoped(&conn, server_id, track_id) + } + + pub fn count_failed_tracks(&self, server_id: &str) -> Result { + let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; + query_failed_tracks_count_scoped(&conn, server_id) + } + + pub fn list_failed_tracks( + &self, + server_id: &str, + limit: Option, + ) -> Result, String> { + let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; + query_failed_tracks_scoped(&conn, server_id, limit) + } + + pub fn clear_failed_tracks( + &self, + server_id: &str, + track_ids: &[String], + ) -> Result { + let conn = self.conn.lock().map_err(|_| "analysis_cache lock poisoned".to_string())?; + if track_ids.is_empty() { + let deleted = conn + .execute( + "DELETE FROM analysis_track WHERE server_id = ?1 AND status = 'failed'", + params![server_id], + ) + .map_err(|e| e.to_string())?; + return Ok(deleted as u64); + } + let mut total = 0u64; + for id in track_ids { + let tid = id.trim(); + if tid.is_empty() { + continue; + } + for variant in track_id_cache_variants(tid) { + let deleted = conn + .execute( + "DELETE FROM analysis_track WHERE server_id = ?1 AND track_id = ?2 AND status = 'failed'", + params![server_id, variant], + ) + .map_err(|e| e.to_string())?; + total = total.saturating_add(deleted as u64); + } + } + Ok(total) + } + /// Both waveform and loudness rows exist for this `(server_id, track_id)` — /// a CPU seed from bytes/file would only decode the file to immediately skip /// with `SkippedWaveformCacheHit`. @@ -637,6 +708,104 @@ fn query_latest_md5_16kb_scoped( Ok(None) } +fn query_latest_status_scoped( + conn: &Connection, + server_id: &str, + track_id: &str, +) -> Result, String> { + const SQL: &str = r#" + SELECT status, updated_at + FROM analysis_track + WHERE server_id = ?1 + AND track_id = ?2 + ORDER BY updated_at DESC + LIMIT 1 + "#; + let mut latest: Option<(String, i64)> = None; + for tid in track_id_cache_variants(track_id) { + let row = conn + .query_row(SQL, params![server_id, tid], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .optional() + .map_err(|e| e.to_string())?; + if let Some(candidate) = row { + let take = latest + .as_ref() + .is_none_or(|(_, ts)| candidate.1 > *ts); + if take { + latest = Some(candidate); + } + } + } + Ok(latest) +} + +fn query_failed_tracks_count_scoped(conn: &Connection, server_id: &str) -> Result { + conn.query_row( + r#" + SELECT COUNT(DISTINCT normalized_track_id) + FROM ( + SELECT CASE + WHEN track_id LIKE 'stream:%' THEN SUBSTR(track_id, 8) + ELSE track_id + END AS normalized_track_id + FROM analysis_track + WHERE server_id = ?1 + AND status = 'failed' + ) + WHERE normalized_track_id IS NOT NULL + AND normalized_track_id != '' + "#, + params![server_id], + |row| row.get(0), + ) + .map_err(|e| e.to_string()) +} + +fn query_failed_tracks_scoped( + conn: &Connection, + server_id: &str, + limit: Option, +) -> Result, String> { + const SQL: &str = r#" + SELECT track_id, md5_16kb, updated_at + FROM analysis_track + WHERE server_id = ?1 + AND status = 'failed' + ORDER BY updated_at DESC + "#; + let mut stmt = conn.prepare(SQL).map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![server_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .map_err(|e| e.to_string())?; + + let mut out: Vec = Vec::new(); + let mut seen = std::collections::HashSet::::new(); + for row in rows { + let (track_id_raw, md5_16kb, updated_at) = row.map_err(|e| e.to_string())?; + let normalized = normalize_track_id(track_id_raw.trim()); + if normalized.is_empty() || !seen.insert(normalized.clone()) { + continue; + } + out.push(FailedTrackEntry { + track_id: normalized, + md5_16kb, + updated_at, + }); + if limit.is_some_and(|n| out.len() >= n) { + break; + } + } + Ok(out) +} + /// Server-scoped variant of the "latest loudness for this track" lookup. fn query_latest_loudness_scoped( conn: &Connection, @@ -1673,4 +1842,62 @@ mod tests { let cache = AnalysisCache::init(&handle).expect("analysis cache init with mock app"); cache.checkpoint_wal("init-test").unwrap(); } + + #[test] + fn latest_status_for_track_prefers_newest_variant_timestamp() { + let cache = AnalysisCache::open_in_memory(); + let base = key_on("server-a", "track-1"); + let prefixed = key_on("server-a", "stream:track-1"); + + cache.touch_track_status(&base, "queued").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1200)); + cache.touch_track_status(&prefixed, "failed").unwrap(); + + let row = cache + .get_latest_status_for_track("server-a", "track-1") + .unwrap() + .expect("latest status row"); + assert_eq!(row.0, "failed"); + } + + #[test] + fn failed_track_queries_deduplicate_stream_variants() { + let cache = AnalysisCache::open_in_memory(); + let base = key_on("server-a", "track-2"); + let prefixed = key_on("server-a", "stream:track-2"); + let other = key_on("server-a", "track-3"); + + cache.touch_track_status(&base, "failed").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1200)); + cache.touch_track_status(&prefixed, "failed").unwrap(); + cache.touch_track_status(&other, "failed").unwrap(); + + let count = cache.count_failed_tracks("server-a").unwrap(); + assert_eq!(count, 2); + + let listed = cache.list_failed_tracks("server-a", None).unwrap(); + assert_eq!(listed.len(), 2); + assert!(listed.iter().any(|r| r.track_id == "track-2")); + assert!(listed.iter().any(|r| r.track_id == "track-3")); + } + + #[test] + fn clear_failed_tracks_removes_only_failed_rows() { + let cache = AnalysisCache::open_in_memory(); + let failed = key_on("server-a", "track-failed"); + let ready = key_on("server-a", "track-ready"); + cache.touch_track_status(&failed, "failed").unwrap(); + cache.touch_track_status(&ready, "ready").unwrap(); + + let deleted = cache + .clear_failed_tracks("server-a", &["track-failed".to_string()]) + .unwrap(); + assert_eq!(deleted, 1); + assert_eq!(cache.count_failed_tracks("server-a").unwrap(), 0); + let ready_latest = cache + .get_latest_status_for_track("server-a", "track-ready") + .unwrap() + .expect("ready row stays"); + assert_eq!(ready_latest.0, "ready"); + } } diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 37ad5169..2e169a99 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -5,6 +5,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use tauri::{Emitter, Manager}; use psysonic_core::user_agent::subsonic_wire_user_agent; +use psysonic_core::track_enrichment::TrackEnrichmentOutcome; use crate::analysis_cache; @@ -435,7 +436,18 @@ async fn enqueue_track_analysis_with_fetch( content_hash ); let bpm_started = std::time::Instant::now(); - run_track_enrichment_from_bytes(app, server_id, track_id, bytes).await; + let outcome = run_track_enrichment_from_bytes(app, server_id, track_id, bytes).await; + if matches!(outcome, TrackEnrichmentOutcome::Failed) { + if let Some(cache) = app.try_state::() { + let key = analysis_cache::TrackKey { + server_id: server_id.to_string(), + track_id: track_id.to_string(), + md5_16kb: content_hash.clone(), + }; + let _ = cache.touch_track_status(&key, "failed"); + } + return Err("track enrichment failed".to_string()); + } let bpm_ms = bpm_started.elapsed().as_millis() as u64; emit_analysis_track_perf(app, track_id, fetch_ms, 0, bpm_ms); return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly); @@ -452,18 +464,22 @@ pub async fn run_track_enrichment_from_bytes( server_id: &str, track_id: &str, bytes: &[u8], -) { +) -> TrackEnrichmentOutcome { if server_id.is_empty() { - return; + return TrackEnrichmentOutcome::SkippedNoServer; } let app = app.clone(); let sid = server_id.to_string(); let tid = track_id.to_string(); let data = bytes.to_vec(); - let _ = tokio::task::spawn_blocking(move || { - crate::track_enrichment::run_track_enrichment_if_needed(&app, &sid, &tid, &data); + match tokio::task::spawn_blocking(move || { + crate::track_enrichment::run_track_enrichment_if_needed(&app, &sid, &tid, &data) }) - .await; + .await + { + Ok(outcome) => outcome, + Err(_) => TrackEnrichmentOutcome::Failed, + } } /// Read a local file and run [`enqueue_track_analysis`] (hot cache, offline, spill promote). diff --git a/src-tauri/crates/psysonic-analysis/src/commands.rs b/src-tauri/crates/psysonic-analysis/src/commands.rs index 7071c4c4..2f351445 100644 --- a/src-tauri/crates/psysonic-analysis/src/commands.rs +++ b/src-tauri/crates/psysonic-analysis/src/commands.rs @@ -59,6 +59,14 @@ pub struct AnalysisDeleteServerReportDto { pub loudness: u64, } +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalysisFailedTrackDto { + pub track_id: String, + pub md5_16kb: String, + pub updated_at: i64, +} + impl From for AnalysisDeleteServerReportDto { fn from(value: analysis_cache::AnalysisDeleteServerReport) -> Self { Self { @@ -69,6 +77,16 @@ impl From for AnalysisDeleteServerRe } } +impl From for AnalysisFailedTrackDto { + fn from(value: analysis_cache::FailedTrackEntry) -> Self { + Self { + track_id: value.track_id, + md5_16kb: value.md5_16kb, + updated_at: value.updated_at, + } + } +} + #[derive(serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct AnalysisServerKeyMigrationDto { @@ -227,6 +245,54 @@ pub fn analysis_delete_all_for_server( Ok(report.into()) } +#[tauri::command] +pub fn analysis_get_failed_track_count( + server_id: String, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + let server_id = server_id.trim().to_string(); + if server_id.is_empty() { + return Ok(0); + } + cache.count_failed_tracks(&server_id) +} + +#[tauri::command] +pub fn analysis_list_failed_tracks( + server_id: String, + limit: Option, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result, String> { + let server_id = server_id.trim().to_string(); + if server_id.is_empty() { + return Ok(Vec::new()); + } + let limit = limit + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .map(|v| v.clamp(1, 5_000)); + let rows = cache.list_failed_tracks(&server_id, limit)?; + Ok(rows.into_iter().map(AnalysisFailedTrackDto::from).collect()) +} + +#[tauri::command] +pub fn analysis_clear_failed_tracks( + server_id: String, + track_ids: Option>, + cache: tauri::State<'_, analysis_cache::AnalysisCache>, +) -> Result { + let server_id = server_id.trim().to_string(); + if server_id.is_empty() { + return Err("server_id required".to_string()); + } + let track_ids = track_ids + .unwrap_or_default() + .into_iter() + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect::>(); + cache.clear_failed_tracks(&server_id, &track_ids) +} + #[tauri::command] pub fn analysis_migrate_server_index_keys( mappings: Vec, diff --git a/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs b/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs index 8936679e..3761fb84 100644 --- a/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs +++ b/src-tauri/crates/psysonic-analysis/src/track_analysis_plan.rs @@ -7,7 +7,7 @@ use psysonic_core::track_analysis::TrackAnalysisPlan; use psysonic_core::track_enrichment::TrackEnrichmentPort; use tauri::{AppHandle, Manager}; -use crate::analysis_cache::AnalysisCache; +use crate::analysis_cache::{AnalysisCache, TrackKey}; pub fn plan_track_analysis( app: &AppHandle, @@ -52,6 +52,40 @@ pub fn track_analysis_needs_work( server_id: &str, track_id: &str, ) -> Result { + if let Some(cache) = app.try_state::() { + let latest_status = cache.get_latest_status_for_track(server_id, track_id)?; + if latest_status + .as_ref() + .is_some_and(|(status, _)| status == "failed") + { + return Ok(false); + } + let plan = plan_track_analysis_from_cache(app, server_id, track_id)?; + if !plan.any() { + return Ok(false); + } + // Legacy reconciliation: some old rows are persisted as `ready` with + // waveform present but no loudness (typically unsupported decode path). + // Those tracks spin forever in pending without converging. Promote to + // terminal `failed` so scheduler/progress can converge. + if latest_status + .as_ref() + .is_some_and(|(status, _)| status == "ready") + && plan.need_loudness + && !plan.need_waveform + { + if let Some(md5) = cache.get_latest_md5_16kb_for_track(server_id, track_id)? { + let key = TrackKey { + server_id: server_id.to_string(), + track_id: track_id.to_string(), + md5_16kb: md5, + }; + let _ = cache.touch_track_status(&key, "failed"); + } + return Ok(false); + } + return Ok(plan.any()); + } Ok(plan_track_analysis_from_cache(app, server_id, track_id)?.any()) } @@ -159,4 +193,5 @@ mod tests { let (wf, ld) = cache_gaps_for_content(Some(&cache), "s1", "t1", "abc"); assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint"); } + } diff --git a/src-tauri/crates/psysonic-library/src/commands.rs b/src-tauri/crates/psysonic-library/src/commands.rs index 32bbf2b1..39f05abe 100644 --- a/src-tauri/crates/psysonic-library/src/commands.rs +++ b/src-tauri/crates/psysonic-library/src/commands.rs @@ -118,6 +118,19 @@ pub fn library_analysis_progress( })) } +#[tauri::command] +pub fn library_count_live_tracks( + runtime: State<'_, LibraryRuntime>, + server_id: String, +) -> Result { + let server_id = server_id.trim().to_string(); + if server_id.is_empty() { + return Ok(0); + } + let repo = TrackRepository::new(&runtime.store); + repo.count_live_tracks(&server_id) +} + #[tauri::command] pub async fn library_get_status( runtime: State<'_, LibraryRuntime>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62768a16..4db64d94 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -683,6 +683,9 @@ pub fn run() { psysonic_analysis::commands::analysis_delete_waveform_for_track, psysonic_analysis::commands::analysis_delete_all_waveforms, psysonic_analysis::commands::analysis_delete_all_for_server, + psysonic_analysis::commands::analysis_get_failed_track_count, + psysonic_analysis::commands::analysis_list_failed_tracks, + psysonic_analysis::commands::analysis_clear_failed_tracks, psysonic_analysis::commands::analysis_migrate_server_index_keys, psysonic_analysis::commands::analysis_enqueue_seed_from_url, psysonic_analysis::commands::analysis_set_playback_priority_hints, @@ -702,6 +705,7 @@ pub fn run() { psysonic_library::commands::library_get_facts, psysonic_library::commands::library_get_offline_path, psysonic_library::commands::library_analysis_progress, + psysonic_library::commands::library_count_live_tracks, psysonic_library::commands::library_sync_bind_session, psysonic_library::commands::library_sync_clear_session, psysonic_library::commands::library_set_playback_hint, diff --git a/src/api/analysis.ts b/src/api/analysis.ts index 9dc46293..d28c2e88 100644 --- a/src/api/analysis.ts +++ b/src/api/analysis.ts @@ -40,6 +40,12 @@ export interface LibraryAnalysisProgressDto { doneTracks: number; } +export interface AnalysisFailedTrackDto { + trackId: string; + md5_16kb: string; + updatedAt: number; +} + export interface AnalysisDeleteServerReportDto { analysisTracks: number; waveforms: number; @@ -82,6 +88,11 @@ export function libraryAnalysisProgress( return invoke('library_analysis_progress', { serverId: indexKey }); } +export function libraryCountLiveTracks(serverId: string): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('library_count_live_tracks', { serverId: indexKey }); +} + export function analysisDeleteAllForServer( serverId: string, ): Promise { @@ -89,6 +100,33 @@ export function analysisDeleteAllForServer( return invoke('analysis_delete_all_for_server', { serverId: indexKey }); } +export function analysisGetFailedTrackCount(serverId: string): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('analysis_get_failed_track_count', { serverId: indexKey }); +} + +export function analysisListFailedTracks( + serverId: string, + limit?: number, +): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('analysis_list_failed_tracks', { + serverId: indexKey, + limit: limit ?? null, + }); +} + +export function analysisClearFailedTracks( + serverId: string, + trackIds?: string[], +): Promise { + const indexKey = serverIndexKeyForId(serverId); + return invoke('analysis_clear_failed_tracks', { + serverId: indexKey, + trackIds: trackIds ?? null, + }); +} + export type AnalysisBackfillPriority = 'high' | 'middle' | 'low'; export function analysisSetPipelineParallelism(workers: number): Promise { diff --git a/src/components/settings/AnalyticsStrategySection.tsx b/src/components/settings/AnalyticsStrategySection.tsx index 6d22d187..b04a5b95 100644 --- a/src/components/settings/AnalyticsStrategySection.tsx +++ b/src/components/settings/AnalyticsStrategySection.tsx @@ -1,15 +1,24 @@ -import { AlertTriangle, BarChart3, X } from 'lucide-react'; +import { AlertTriangle, BarChart3, FileDown, RefreshCcw, TriangleAlert, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; +import { save as saveDialog } from '@tauri-apps/plugin-dialog'; +import { writeFile } from '@tauri-apps/plugin-fs'; import SettingsSubSection from '../SettingsSubSection'; import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore'; import { useAuthStore } from '../../store/authStore'; import { + analysisClearFailedTracks, analysisDeleteAllForServer, + analysisEnqueueSeedFromUrl, + analysisGetFailedTrackCount, + analysisListFailedTracks, + type AnalysisFailedTrackDto, libraryAnalysisProgress, type LibraryAnalysisProgressDto, } from '../../api/analysis'; +import { libraryGetTracksBatch, type LibraryTrackDto, type TrackRefDto } from '../../api/library'; +import { buildStreamUrlForServer } from '../../api/subsonicStreamUrl'; import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey'; import { showToast } from '../../utils/ui/toast'; @@ -25,6 +34,17 @@ type ClearTarget = { label: string; }; +type FailedModalTarget = { + serverId: string; + label: string; + indexKey: string; +}; + +type FailedTrackView = AnalysisFailedTrackDto & { + title?: string | null; + serverPath?: string | null; +}; + export default function AnalyticsStrategySection() { const { t } = useTranslation(); const servers = useAuthStore(s => s.servers); @@ -38,8 +58,13 @@ export default function AnalyticsStrategySection() { getAdvancedParallelismForServer, } = useAnalysisStrategyStore(); const [progressByServer, setProgressByServer] = useState>({}); + const [failedCountByServer, setFailedCountByServer] = useState>({}); + const [failedTracksByServer, setFailedTracksByServer] = useState>({}); const [clearTarget, setClearTarget] = useState(null); const [clearingServerId, setClearingServerId] = useState(null); + const [failedModalTarget, setFailedModalTarget] = useState(null); + const [failedModalLoading, setFailedModalLoading] = useState(false); + const [failedActionBusy, setFailedActionBusy] = useState(false); const activeServerIds = useMemo( () => new Set(servers.map(server => serverIndexKeyForProfile(server))), @@ -64,9 +89,12 @@ export default function AnalyticsStrategySection() { void Promise.all( servers.map(server => { const key = serverIndexKeyForProfile(server); - return libraryAnalysisProgress(server.id) - .then(progress => ({ key, progress })) - .catch(() => ({ key, progress: null })); + return Promise.all([ + libraryAnalysisProgress(server.id).catch(() => null), + analysisGetFailedTrackCount(server.id).catch(() => 0), + ]) + .then(([progress, failedCount]) => ({ key, progress, failedCount })) + .catch(() => ({ key, progress: null, failedCount: 0 })); }), ).then(results => { if (cancelled) return; @@ -77,6 +105,13 @@ export default function AnalyticsStrategySection() { }); return next; }); + setFailedCountByServer(prev => { + const next = { ...prev }; + results.forEach(({ key, failedCount }) => { + next[key] = Number.isFinite(failedCount) ? failedCount : 0; + }); + return next; + }); }); }; refresh(); @@ -87,10 +122,14 @@ export default function AnalyticsStrategySection() { }; }, [servers]); - const progressLabel = (progress: LibraryAnalysisProgressDto | null) => { + const progressLabel = (progress: LibraryAnalysisProgressDto | null, failedCount: number) => { if (!progress || progress.totalTracks <= 0) return null; - const done = progress.doneTracks; - const total = progress.totalTracks; + const blocked = Math.max(0, failedCount); + const total = Math.max(0, progress.totalTracks - blocked); + if (total <= 0) { + return t('settings.analyticsStrategyProgressEmptyAfterFailed'); + } + const done = Math.max(0, total - progress.pendingTracks); const percent = Math.max(0, Math.min(100, Math.round((done / total) * 100))); return t('settings.analyticsStrategyProgressValue', { percent, @@ -123,6 +162,95 @@ export default function AnalyticsStrategySection() { } }; + const openFailedTracksModal = async (target: FailedModalTarget) => { + setFailedModalTarget(target); + setFailedModalLoading(true); + try { + const tracks = await analysisListFailedTracks(target.serverId, 2000); + const refs: TrackRefDto[] = tracks.map(track => ({ + serverId: target.serverId, + trackId: track.trackId, + })); + const dtoById = new Map(); + if (refs.length > 0) { + const batch = await libraryGetTracksBatch(refs).catch(() => []); + batch.forEach(track => { + if (!dtoById.has(track.id)) dtoById.set(track.id, track); + }); + } + const merged: FailedTrackView[] = tracks.map(track => { + const dto = dtoById.get(track.trackId); + return { + ...track, + title: dto?.title ?? null, + serverPath: dto?.serverPath ?? null, + }; + }); + setFailedTracksByServer(prev => ({ ...prev, [target.indexKey]: merged })); + setFailedCountByServer(prev => ({ ...prev, [target.indexKey]: merged.length })); + } catch { + showToast(t('settings.analyticsFailedTracksLoadError'), 4500, 'error'); + } finally { + setFailedModalLoading(false); + } + }; + + const handleExportFailedTracks = async () => { + if (!failedModalTarget) return; + const tracks = failedTracksByServer[failedModalTarget.indexKey] ?? []; + if (tracks.length === 0) return; + const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 16); + const suggestedName = `psysonic-failed-tracks-${stamp}.json`; + const selected = await saveDialog({ + defaultPath: suggestedName, + filters: [{ name: 'JSON', extensions: ['json'] }], + title: t('settings.analyticsFailedTracksExport'), + }); + if (!selected || Array.isArray(selected)) return; + try { + const payload = { + serverId: failedModalTarget.serverId, + exportedAt: new Date().toISOString(), + count: tracks.length, + tracks, + }; + const bytes = new TextEncoder().encode(JSON.stringify(payload, null, 2)); + await writeFile(selected, bytes); + showToast(t('settings.analyticsFailedTracksExportSuccess', { count: tracks.length }), 3500, 'success'); + } catch { + showToast(t('settings.analyticsFailedTracksExportError'), 4500, 'error'); + } + }; + + const handleRescanFailedTracks = async () => { + if (!failedModalTarget) return; + const tracks = failedTracksByServer[failedModalTarget.indexKey] ?? []; + if (tracks.length === 0) return; + setFailedActionBusy(true); + try { + const ids = tracks.map(t => t.trackId); + await analysisClearFailedTracks(failedModalTarget.serverId, ids); + await Promise.allSettled( + ids.slice(0, 200).map(trackId => + analysisEnqueueSeedFromUrl( + trackId, + buildStreamUrlForServer(failedModalTarget.serverId, trackId), + failedModalTarget.serverId, + 'low', + ), + ), + ); + setFailedTracksByServer(prev => ({ ...prev, [failedModalTarget.indexKey]: [] })); + setFailedCountByServer(prev => ({ ...prev, [failedModalTarget.indexKey]: 0 })); + showToast(t('settings.analyticsFailedTracksRescanSuccess', { count: ids.length }), 4500, 'success'); + setFailedModalTarget(null); + } catch { + showToast(t('settings.analyticsFailedTracksRescanError'), 5000, 'error'); + } finally { + setFailedActionBusy(false); + } + }; + return ( @@ -207,7 +336,23 @@ export default function AnalyticsStrategySection() { )} - {progressLabel(progress) ?? '—'} +
+ {progressLabel(progress, failedCount) ?? '—'} + {failedCount > 0 && ( + + )} +
+

+ {t('settings.analyticsFailedTracksTitle', { server: failedModalTarget.label })} +

+

+ {t('settings.analyticsFailedTracksDesc')} +

+ + {failedModalLoading ? ( +
+ {t('settings.analyticsFailedTracksLoading')} +
+ ) : (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0 ? ( +
+ {t('settings.analyticsFailedTracksEmpty')} +
+ ) : ( +
+ {(failedTracksByServer[failedModalTarget.indexKey] ?? []).map(track => ( +
+
+
+ {track.title?.trim() || track.trackId} +
+
+ {track.serverPath?.trim() || track.trackId} +
+
+ + {new Date(track.updatedAt * 1000).toLocaleString()} + +
+ ))} +
+ )} + +
+ + +
+ + +
+
+ + , + document.body, + )}
); } diff --git a/src/hooks/useLibraryAnalysisBackfill.ts b/src/hooks/useLibraryAnalysisBackfill.ts index a56a256c..d7a879e6 100644 --- a/src/hooks/useLibraryAnalysisBackfill.ts +++ b/src/hooks/useLibraryAnalysisBackfill.ts @@ -5,6 +5,8 @@ import { analysisGetPipelineQueueStats, analysisSetPipelineParallelism, libraryAnalysisBackfillBatch, + libraryCountLiveTracks, + libraryAnalysisProgress, } from '../api/analysis'; import { useAuthStore } from '../store/authStore'; import { useAnalysisStrategyStore } from '../store/analysisStrategyStore'; @@ -19,6 +21,7 @@ const TOP_UP_POLL_MS = 500; const STEADY_POLL_MS = 2000; const READY_POLL_MS = 5000; const EXHAUSTED_PAUSE_MS = 60_000; +const COMPLETED_RECHECK_MS = 5 * 60_000; const EMPTY_PIPELINE_STATS = { pipelineWorkers: 1, @@ -52,6 +55,7 @@ export function useLibraryAnalysisBackfill(enabled = true): void { s => s.getAdvancedParallelismForServer(activeServerId), ); const cursorRef = useRef(null); + const completedTotalRef = useRef(null); useEffect(() => { if (!enabled) return; @@ -65,9 +69,33 @@ export function useLibraryAnalysisBackfill(enabled = true): void { let cancelled = false; const serverId = activeServerId; + let initialized = false; void (async () => { while (!cancelled) { + if (!initialized) { + initialized = true; + const progress = await libraryAnalysisProgress(serverId).catch(() => null); + if (progress && progress.pendingTracks <= 0) { + completedTotalRef.current = progress.totalTracks; + cursorRef.current = null; + } + } + + if (completedTotalRef.current !== null) { + const totalTracks = await libraryCountLiveTracks(serverId).catch(() => null); + if (!Number.isFinite(totalTracks)) { + await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS)); + continue; + } + if (totalTracks === completedTotalRef.current) { + await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS)); + continue; + } + completedTotalRef.current = null; + cursorRef.current = null; + } + if (!(await libraryIsReady(serverId))) { await new Promise(r => setTimeout(r, READY_POLL_MS)); continue; @@ -119,6 +147,12 @@ export function useLibraryAnalysisBackfill(enabled = true): void { if (batch.exhausted) { cursorRef.current = null; + const progress = await libraryAnalysisProgress(serverId).catch(() => null); + if (progress && progress.pendingTracks <= 0) { + completedTotalRef.current = progress.totalTracks; + await new Promise(r => setTimeout(r, COMPLETED_RECHECK_MS)); + continue; + } await new Promise(r => setTimeout(r, EXHAUSTED_PAUSE_MS)); } else if (batch.trackIds.length === 0) { await new Promise(r => setTimeout(r, TOP_UP_POLL_MS)); @@ -130,6 +164,7 @@ export function useLibraryAnalysisBackfill(enabled = true): void { return () => { cancelled = true; cursorRef.current = null; + completedTotalRef.current = null; }; }, [strategy, activeServerId, advancedParallelism, enabled]); } diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index 21ab9f18..444c33f0 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -520,4 +520,71 @@ export const settings = { floatingPlayerBarSub: 'Player-Leiste über dem Inhalt schweben lassen', uiScaleTitle: 'Interface-Skalierung', uiScaleLabel: 'Zoom', + waveformCacheClearBtn: 'Waveform-Cache leeren', + waveformCacheCleared: 'Waveform-Cache geleert ({{count}} Zeilen).', + waveformCacheClearFailed: 'Waveform-Cache konnte nicht geleert werden.', + analyticsStrategyTitle: 'Analysestrategien', + analyticsStrategyDesc: 'Wähle den Analysemodus für die Bibliothek.', + analyticsStrategyLabel: 'Strategie', + analyticsStrategyLazy: 'Lazy', + analyticsStrategyLazyDesc: 'Analysiert nur, wenn Tracks abgespielt werden oder in den Hot-/Offline-Cache kommen — minimale CPU- und Netzlast.', + analyticsStrategyAdvanced: 'Aggressiv', + analyticsStrategyAdvancedDesc: 'Aggressiver Hintergrundscan. Nach Abschluss erhältst du BPM für alle Tracks sowie sofort verfügbare Loudness- und Waveform-Daten.', + analyticsStrategyPriorityTitle: 'Analyse-Priorität (immer aktiv)', + analyticsStrategyPriorityHigh: '1 — Aktuell laufender Track: zuerst herunterladen und analysieren (Playback-Bytes, Loudness-Backfill oder HTTP falls nötig).', + analyticsStrategyPriorityMiddle: '2 — Als Nächstes: die nächsten ~5 Queue-Tracks, Preload-next und Hot-Cache-Nachbarn — Analyse bevorzugt aus Cache/Downloads; HTTP-Backfill nur bei Miss.', + analyticsStrategyPriorityLow: '3 — Bibliotheks-Batch (nur Aggressiv): automatische Hintergrund-Queue am Ende; wird per Watermark (~3× Worker) nachgefüllt, ohne auf eine leere Queue zu warten.', + analyticsStrategyServerLabel: 'Server', + analyticsStrategyProgressLabel: 'Analysefortschritt:', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: 'Aktionen', + analyticsStrategyParallelismLabel: 'Parallele Worker', + analyticsStrategyParallelismValue: '{{n}} Threads', + analyticsStrategyParallelismDesc: 'Wie viele Tracks gleichzeitig heruntergeladen und analysiert werden (HTTP + CPU). Höhere Werte beschleunigen das Aufholen der Bibliothek, nutzen aber mehr Netz und CPU.', + analyticsStrategyAdvancedWarning: 'Aggressiv nutzt mehr CPU und Netzwerkbandbreite, während der Bibliotheks-Batch im Hintergrund läuft.', + analyticsStrategyServerRemoved: 'Entfernter Server', + analyticsStrategyClearAction: 'Analyse löschen', + analyticsStrategyClearTitle: 'Analyse löschen?', + analyticsStrategyClearDesc: 'Das Löschen der Analyse für {{server}} entfernt zwischengespeicherte Waveforms und Loudness-Daten. Beim späteren erneuten Hinzufügen kann eine lange Neuindizierung nötig sein.', + analyticsStrategyClearCancel: 'Abbrechen', + analyticsStrategyClearConfirm: 'Analyse löschen', + analyticsStrategyClearSuccess: 'Analyse für den entfernten Server wurde gelöscht.', + analyticsStrategyClearError: 'Analyse für den entfernten Server konnte nicht gelöscht werden.', + analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, durch fehlgeschlagene Tracks blockiert)', + analyticsFailedTracksOpenTitle: 'Fehlgeschlagene Tracks: {{count}}', + analyticsFailedTracksTitle: 'Fehlgeschlagene Analyse-Tracks — {{server}}', + analyticsFailedTracksDesc: 'Diese Tracks sind bei Decode/Enrichment wiederholt fehlgeschlagen und vom automatischen Hintergrund-Analyseprozess ausgeschlossen.', + analyticsFailedTracksLoading: 'Liste fehlgeschlagener Tracks wird geladen…', + analyticsFailedTracksEmpty: 'Für diesen Server gibt es keine fehlgeschlagenen Tracks.', + analyticsFailedTracksClose: 'Schließen', + analyticsFailedTracksExport: 'Liste exportieren', + analyticsFailedTracksExportSuccess: 'Liste fehlgeschlagener Tracks exportiert ({{count}}).', + analyticsFailedTracksExportError: 'Liste fehlgeschlagener Tracks konnte nicht exportiert werden.', + analyticsFailedTracksRescan: 'Fehlgeschlagene Tracks neu scannen', + analyticsFailedTracksRescanSuccess: 'Neu-Scan für {{count}} fehlgeschlagene Tracks eingeplant.', + analyticsFailedTracksRescanError: 'Neu-Scan fehlgeschlagener Tracks konnte nicht gestartet werden.', + analyticsFailedTracksLoadError: 'Fehlgeschlagene Tracks konnten nicht geladen werden.', + backupImportAny: 'Backup importieren', + backupImportAnyConfirm: 'Ausgewählte Backup-Datei importieren und Inhalt anwenden (Einstellungen, Bibliotheksdatenbanken oder vollständig). Fortfahren?', + backupModeFull: 'Vollständig', + backupModeLibrary: 'Bibliotheksdatenbanken', + backupModeConfig: 'Konfiguration', + backupFullDesc: 'Archiviert Einstellungen und Bibliotheksdatenbanken in einer Backup-Datei.', + backupFullExport: 'Vollständiges Backup exportieren', + backupFullImport: 'Vollständiges Backup importieren', + backupFullImportConfirm: 'Dadurch werden Einstellungen überschrieben, aktuelle Bibliotheksdatenbanken ersetzt und nach .bak verschoben. Fortfahren?', + backupFullExportSuccess: 'Vollständiges Backup exportiert.', + backupFullImportSuccess: 'Vollständiges Backup importiert — lade neu…', + backupFullImportError: 'Vollständiges Backup konnte nicht importiert werden.', + backupLibraryExport: 'Datenbanken exportieren', + backupLibraryExportDesc: 'Erstellt ein komprimiertes Archiv mit sauberen SQLite-Snapshots der Bibliotheksdatenbanken.', + backupLibraryImport: 'Datenbanken importieren', + backupLibraryImportDesc: 'Stellt Bibliotheksdatenbanken aus einer Archivdatei wieder her. Aktuelle Datenbanken werden vor dem Wechsel nach .bak verschoben.', + backupLibraryImportConfirm: 'Dadurch werden aktuelle Bibliotheksdatenbanken ersetzt und nach .bak verschoben. Fortfahren?', + backupLibraryExportSuccess: 'Datenbank-Backup exportiert.', + backupLibraryImportSuccess: 'Datenbank-Backup importiert.', + backupLibraryImportError: 'Datenbank-Backup konnte nicht importiert werden.', + backupOverlayExportTitle: 'Backup wird erstellt…', + backupOverlayImportTitle: 'Backup wird wiederhergestellt…', + backupOverlayHint: 'Das Fenster kann geöffnet bleiben, während wir Dateien verarbeiten. Eingaben sind vorübergehend gesperrt.', }; diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index ce40a264..1f14e1d2 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -310,6 +310,20 @@ export const settings = { analyticsStrategyClearConfirm: 'Clear analysis', analyticsStrategyClearSuccess: 'Analysis cleared for the removed server.', analyticsStrategyClearError: 'Failed to clear analysis for the removed server.', + analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, blocked by failed tracks)', + analyticsFailedTracksOpenTitle: 'Failed tracks: {{count}}', + analyticsFailedTracksTitle: 'Failed analysis tracks — {{server}}', + analyticsFailedTracksDesc: 'These tracks repeatedly failed decode/enrichment and are excluded from automatic background analysis.', + analyticsFailedTracksLoading: 'Loading failed track list…', + analyticsFailedTracksEmpty: 'No failed tracks for this server.', + analyticsFailedTracksClose: 'Close', + analyticsFailedTracksExport: 'Export list', + analyticsFailedTracksExportSuccess: 'Failed track list exported ({{count}}).', + analyticsFailedTracksExportError: 'Could not export failed track list.', + analyticsFailedTracksRescan: 'Rescan failed tracks', + analyticsFailedTracksRescanSuccess: 'Rescan scheduled for {{count}} failed tracks.', + analyticsFailedTracksRescanError: 'Could not rescan failed tracks.', + analyticsFailedTracksLoadError: 'Could not load failed tracks.', randomMixTitle: 'Random Mix Blacklist', luckyMixMenuTitle: 'Show Lucky Mix in menu', luckyMixMenuDesc: 'Enables Lucky Mix in Build a Mix and as a separate menu item when split navigation is on. Visible only when AudioMuse is enabled on the active server.', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 909d1e8b..4e1d3e25 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -519,4 +519,72 @@ export const settings = { floatingPlayerBarSub: 'Mantener la barra del reproductor flotando sobre el contenido', uiScaleTitle: 'Escala de Interfaz', uiScaleLabel: 'Zoom', + confirmDeleteServerLibrary: '¿También quieres eliminar el índice local de biblioteca de este servidor? Pulsa Cancelar para conservar la copia en caché para uso sin conexión.', + waveformCacheClearBtn: 'Borrar caché de forma de onda', + waveformCacheCleared: 'Caché de forma de onda borrada ({{count}} filas).', + waveformCacheClearFailed: 'No se pudo borrar la caché de forma de onda.', + analyticsStrategyTitle: 'Estrategias de análisis', + analyticsStrategyDesc: 'Elige el estilo de análisis para la biblioteca.', + analyticsStrategyLabel: 'Estrategia', + analyticsStrategyLazy: 'Perezosa', + analyticsStrategyLazyDesc: 'Analiza solo cuando se reproduce una pista o entra en la caché caliente/sin conexión; uso mínimo de CPU y red.', + analyticsStrategyAdvanced: 'Agresiva', + analyticsStrategyAdvancedDesc: 'Escaneo en segundo plano más agresivo. Al terminar, obtienes BPM para todas las pistas y carga inmediata de loudness y forma de onda.', + analyticsStrategyPriorityTitle: 'Prioridad de análisis (siempre activa)', + analyticsStrategyPriorityHigh: '1 — Reproducción actual: descargar y analizar primero (bytes de reproducción, backfill de loudness o HTTP cuando haga falta).', + analyticsStrategyPriorityMiddle: '2 — Siguientes: ~5 pistas de cola, precarga de la siguiente y vecinas de caché caliente; analiza desde caché/descarga cuando sea posible, con backfill HTTP solo en fallo.', + analyticsStrategyPriorityLow: '3 — Lote de biblioteca (solo Agresiva): cola automática en segundo plano al final; se rellena por watermark (~3× workers) sin esperar a que quede vacía.', + analyticsStrategyServerLabel: 'Servidor', + analyticsStrategyProgressLabel: 'Progreso del análisis:', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: 'Acciones', + analyticsStrategyParallelismLabel: 'Workers en paralelo', + analyticsStrategyParallelismValue: '{{n}} hilos', + analyticsStrategyParallelismDesc: 'Cuántas pistas descargar y analizar al mismo tiempo (HTTP + CPU). Valores más altos aceleran el progreso, pero consumen más red y CPU.', + analyticsStrategyAdvancedWarning: 'La estrategia agresiva usa más CPU y ancho de banda mientras se ejecuta el lote de biblioteca en segundo plano.', + analyticsStrategyServerRemoved: 'Servidor eliminado', + analyticsStrategyClearAction: 'Borrar análisis', + analyticsStrategyClearTitle: '¿Borrar análisis?', + analyticsStrategyClearDesc: 'Borrar el análisis de {{server}} elimina las formas de onda y loudness en caché. Si vuelves a añadir este servidor, puede requerir una reindexación larga.', + analyticsStrategyClearCancel: 'Cancelar', + analyticsStrategyClearConfirm: 'Borrar análisis', + analyticsStrategyClearSuccess: 'Análisis borrado para el servidor eliminado.', + analyticsStrategyClearError: 'No se pudo borrar el análisis del servidor eliminado.', + analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, bloqueado por pistas fallidas)', + analyticsFailedTracksOpenTitle: 'Pistas fallidas: {{count}}', + analyticsFailedTracksTitle: 'Pistas de análisis fallidas — {{server}}', + analyticsFailedTracksDesc: 'Estas pistas fallaron repetidamente en decode/enrichment y quedan excluidas del análisis automático en segundo plano.', + analyticsFailedTracksLoading: 'Cargando lista de pistas fallidas…', + analyticsFailedTracksEmpty: 'No hay pistas fallidas para este servidor.', + analyticsFailedTracksClose: 'Cerrar', + analyticsFailedTracksExport: 'Exportar lista', + analyticsFailedTracksExportSuccess: 'Lista de pistas fallidas exportada ({{count}}).', + analyticsFailedTracksExportError: 'No se pudo exportar la lista de pistas fallidas.', + analyticsFailedTracksRescan: 'Reanalizar pistas fallidas', + analyticsFailedTracksRescanSuccess: 'Reanálisis programado para {{count}} pistas fallidas.', + analyticsFailedTracksRescanError: 'No se pudo reanalizar las pistas fallidas.', + analyticsFailedTracksLoadError: 'No se pudo cargar las pistas fallidas.', + backupImportAny: 'Importar copia', + backupImportAnyConfirm: 'Importar el archivo de copia seleccionado y aplicar su contenido (ajustes, bases de datos de biblioteca o copia completa). ¿Continuar?', + backupModeFull: 'Completa', + backupModeLibrary: 'Bases de datos de biblioteca', + backupModeConfig: 'Configuración', + backupFullDesc: 'Archiva los ajustes y las bases de datos de la biblioteca en un único archivo de copia.', + backupFullExport: 'Exportar copia completa', + backupFullImport: 'Importar copia completa', + backupFullImportConfirm: 'Esto sobrescribirá los ajustes, reemplazará las bases de datos actuales y las moverá a .bak. ¿Continuar?', + backupFullExportSuccess: 'Copia completa exportada.', + backupFullImportSuccess: 'Copia completa importada — recargando…', + backupFullImportError: 'No se pudo importar la copia completa.', + backupLibraryExport: 'Exportar bases de datos', + backupLibraryExportDesc: 'Crea un archivo comprimido con instantáneas SQLite limpias de las bases de datos de biblioteca.', + backupLibraryImport: 'Importar bases de datos', + backupLibraryImportDesc: 'Restaura las bases de datos de biblioteca desde un archivo. Las bases actuales se mueven a .bak antes del cambio.', + backupLibraryImportConfirm: 'Esto reemplazará las bases de datos actuales y las moverá a .bak. ¿Continuar?', + backupLibraryExportSuccess: 'Copia de bases de datos exportada.', + backupLibraryImportSuccess: 'Copia de bases de datos importada.', + backupLibraryImportError: 'No se pudo importar la copia de bases de datos.', + backupOverlayExportTitle: 'Creando copia…', + backupOverlayImportTitle: 'Restaurando copia…', + backupOverlayHint: 'Puedes mantener la ventana abierta mientras procesamos archivos. La entrada está bloqueada temporalmente.', }; diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index fa4ef83a..d7679552 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -517,4 +517,74 @@ export const settings = { floatingPlayerBarSub: 'Garder la barre du lecteur flottante au-dessus du contenu', uiScaleTitle: "Mise à l'échelle de l'interface", uiScaleLabel: 'Zoom', + confirmDeleteServerLibrary: 'Supprimer aussi l’index local de bibliothèque de ce serveur ? Cliquez sur Annuler pour conserver la copie en cache pour un usage hors ligne.', + waveformCacheClearBtn: 'Vider le cache des formes d’onde', + waveformCacheCleared: 'Cache des formes d’onde vidé ({{count}} lignes).', + waveformCacheClearFailed: 'Impossible de vider le cache des formes d’onde.', + useCustomTitlebar: 'Barre de titre personnalisée', + useCustomTitlebarDesc: 'Remplace la barre de titre système par une barre intégrée assortie au thème de l’app. Désactivez pour utiliser la barre GNOME/GTK native.', + analyticsStrategyTitle: 'Stratégies d’analyse', + analyticsStrategyDesc: 'Choisissez le style d’analyse pour la bibliothèque.', + analyticsStrategyLabel: 'Stratégie', + analyticsStrategyLazy: 'Paresseuse', + analyticsStrategyLazyDesc: 'Analyse uniquement quand les pistes sont lues ou entrent dans le cache hot/hors ligne — utilisation minimale du CPU et du réseau.', + analyticsStrategyAdvanced: 'Agressive', + analyticsStrategyAdvancedDesc: 'Analyse de fond plus agressive. Une fois terminée, vous obtenez le BPM pour toutes les pistes et un chargement immédiat de loudness et de formes d’onde.', + analyticsStrategyPriorityTitle: 'Priorité d’analyse (toujours appliquée)', + analyticsStrategyPriorityHigh: '1 — En cours de lecture : télécharger et analyser en premier (octets de lecture, backfill loudness ou HTTP si nécessaire).', + analyticsStrategyPriorityMiddle: '2 — À suivre : les ~5 pistes suivantes de la file, préchargement de la suivante et voisines du hot-cache — analyse depuis cache/téléchargements si possible, backfill HTTP seulement en cas d’absence.', + analyticsStrategyPriorityLow: '3 — Lot bibliothèque (Agressive uniquement) : file d’arrière-plan automatique en fin de file ; réalimentée par watermark (~3× workers) sans attendre une file vide.', + analyticsStrategyServerLabel: 'Server', + analyticsStrategyProgressLabel: 'Progression de l’analyse :', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: 'Actions', + analyticsStrategyParallelismLabel: 'Workers parallèles', + analyticsStrategyParallelismValue: '{{n}} threads', + analyticsStrategyParallelismDesc: 'Nombre de pistes à télécharger et analyser en même temps (HTTP + CPU). Des valeurs plus élevées accélèrent le rattrapage de la bibliothèque, mais consomment plus de réseau et de CPU.', + analyticsStrategyAdvancedWarning: 'Le mode agressif utilise davantage de CPU et de bande passante réseau pendant l’exécution du lot bibliothèque en arrière-plan.', + analyticsStrategyServerRemoved: 'Serveur supprimé', + analyticsStrategyClearAction: 'Effacer l’analyse', + analyticsStrategyClearTitle: 'Effacer l’analyse ?', + analyticsStrategyClearDesc: 'Effacer l’analyse de {{server}} supprime les formes d’onde et données de loudness mises en cache. Réajouter ce serveur plus tard peut nécessiter une longue réindexation.', + analyticsStrategyClearCancel: 'Annuler', + analyticsStrategyClearConfirm: 'Effacer l’analyse', + analyticsStrategyClearSuccess: 'Analyse effacée pour le serveur supprimé.', + analyticsStrategyClearError: 'Échec de l’effacement de l’analyse pour le serveur supprimé.', + analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, bloqué par des pistes en échec)', + analyticsFailedTracksOpenTitle: 'Pistes en échec : {{count}}', + analyticsFailedTracksTitle: 'Pistes d’analyse en échec — {{server}}', + analyticsFailedTracksDesc: 'Ces pistes ont échoué de manière répétée au décodage/enrichissement et sont exclues de l’analyse de fond automatique.', + analyticsFailedTracksLoading: 'Chargement de la liste des pistes en échec…', + analyticsFailedTracksEmpty: 'Aucune piste en échec pour ce serveur.', + analyticsFailedTracksClose: 'Fermer', + analyticsFailedTracksExport: 'Exporter la liste', + analyticsFailedTracksExportSuccess: 'Liste des pistes en échec exportée ({{count}}).', + analyticsFailedTracksExportError: 'Impossible d’exporter la liste des pistes en échec.', + analyticsFailedTracksRescan: 'Relancer l’analyse des pistes en échec', + analyticsFailedTracksRescanSuccess: 'Relance programmée pour {{count}} pistes en échec.', + analyticsFailedTracksRescanError: 'Impossible de relancer l’analyse des pistes en échec.', + analyticsFailedTracksLoadError: 'Impossible de charger les pistes en échec.', + backupImportAny: 'Importer une sauvegarde', + backupImportAnyConfirm: 'Importer le fichier de sauvegarde sélectionné et appliquer son contenu (réglages, bases de données de bibliothèque ou sauvegarde complète). Continuer ?', + backupModeFull: 'Complet', + backupModeLibrary: 'Bases de données de bibliothèque', + backupModeConfig: 'Configuration', + backupFullDesc: 'Archive les réglages et les bases de données de bibliothèque dans un seul fichier de sauvegarde.', + backupFullExport: 'Exporter une sauvegarde complète', + backupFullImport: 'Importer une sauvegarde complète', + backupFullImportConfirm: 'Cela écrasera les réglages, remplacera les bases de données actuelles et les déplacera en .bak. Continuer ?', + backupFullExportSuccess: 'Sauvegarde complète exportée.', + backupFullImportSuccess: 'Sauvegarde complète importée — rechargement…', + backupFullImportError: 'Impossible d’importer la sauvegarde complète.', + backupLibraryExport: 'Exporter les bases de données', + backupLibraryExportDesc: 'Crée une archive compressée contenant des instantanés SQLite propres des bases de données de bibliothèque.', + backupLibraryImport: 'Importer les bases de données', + backupLibraryImportDesc: 'Restaure les bases de données de bibliothèque depuis un fichier d’archive. Les bases actuelles sont déplacées en .bak avant le basculement.', + backupLibraryImportConfirm: 'Cela remplacera les bases de données actuelles et les déplacera en .bak. Continuer ?', + backupLibraryExportSuccess: 'Sauvegarde des bases de données exportée.', + backupLibraryImportSuccess: 'Sauvegarde des bases de données importée.', + backupLibraryImportError: 'Impossible d’importer la sauvegarde des bases de données.', + backupOverlayExportTitle: 'Création de la sauvegarde…', + backupOverlayImportTitle: 'Restauration de la sauvegarde…', + backupOverlayHint: 'Vous pouvez garder la fenêtre ouverte pendant le traitement des fichiers. La saisie est temporairement verrouillée.', }; diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 4ea9da90..28ebdb5a 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -516,4 +516,78 @@ export const settings = { floatingPlayerBarSub: 'Hold spillerlinjen flytende over innholdet', uiScaleTitle: 'Grensesnittskala', uiScaleLabel: 'Zoom', + confirmDeleteServerLibrary: 'Vil du også slette denne serverens lokale bibliotekindeks? Klikk Avbryt for å beholde den bufrede kopien for offline bruk.', + waveformCacheClearBtn: 'Tøm waveform-cache', + waveformCacheCleared: 'Waveform-cache tømt ({{count}} rader).', + waveformCacheClearFailed: 'Kunne ikke tømme waveform-cache.', + showTrayIcon: 'Vis systemstatusikon', + showTrayIconDesc: 'Vis Psysonic-ikonet i systemstatusfeltet / menylinjen.', + useCustomTitlebar: 'Tilpasset tittellinje', + useCustomTitlebarDesc: 'Erstatt systemets tittellinje med en innebygd som matcher app-temaet. Slå av for å bruke den native GNOME/GTK-tittellinjen.', + analyticsStrategyTitle: 'Analysestrategier', + analyticsStrategyDesc: 'Velg analysestil for biblioteket.', + analyticsStrategyLabel: 'Strategi', + analyticsStrategyLazy: 'Lat', + analyticsStrategyLazyDesc: 'Analyserer bare når spor spiller eller går inn i hot/offline-cache — minimalt CPU- og nettverksbruk.', + analyticsStrategyAdvanced: 'Aggressiv', + analyticsStrategyAdvancedDesc: 'Mer aggressiv bakgrunnsskanning. Når den er ferdig får du BPM for alle spor samt umiddelbar lasting av loudness og waveform.', + analyticsStrategyPriorityTitle: 'Analyseprioritet (alltid aktiv)', + analyticsStrategyPriorityHigh: '1 — Spiller nå: last ned og analyser først (avspillingsbytes, loudness-backfill eller HTTP ved behov).', + analyticsStrategyPriorityMiddle: '2 — Neste opp: de neste ~5 køsporene, preload av neste og hot-cache-naboer — analyser fra cache/nedlastede bytes når mulig; HTTP-backfill kun ved bom.', + analyticsStrategyPriorityLow: '3 — Bibliotekbatch (kun Aggressiv): automatisk bakgrunnskø bakerst; fylles på ved watermark (~3× workers) uten å vente på tom kø.', + analyticsStrategyServerLabel: 'Server', + analyticsStrategyProgressLabel: 'Analysefremdrift:', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: 'Handlinger', + analyticsStrategyParallelismLabel: 'Parallelle workers', + analyticsStrategyParallelismValue: '{{n}} tråder', + analyticsStrategyParallelismDesc: 'Hvor mange spor som lastes ned og analyseres samtidig (HTTP + CPU). Høyere verdi gjør bibliotekinnhentingen raskere, men bruker mer nettverk og CPU.', + analyticsStrategyAdvancedWarning: 'Aggressiv bruker mer CPU og nettverksbåndbredde mens bibliotekbatchen kjører i bakgrunnen.', + analyticsStrategyServerRemoved: 'Fjernet server', + analyticsStrategyClearAction: 'Tøm analyse', + analyticsStrategyClearTitle: 'Tøm analyse?', + analyticsStrategyClearDesc: 'Å tømme analyse for {{server}} fjerner bufrede waveforms og loudness. Å legge til serveren igjen senere kan kreve lang reindeksering.', + analyticsStrategyClearCancel: 'Avbryt', + analyticsStrategyClearConfirm: 'Tøm analyse', + analyticsStrategyClearSuccess: 'Analyse tømt for den fjernede serveren.', + analyticsStrategyClearError: 'Kunne ikke tømme analyse for den fjernede serveren.', + analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, blokkert av feilede spor)', + analyticsFailedTracksOpenTitle: 'Feilede spor: {{count}}', + analyticsFailedTracksTitle: 'Feilede analysespor — {{server}}', + analyticsFailedTracksDesc: 'Disse sporene feilet gjentatte ganger i decode/enrichment og er ekskludert fra automatisk bakgrunnsanalyse.', + analyticsFailedTracksLoading: 'Laster liste over feilede spor…', + analyticsFailedTracksEmpty: 'Ingen feilede spor for denne serveren.', + analyticsFailedTracksClose: 'Lukk', + analyticsFailedTracksExport: 'Eksporter liste', + analyticsFailedTracksExportSuccess: 'Liste over feilede spor eksportert ({{count}}).', + analyticsFailedTracksExportError: 'Kunne ikke eksportere liste over feilede spor.', + analyticsFailedTracksRescan: 'Skann feilede spor på nytt', + analyticsFailedTracksRescanSuccess: 'Ny skanning planlagt for {{count}} feilede spor.', + analyticsFailedTracksRescanError: 'Kunne ikke skanne feilede spor på nytt.', + analyticsFailedTracksLoadError: 'Kunne ikke laste feilede spor.', + tabAudio: 'Lyd', + tabInput: 'Inndata', + backupImportAny: 'Importer sikkerhetskopi', + backupImportAnyConfirm: 'Importer valgt sikkerhetskopifil og bruk innholdet (innstillinger, bibliotekdatabaser eller full). Fortsette?', + backupModeFull: 'Full', + backupModeLibrary: 'Bibliotekdatabaser', + backupModeConfig: 'Konfig', + backupFullDesc: 'Arkiverer innstillinger og bibliotekdatabaser i én sikkerhetskopifil.', + backupFullExport: 'Eksporter full sikkerhetskopi', + backupFullImport: 'Importer full sikkerhetskopi', + backupFullImportConfirm: 'Dette vil overskrive innstillinger, erstatte nåværende bibliotekdatabaser og flytte dem til .bak. Fortsette?', + backupFullExportSuccess: 'Full sikkerhetskopi eksportert.', + backupFullImportSuccess: 'Full sikkerhetskopi importert — laster inn på nytt…', + backupFullImportError: 'Kunne ikke importere full sikkerhetskopi.', + backupLibraryExport: 'Eksporter databaser', + backupLibraryExportDesc: 'Lager et komprimert arkiv med rene SQLite-øyeblikksbilder av bibliotekdatabaser.', + backupLibraryImport: 'Importer databaser', + backupLibraryImportDesc: 'Gjenoppretter bibliotekdatabaser fra en arkivfil. Nåværende databaser flyttes til .bak før bytte.', + backupLibraryImportConfirm: 'Dette vil erstatte nåværende bibliotekdatabaser og flytte dem til .bak. Fortsette?', + backupLibraryExportSuccess: 'Databasesikkerhetskopi eksportert.', + backupLibraryImportSuccess: 'Databasesikkerhetskopi importert.', + backupLibraryImportError: 'Kunne ikke importere databasesikkerhetskopi.', + backupOverlayExportTitle: 'Oppretter sikkerhetskopi…', + backupOverlayImportTitle: 'Gjenoppretter sikkerhetskopi…', + backupOverlayHint: 'Du kan holde vinduet åpent mens vi behandler filer. Inndata er midlertidig låst.', }; diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index a7d77784..485cc25e 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -517,4 +517,74 @@ export const settings = { floatingPlayerBarSub: 'Houd de spelerbalk zwevend boven de inhoud', uiScaleTitle: 'Interface schaal', uiScaleLabel: 'Zoom', + confirmDeleteServerLibrary: 'Ook de lokale bibliotheekindex van deze server verwijderen? Klik op Annuleren om de gecachte kopie voor offline gebruik te behouden.', + waveformCacheClearBtn: 'Waveform-cache wissen', + waveformCacheCleared: 'Waveform-cache gewist ({{count}} rijen).', + waveformCacheClearFailed: 'Waveform-cache wissen mislukt.', + useCustomTitlebar: 'Aangepaste titelbalk', + useCustomTitlebarDesc: 'Vervang de systeemtitelbalk door een ingebouwde balk die bij het app-thema past. Schakel uit om de native GNOME/GTK-titelbalk te gebruiken.', + analyticsStrategyTitle: 'Analysestrategieën', + analyticsStrategyDesc: 'Kies de analysemethode voor de bibliotheek.', + analyticsStrategyLabel: 'Strategie', + analyticsStrategyLazy: 'Lui', + analyticsStrategyLazyDesc: 'Analyseert alleen wanneer tracks worden afgespeeld of in de hot/offline-cache terechtkomen — minimaal CPU- en netwerkgebruik.', + analyticsStrategyAdvanced: 'Agressief', + analyticsStrategyAdvancedDesc: 'Agressievere achtergrondscan. Na voltooiing krijg je BPM voor alle tracks plus directe loudness- en waveform-lading.', + analyticsStrategyPriorityTitle: 'Analyseprioriteit (altijd actief)', + analyticsStrategyPriorityHigh: '1 — Nu afspelen: eerst downloaden en analyseren (playback-bytes, loudness-backfill of HTTP indien nodig).', + analyticsStrategyPriorityMiddle: '2 — Hierna: de volgende ~5 wachtrijtracks, preload-next en hot-cache-buren — analyse vanuit cache/downloads waar mogelijk; HTTP-backfill alleen bij miss.', + analyticsStrategyPriorityLow: '3 — Bibliotheekbatch (alleen Agressief): automatische achtergrondwachtrij achteraan; wordt bijgevuld via watermark (~3× workers) zonder op een lege wachtrij te wachten.', + analyticsStrategyServerLabel: 'Server', + analyticsStrategyProgressLabel: 'Analysevoortgang:', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: 'Acties', + analyticsStrategyParallelismLabel: 'Parallelle workers', + analyticsStrategyParallelismValue: '{{n}} threads', + analyticsStrategyParallelismDesc: 'Hoeveel tracks tegelijk worden gedownload en geanalyseerd (HTTP + CPU). Hogere waarden versnellen het inhalen van de bibliotheek, maar gebruiken meer netwerk en CPU.', + analyticsStrategyAdvancedWarning: 'Agressief gebruikt meer CPU en netwerkbandbreedte terwijl de bibliotheekbatch op de achtergrond draait.', + analyticsStrategyServerRemoved: 'Verwijderde server', + analyticsStrategyClearAction: 'Analyse wissen', + analyticsStrategyClearTitle: 'Analyse wissen?', + analyticsStrategyClearDesc: 'Analyse wissen voor {{server}} verwijdert gecachte waveforms en loudness. Deze server later opnieuw toevoegen kan een lange herindexering vereisen.', + analyticsStrategyClearCancel: 'Annuleren', + analyticsStrategyClearConfirm: 'Analyse wissen', + analyticsStrategyClearSuccess: 'Analyse gewist voor de verwijderde server.', + analyticsStrategyClearError: 'Analyse wissen voor de verwijderde server mislukt.', + analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, geblokkeerd door mislukte tracks)', + analyticsFailedTracksOpenTitle: 'Mislukte tracks: {{count}}', + analyticsFailedTracksTitle: 'Mislukte analysetracks — {{server}}', + analyticsFailedTracksDesc: 'Deze tracks faalden herhaaldelijk bij decode/enrichment en zijn uitgesloten van automatische achtergrondanalyse.', + analyticsFailedTracksLoading: 'Lijst met mislukte tracks laden…', + analyticsFailedTracksEmpty: 'Geen mislukte tracks voor deze server.', + analyticsFailedTracksClose: 'Sluiten', + analyticsFailedTracksExport: 'Lijst exporteren', + analyticsFailedTracksExportSuccess: 'Lijst met mislukte tracks geëxporteerd ({{count}}).', + analyticsFailedTracksExportError: 'Lijst met mislukte tracks kon niet worden geëxporteerd.', + analyticsFailedTracksRescan: 'Mislukte tracks opnieuw scannen', + analyticsFailedTracksRescanSuccess: 'Opnieuw scannen ingepland voor {{count}} mislukte tracks.', + analyticsFailedTracksRescanError: 'Mislukte tracks opnieuw scannen mislukt.', + analyticsFailedTracksLoadError: 'Mislukte tracks konden niet worden geladen.', + backupImportAny: 'Back-up importeren', + backupImportAnyConfirm: 'Geselecteerd back-upbestand importeren en inhoud toepassen (instellingen, bibliotheekdatabases of volledig). Doorgaan?', + backupModeFull: 'Volledig', + backupModeLibrary: 'Bibliotheekdatabases', + backupModeConfig: 'Configuratie', + backupFullDesc: 'Archiveert instellingen en bibliotheekdatabases in één back-upbestand.', + backupFullExport: 'Volledige back-up exporteren', + backupFullImport: 'Volledige back-up importeren', + backupFullImportConfirm: 'Dit overschrijft instellingen, vervangt huidige bibliotheekdatabases en verplaatst ze naar .bak. Doorgaan?', + backupFullExportSuccess: 'Volledige back-up geëxporteerd.', + backupFullImportSuccess: 'Volledige back-up geïmporteerd — herladen…', + backupFullImportError: 'Volledige back-up kon niet worden geïmporteerd.', + backupLibraryExport: 'Databases exporteren', + backupLibraryExportDesc: 'Maakt een gecomprimeerd archief met schone SQLite-snapshots van bibliotheekdatabases.', + backupLibraryImport: 'Databases importeren', + backupLibraryImportDesc: 'Herstelt bibliotheekdatabases uit een archiefbestand. Huidige databases worden voor het omschakelen naar .bak verplaatst.', + backupLibraryImportConfirm: 'Dit vervangt huidige bibliotheekdatabases en verplaatst ze naar .bak. Doorgaan?', + backupLibraryExportSuccess: 'Databaseback-up geëxporteerd.', + backupLibraryImportSuccess: 'Databaseback-up geïmporteerd.', + backupLibraryImportError: 'Databaseback-up kon niet worden geïmporteerd.', + backupOverlayExportTitle: 'Back-up maken…', + backupOverlayImportTitle: 'Back-up herstellen…', + backupOverlayHint: 'Je kunt het venster open laten terwijl we bestanden verwerken. Invoer is tijdelijk vergrendeld.', }; diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 45765fa4..154b8be5 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -522,4 +522,69 @@ export const settings = { floatingPlayerBarSub: 'Păstrează bara playerului plutind deasupra conținutului', uiScaleTitle: 'Scara interfaței', uiScaleLabel: 'Zoom', + confirmDeleteServerLibrary: 'Ștergi și indexul local al bibliotecii pentru acest server? Apasă Anulează pentru a păstra copia din cache pentru utilizare offline.', + analyticsStrategyTitle: 'Strategii de analiză', + analyticsStrategyDesc: 'Alege stilul de analiză pentru bibliotecă.', + analyticsStrategyLabel: 'Strategie', + analyticsStrategyLazy: 'Leneșă', + analyticsStrategyLazyDesc: 'Analizează doar când piesele sunt redate sau intră în cache-ul hot/offline — consum minim de CPU și rețea.', + analyticsStrategyAdvanced: 'Agresivă', + analyticsStrategyAdvancedDesc: 'Scanare de fundal mai agresivă. După finalizare, primești BPM pentru toate piesele și încărcare instantă pentru loudness și waveform.', + analyticsStrategyPriorityTitle: 'Prioritate analiză (mereu activă)', + analyticsStrategyPriorityHigh: '1 — Redare curentă: descarcă și analizează mai întâi (bytes de playback, loudness backfill sau HTTP când e nevoie).', + analyticsStrategyPriorityMiddle: '2 — Urmează: următoarele ~5 piese din coadă, preload pentru următoarea și vecinii din hot-cache — analiză din cache/descărcări când se poate; backfill HTTP doar la miss.', + analyticsStrategyPriorityLow: '3 — Lot bibliotecă (doar Agresivă): coadă automată în fundal la final; reumplere pe watermark (~3× workers) fără a aștepta golirea cozii.', + analyticsStrategyServerLabel: 'Server', + analyticsStrategyProgressLabel: 'Progres analiză:', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: 'Acțiuni', + analyticsStrategyParallelismLabel: 'Workeri paraleli', + analyticsStrategyParallelismValue: '{{n}} thread-uri', + analyticsStrategyParallelismDesc: 'Câte piese se descarcă și se analizează simultan (HTTP + CPU). Valori mai mari accelerează procesarea bibliotecii, dar folosesc mai multă rețea și CPU.', + analyticsStrategyAdvancedWarning: 'Modul agresiv folosește mai mult CPU și lățime de bandă cât timp lotul bibliotecii rulează în fundal.', + analyticsStrategyServerRemoved: 'Server eliminat', + analyticsStrategyClearAction: 'Șterge analiza', + analyticsStrategyClearTitle: 'Ștergi analiza?', + analyticsStrategyClearDesc: 'Ștergerea analizei pentru {{server}} elimină waveforms și loudness din cache. Dacă readaugi serverul mai târziu, poate necesita reindexare îndelungată.', + analyticsStrategyClearCancel: 'Anulează', + analyticsStrategyClearConfirm: 'Șterge analiza', + analyticsStrategyClearSuccess: 'Analiza a fost ștearsă pentru serverul eliminat.', + analyticsStrategyClearError: 'Nu s-a putut șterge analiza pentru serverul eliminat.', + analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, blocat de piese eșuate)', + analyticsFailedTracksOpenTitle: 'Piese eșuate: {{count}}', + analyticsFailedTracksTitle: 'Piese cu analiză eșuată — {{server}}', + analyticsFailedTracksDesc: 'Aceste piese au eșuat repetat la decode/enrichment și sunt excluse din analiza automată în fundal.', + analyticsFailedTracksLoading: 'Se încarcă lista pieselor eșuate…', + analyticsFailedTracksEmpty: 'Nu există piese eșuate pentru acest server.', + analyticsFailedTracksClose: 'Închide', + analyticsFailedTracksExport: 'Exportă lista', + analyticsFailedTracksExportSuccess: 'Lista pieselor eșuate a fost exportată ({{count}}).', + analyticsFailedTracksExportError: 'Nu s-a putut exporta lista pieselor eșuate.', + analyticsFailedTracksRescan: 'Rescan pentru piese eșuate', + analyticsFailedTracksRescanSuccess: 'Rescan programat pentru {{count}} piese eșuate.', + analyticsFailedTracksRescanError: 'Nu s-a putut porni rescan-ul pentru piesele eșuate.', + analyticsFailedTracksLoadError: 'Nu s-au putut încărca piesele eșuate.', + backupImportAny: 'Importă backup', + backupImportAnyConfirm: 'Importă fișierul de backup selectat și aplică conținutul (setări, baze de date bibliotecă sau complet). Continui?', + backupModeFull: 'Complet', + backupModeLibrary: 'Baze de date bibliotecă', + backupModeConfig: 'Configurație', + backupFullDesc: 'Arhivează setările și bazele de date ale bibliotecii într-un singur fișier backup.', + backupFullExport: 'Exportă backup complet', + backupFullImport: 'Importă backup complet', + backupFullImportConfirm: 'Acest lucru va suprascrie setările, va înlocui bazele de date curente și le va muta în .bak. Continui?', + backupFullExportSuccess: 'Backup complet exportat.', + backupFullImportSuccess: 'Backup complet importat — reîncărcare…', + backupFullImportError: 'Nu s-a putut importa backup-ul complet.', + backupLibraryExport: 'Exportă bazele de date', + backupLibraryExportDesc: 'Creează o arhivă comprimată cu snapshot-uri SQLite curate ale bazelor de date de bibliotecă.', + backupLibraryImport: 'Importă bazele de date', + backupLibraryImportDesc: 'Restaurează bazele de date ale bibliotecii dintr-un fișier arhivă. Bazele curente sunt mutate în .bak înainte de comutare.', + backupLibraryImportConfirm: 'Acest lucru va înlocui bazele de date curente și le va muta în .bak. Continui?', + backupLibraryExportSuccess: 'Backup-ul bazelor de date a fost exportat.', + backupLibraryImportSuccess: 'Backup-ul bazelor de date a fost importat.', + backupLibraryImportError: 'Nu s-a putut importa backup-ul bazelor de date.', + backupOverlayExportTitle: 'Se creează backup-ul…', + backupOverlayImportTitle: 'Se restaurează backup-ul…', + backupOverlayHint: 'Poți lăsa fereastra deschisă cât timp procesăm fișierele. Introducerea este blocată temporar.', }; diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index 8a4c74cc..0580a47c 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -315,6 +315,20 @@ export const settings = { analyticsStrategyClearConfirm: 'Очистить анализ', analyticsStrategyClearSuccess: 'Анализ удалён для удалённого сервера.', analyticsStrategyClearError: 'Не удалось очистить анализ для удалённого сервера.', + analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, треки помечены как проблемные)', + analyticsFailedTracksOpenTitle: 'Проблемных треков: {{count}}', + analyticsFailedTracksTitle: 'Проблемные треки анализа — {{server}}', + analyticsFailedTracksDesc: 'Эти треки многократно падали на decode/enrichment и исключены из фонового автоанализа.', + analyticsFailedTracksLoading: 'Загрузка списка проблемных треков…', + analyticsFailedTracksEmpty: 'Для этого сервера проблемных треков нет.', + analyticsFailedTracksClose: 'Закрыть', + analyticsFailedTracksExport: 'Экспорт списка', + analyticsFailedTracksExportSuccess: 'Список проблемных треков экспортирован ({{count}}).', + analyticsFailedTracksExportError: 'Не удалось экспортировать список проблемных треков.', + analyticsFailedTracksRescan: 'Рескан проблемных треков', + analyticsFailedTracksRescanSuccess: 'Рескан запланирован для {{count}} проблемных треков.', + analyticsFailedTracksRescanError: 'Не удалось запустить рескан проблемных треков.', + analyticsFailedTracksLoadError: 'Не удалось загрузить список проблемных треков.', randomMixTitle: 'Чёрный список случайного микса', luckyMixMenuTitle: 'Показывать «Мне повезёт» в меню', luckyMixMenuDesc: @@ -590,4 +604,5 @@ export const settings = { floatingPlayerBarSub: 'Держать панель плеера плавающей над содержимым', uiScaleTitle: 'Масштаб интерфейса', uiScaleLabel: 'Масштаб', + confirmDeleteServerLibrary: 'Также удалить локальный индекс библиотеки этого сервера? Нажмите «Отмена», чтобы сохранить кэш-копию для офлайн-использования.', }; diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 9d40cbb8..bc1f6261 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -516,4 +516,74 @@ export const settings = { floatingPlayerBarSub: '保持播放栏悬浮在内容上方', uiScaleTitle: '界面缩放', uiScaleLabel: '缩放', + confirmDeleteServerLibrary: '是否同时删除该服务器的本地库索引?点击“取消”可保留缓存副本以供离线使用。', + waveformCacheClearBtn: '清除波形缓存', + waveformCacheCleared: '波形缓存已清除({{count}} 行)。', + waveformCacheClearFailed: '清除波形缓存失败。', + useCustomTitlebar: '自定义标题栏', + useCustomTitlebarDesc: '用与应用主题匹配的内置标题栏替换系统标题栏。关闭后将使用原生 GNOME/GTK 标题栏。', + analyticsStrategyTitle: '分析策略', + analyticsStrategyDesc: '选择库的分析方式。', + analyticsStrategyLabel: '策略', + analyticsStrategyLazy: '懒加载', + analyticsStrategyLazyDesc: '仅在播放歌曲或进入热缓存/离线缓存时分析——CPU 和网络占用最低。', + analyticsStrategyAdvanced: '激进', + analyticsStrategyAdvancedDesc: '更激进的后台扫描。完成后可获得所有歌曲 BPM,并可即时加载响度与波形。', + analyticsStrategyPriorityTitle: '分析优先级(始终生效)', + analyticsStrategyPriorityHigh: '1 — 当前播放:优先下载并分析(播放字节、响度回填,或在需要时走 HTTP)。', + analyticsStrategyPriorityMiddle: '2 — 即将播放:队列中接下来的约 5 首、预加载下一首以及热缓存邻近曲目——优先从缓存/已下载字节分析,未命中时才做 HTTP 回填。', + analyticsStrategyPriorityLow: '3 — 库批处理(仅“激进”):自动后台队列排在末尾;按水位线(约 3× workers)补充,无需等待队列清空。', + analyticsStrategyServerLabel: '服务器', + analyticsStrategyProgressLabel: '分析进度:', + analyticsStrategyProgressValue: '{{percent}}% ({{done}}/{{total}})', + analyticsStrategyActionsLabel: '操作', + analyticsStrategyParallelismLabel: '并行 worker', + analyticsStrategyParallelismValue: '{{n}} 线程', + analyticsStrategyParallelismDesc: '同时下载并分析的歌曲数量(HTTP + CPU)。数值越高,补齐库分析越快,但会占用更多网络和 CPU。', + analyticsStrategyAdvancedWarning: '“激进”模式在后台库批处理运行期间会占用更多 CPU 和网络带宽。', + analyticsStrategyServerRemoved: '已移除的服务器', + analyticsStrategyClearAction: '清除分析', + analyticsStrategyClearTitle: '清除分析?', + analyticsStrategyClearDesc: '清除 {{server}} 的分析将删除缓存的波形与响度数据。稍后重新添加该服务器可能需要较长时间重新索引。', + analyticsStrategyClearCancel: '取消', + analyticsStrategyClearConfirm: '清除分析', + analyticsStrategyClearSuccess: '已清除已移除服务器的分析数据。', + analyticsStrategyClearError: '清除已移除服务器的分析数据失败。', + analyticsStrategyProgressEmptyAfterFailed: '100%(0/0,受失败歌曲阻塞)', + analyticsFailedTracksOpenTitle: '失败歌曲:{{count}}', + analyticsFailedTracksTitle: '分析失败歌曲 — {{server}}', + analyticsFailedTracksDesc: '这些歌曲在 decode/enrichment 中反复失败,已被排除在自动后台分析之外。', + analyticsFailedTracksLoading: '正在加载失败歌曲列表…', + analyticsFailedTracksEmpty: '该服务器没有失败歌曲。', + analyticsFailedTracksClose: '关闭', + analyticsFailedTracksExport: '导出列表', + analyticsFailedTracksExportSuccess: '失败歌曲列表已导出({{count}})。', + analyticsFailedTracksExportError: '导出失败歌曲列表失败。', + analyticsFailedTracksRescan: '重新扫描失败歌曲', + analyticsFailedTracksRescanSuccess: '已为 {{count}} 首失败歌曲安排重新扫描。', + analyticsFailedTracksRescanError: '无法重新扫描失败歌曲。', + analyticsFailedTracksLoadError: '无法加载失败歌曲。', + backupImportAny: '导入备份', + backupImportAnyConfirm: '导入所选备份文件并应用其内容(设置、库数据库或完整备份)。是否继续?', + backupModeFull: '完整', + backupModeLibrary: '库数据库', + backupModeConfig: '配置', + backupFullDesc: '将设置与库数据库打包到一个备份文件中。', + backupFullExport: '导出完整备份', + backupFullImport: '导入完整备份', + backupFullImportConfirm: '这将覆盖设置、替换当前库数据库并移动到 .bak。是否继续?', + backupFullExportSuccess: '完整备份已导出。', + backupFullImportSuccess: '完整备份已导入——正在重载…', + backupFullImportError: '无法导入完整备份。', + backupLibraryExport: '导出数据库', + backupLibraryExportDesc: '创建一个压缩归档,其中包含干净的库数据库 SQLite 快照。', + backupLibraryImport: '导入数据库', + backupLibraryImportDesc: '从归档文件恢复库数据库。切换前会将当前数据库移动到 .bak。', + backupLibraryImportConfirm: '这将替换当前库数据库并移动到 .bak。是否继续?', + backupLibraryExportSuccess: '数据库备份已导出。', + backupLibraryImportSuccess: '数据库备份已导入。', + backupLibraryImportError: '无法导入数据库备份。', + backupOverlayExportTitle: '正在创建备份…', + backupOverlayImportTitle: '正在恢复备份…', + backupOverlayHint: '处理文件时你可以保持窗口打开。输入会暂时锁定。', };