fix(analysis): persist failed tracks and reconcile progress counts (#867)

* fix(analysis): persist failed-track suppression and reduce aggressive polling

Persist unsupported/broken analysis tracks as failed entries and expose them in Settings with track metadata, export, and targeted rescan actions. Also make aggressive-mode completion checks cheap by gating re-entry on live track-count changes with a startup seed and 5-minute recheck cadence.

* fix(analysis): mark unsupported decode tracks as failed in cpu-seed

When full-seed falls back to waveform-only (no EBU loudness) or enrichment decode fails, persist analysis_track status as failed so aggressive backfill does not requeue the same unsupported tracks indefinitely.

* fix(analysis): reconcile legacy ready tracks without loudness

Auto-mark legacy ready tracks that only miss loudness as failed during needs-work checks so analysis progress converges instead of staying permanently pending.

* docs(changelog): add failed-analysis recovery notes for PR 867

Document persistent failed-track handling, analytics strategy controls, and low-cost aggressive-mode recheck behavior in the 1.47.0 changelog.

* fix(i18n): align settings locale coverage across all languages

Sync missing Settings keys for all shipped locales and replace recent English fallbacks with localized strings so analytics and backup UI text stays consistent outside en/ru.
This commit is contained in:
cucadmuh
2026-05-25 01:37:15 +03:00
committed by GitHub
parent 820f71c421
commit bc85065316
21 changed files with 1263 additions and 17 deletions
+10
View File
@@ -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.
@@ -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<R: Runtime>(
}
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,
@@ -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,
};
@@ -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<Connection>,
}
@@ -110,6 +117,10 @@ fn track_id_cache_variants(id: &str) -> Vec<String> {
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<Option<(String, i64)>, 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<i64, String> {
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<usize>,
) -> Result<Vec<FailedTrackEntry>, 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<u64, String> {
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<Option<(String, i64)>, 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<i64, String> {
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<usize>,
) -> Result<Vec<FailedTrackEntry>, 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<FailedTrackEntry> = Vec::new();
let mut seen = std::collections::HashSet::<String>::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");
}
}
@@ -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::<analysis_cache::AnalysisCache>() {
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).
@@ -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<analysis_cache::AnalysisDeleteServerReport> for AnalysisDeleteServerReportDto {
fn from(value: analysis_cache::AnalysisDeleteServerReport) -> Self {
Self {
@@ -69,6 +77,16 @@ impl From<analysis_cache::AnalysisDeleteServerReport> for AnalysisDeleteServerRe
}
}
impl From<analysis_cache::FailedTrackEntry> 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<i64, String> {
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<u32>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<Vec<AnalysisFailedTrackDto>, 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<Vec<String>>,
cache: tauri::State<'_, analysis_cache::AnalysisCache>,
) -> Result<u64, String> {
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::<Vec<_>>();
cache.clear_failed_tracks(&server_id, &track_ids)
}
#[tauri::command]
pub fn analysis_migrate_server_index_keys(
mappings: Vec<AnalysisServerKeyMigrationDto>,
@@ -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<bool, String> {
if let Some(cache) = app.try_state::<AnalysisCache>() {
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");
}
}
@@ -118,6 +118,19 @@ pub fn library_analysis_progress(
}))
}
#[tauri::command]
pub fn library_count_live_tracks(
runtime: State<'_, LibraryRuntime>,
server_id: String,
) -> Result<i64, String> {
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>,
+4
View File
@@ -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,
+38
View File
@@ -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<LibraryAnalysisProgressDto>('library_analysis_progress', { serverId: indexKey });
}
export function libraryCountLiveTracks(serverId: string): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('library_count_live_tracks', { serverId: indexKey });
}
export function analysisDeleteAllForServer(
serverId: string,
): Promise<AnalysisDeleteServerReportDto> {
@@ -89,6 +100,33 @@ export function analysisDeleteAllForServer(
return invoke<AnalysisDeleteServerReportDto>('analysis_delete_all_for_server', { serverId: indexKey });
}
export function analysisGetFailedTrackCount(serverId: string): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('analysis_get_failed_track_count', { serverId: indexKey });
}
export function analysisListFailedTracks(
serverId: string,
limit?: number,
): Promise<AnalysisFailedTrackDto[]> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<AnalysisFailedTrackDto[]>('analysis_list_failed_tracks', {
serverId: indexKey,
limit: limit ?? null,
});
}
export function analysisClearFailedTracks(
serverId: string,
trackIds?: string[],
): Promise<number> {
const indexKey = serverIndexKeyForId(serverId);
return invoke<number>('analysis_clear_failed_tracks', {
serverId: indexKey,
trackIds: trackIds ?? null,
});
}
export type AnalysisBackfillPriority = 'high' | 'middle' | 'low';
export function analysisSetPipelineParallelism(workers: number): Promise<void> {
@@ -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<Record<string, LibraryAnalysisProgressDto | null>>({});
const [failedCountByServer, setFailedCountByServer] = useState<Record<string, number>>({});
const [failedTracksByServer, setFailedTracksByServer] = useState<Record<string, FailedTrackView[]>>({});
const [clearTarget, setClearTarget] = useState<ClearTarget | null>(null);
const [clearingServerId, setClearingServerId] = useState<string | null>(null);
const [failedModalTarget, setFailedModalTarget] = useState<FailedModalTarget | null>(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<string, LibraryTrackDto>();
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 (
<SettingsSubSection
title={t('settings.analyticsStrategyTitle')}
@@ -160,6 +288,7 @@ export default function AnalyticsStrategySection() {
const advancedParallelism = getAdvancedParallelismForServer(server.id);
const key = serverIndexKeyForProfile(server);
const progress = progressByServer[key] ?? null;
const failedCount = failedCountByServer[key] ?? 0;
const label = serverListDisplayLabel(server, servers);
return (
<tr key={server.id} style={{ borderTop: '1px solid var(--border-subtle, rgba(255,255,255,0.06))' }}>
@@ -207,7 +336,23 @@ export default function AnalyticsStrategySection() {
)}
</td>
<td style={{ padding: '10px', fontSize: 12, color: 'var(--text-secondary)' }}>
{progressLabel(progress) ?? '—'}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{progressLabel(progress, failedCount) ?? '—'}</span>
{failedCount > 0 && (
<button
type="button"
className="btn btn-sm btn-surface"
onClick={() => void openFailedTracksModal({ serverId: server.id, label, indexKey: key })}
title={t('settings.analyticsFailedTracksOpenTitle', { count: failedCount })}
style={{ padding: '2px 8px', minHeight: 24 }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<TriangleAlert size={13} />
{failedCount}
</span>
</button>
)}
</div>
</td>
<td style={{ padding: '10px' }}>
<button
@@ -366,6 +511,127 @@ export default function AnalyticsStrategySection() {
</div>,
document.body,
)}
{failedModalTarget &&
createPortal(
<div
className="modal-overlay"
onClick={() => !failedActionBusy && setFailedModalTarget(null)}
role="dialog"
aria-modal="true"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div
className="modal-content"
onClick={e => e.stopPropagation()}
style={{ maxWidth: '680px', width: 'min(680px, 92vw)' }}
>
<button
className="modal-close"
onClick={() => setFailedModalTarget(null)}
aria-label={t('settings.analyticsFailedTracksClose')}
disabled={failedActionBusy}
>
<X size={18} />
</button>
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>
{t('settings.analyticsFailedTracksTitle', { server: failedModalTarget.label })}
</h3>
<p style={{ color: 'var(--text-secondary)', marginBottom: '1rem', lineHeight: 1.5 }}>
{t('settings.analyticsFailedTracksDesc')}
</p>
{failedModalLoading ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t('settings.analyticsFailedTracksLoading')}
</div>
) : (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
{t('settings.analyticsFailedTracksEmpty')}
</div>
) : (
<div
style={{
border: '1px solid var(--border-subtle, rgba(255,255,255,0.08))',
borderRadius: 10,
maxHeight: 280,
overflowY: 'auto',
marginBottom: 12,
}}
>
{(failedTracksByServer[failedModalTarget.indexKey] ?? []).map(track => (
<div
key={`${track.trackId}:${track.md5_16kb}:${track.updatedAt}`}
style={{
padding: '8px 10px',
borderBottom: '1px solid var(--border-subtle, rgba(255,255,255,0.06))',
fontSize: 12,
color: 'var(--text-secondary)',
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 10,
}}
>
<div style={{ minWidth: 0 }}>
<div
style={{
wordBreak: 'break-word',
color: 'var(--text-primary)',
fontFamily: 'var(--font-ui)',
fontSize: 12,
}}
>
{track.title?.trim() || track.trackId}
</div>
<div style={{ wordBreak: 'break-all', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
{track.serverPath?.trim() || track.trackId}
</div>
</div>
<span style={{ whiteSpace: 'nowrap', color: 'var(--text-muted)' }}>
{new Date(track.updatedAt * 1000).toLocaleString()}
</span>
</div>
))}
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-surface"
onClick={handleExportFailedTracks}
disabled={failedModalLoading || failedActionBusy || (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<FileDown size={14} />
{t('settings.analyticsFailedTracksExport')}
</span>
</button>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-primary"
onClick={() => setFailedModalTarget(null)}
disabled={failedActionBusy}
>
{t('settings.analyticsFailedTracksClose')}
</button>
<button
className="btn btn-surface"
onClick={handleRescanFailedTracks}
disabled={failedModalLoading || failedActionBusy || (failedTracksByServer[failedModalTarget.indexKey] ?? []).length === 0}
style={{ borderColor: 'var(--warning, #f59e0b)', color: 'var(--warning, #f59e0b)' }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<RefreshCcw size={14} />
{t('settings.analyticsFailedTracksRescan')}
</span>
</button>
</div>
</div>
</div>
</div>,
document.body,
)}
</SettingsSubSection>
);
}
+35
View File
@@ -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<string | null>(null);
const completedTotalRef = useRef<number | null>(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]);
}
+67
View File
@@ -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.',
};
+14
View File
@@ -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.',
+68
View File
@@ -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.',
};
+70
View File
@@ -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 lindex 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 donde',
waveformCacheCleared: 'Cache des formes donde vidé ({{count}} lignes).',
waveformCacheClearFailed: 'Impossible de vider le cache des formes donde.',
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 lapp. Désactivez pour utiliser la barre GNOME/GTK native.',
analyticsStrategyTitle: 'Stratégies danalyse',
analyticsStrategyDesc: 'Choisissez le style danalyse 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 donde.',
analyticsStrategyPriorityTitle: 'Priorité danalyse (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 dabsence.',
analyticsStrategyPriorityLow: '3 — Lot bibliothèque (Agressive uniquement) : file darrière-plan automatique en fin de file ; réalimentée par watermark (~3× workers) sans attendre une file vide.',
analyticsStrategyServerLabel: 'Server',
analyticsStrategyProgressLabel: 'Progression de lanalyse :',
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 lexécution du lot bibliothèque en arrière-plan.',
analyticsStrategyServerRemoved: 'Serveur supprimé',
analyticsStrategyClearAction: 'Effacer lanalyse',
analyticsStrategyClearTitle: 'Effacer lanalyse ?',
analyticsStrategyClearDesc: 'Effacer lanalyse de {{server}} supprime les formes donde 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 lanalyse',
analyticsStrategyClearSuccess: 'Analyse effacée pour le serveur supprimé.',
analyticsStrategyClearError: 'Échec de leffacement de lanalyse pour le serveur supprimé.',
analyticsStrategyProgressEmptyAfterFailed: '100% (0/0, bloqué par des pistes en échec)',
analyticsFailedTracksOpenTitle: 'Pistes en échec : {{count}}',
analyticsFailedTracksTitle: 'Pistes danalyse en échec — {{server}}',
analyticsFailedTracksDesc: 'Ces pistes ont échoué de manière répétée au décodage/enrichissement et sont exclues de lanalyse 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 dexporter la liste des pistes en échec.',
analyticsFailedTracksRescan: 'Relancer lanalyse des pistes en échec',
analyticsFailedTracksRescanSuccess: 'Relance programmée pour {{count}} pistes en échec.',
analyticsFailedTracksRescanError: 'Impossible de relancer lanalyse 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 dimporter 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 darchive. 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 dimporter 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.',
};
+74
View File
@@ -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.',
};
+70
View File
@@ -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.',
};
+65
View File
@@ -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.',
};
+15
View File
@@ -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: 'Также удалить локальный индекс библиотеки этого сервера? Нажмите «Отмена», чтобы сохранить кэш-копию для офлайн-использования.',
};
+70
View File
@@ -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: '处理文件时你可以保持窗口打开。输入会暂时锁定。',
};