diff --git a/Cargo.lock b/Cargo.lock index 1f967a4..5c73d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,6 +782,7 @@ dependencies = [ "conduction-library", "rekordcrate", "serde", + "serde_json", "thiserror 1.0.69", "tracing", "utoipa", diff --git a/crates/conduction-app/src/commands.rs b/crates/conduction-app/src/commands.rs index 9a1a9f8..457561e 100644 --- a/crates/conduction-app/src/commands.rs +++ b/crates/conduction-app/src/commands.rs @@ -18,9 +18,14 @@ use tracing::{info, warn}; use uuid::Uuid; use crate::audio_engine::{parse_deck, parse_tempo_range, AudioCommand, AudioHandle, MixerSnapshot}; +use crate::export_state::ExportRegistryHandle; use crate::setlist_state::SetlistHandle; use conduction_conductor::Template; use conduction_core::{Setlist, SetlistEntry, SetlistEntryId, SetlistId, TransitionSpec}; +use conduction_export::{ + ConflictStrategy, ExportOptions, Format, FormatInfo, ImportOptions, LibraryExportReport, + LibraryImportReport, +}; use crate::library_state::{LibraryHandle, TrackSummary}; use crate::settings::{AppSettings, SettingsHandle}; use crate::system_stats::{ResourceStats, SystemStatsHandle}; @@ -1246,3 +1251,66 @@ pub fn yt_download( ); result } + +// ======== Library-level export / import (format-aware) ======== +// +// These commands dispatch on a Format chosen by the UI to a plugin +// registered in the ExportRegistryHandle. Phase 0 ships the scaffold; +// concrete plugins land in Phase 1+ (rekordbox XML), Phase 6+ (Serato), +// etc. Until then every Format returns "no exporter registered". + +#[tauri::command] +pub fn list_export_formats(registry: State<'_, ExportRegistryHandle>) -> Vec { + registry.0.export_formats() +} + +#[tauri::command] +pub fn list_import_formats(registry: State<'_, ExportRegistryHandle>) -> Vec { + registry.0.import_formats() +} + +#[tauri::command] +pub fn library_export( + registry: State<'_, ExportRegistryHandle>, + library: State<'_, LibraryHandle>, + format: Format, + destination: String, + dry_run: Option, + extra: Option, +) -> CmdResult { + let exporter = registry + .0 + .exporter(format) + .ok_or_else(|| format!("no exporter registered for {}", format.id()))?; + let options = ExportOptions { + destination: PathBuf::from(destination), + dry_run: dry_run.unwrap_or(false), + extra, + }; + library + .with_library(|lib| exporter.export(lib, &options)) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn library_import( + registry: State<'_, ExportRegistryHandle>, + library: State<'_, LibraryHandle>, + format: Format, + source: String, + conflict_strategy: Option, + extra: Option, +) -> CmdResult { + let importer = registry + .0 + .importer(format) + .ok_or_else(|| format!("no importer registered for {}", format.id()))?; + let options = ImportOptions { + source: PathBuf::from(source), + conflict_strategy: conflict_strategy.unwrap_or_default(), + extra, + }; + library + .with_library(|lib| importer.import(lib, &options)) + .map_err(|e| e.to_string()) +} diff --git a/crates/conduction-app/src/export_state.rs b/crates/conduction-app/src/export_state.rs new file mode 100644 index 0000000..705b132 --- /dev/null +++ b/crates/conduction-app/src/export_state.rs @@ -0,0 +1,18 @@ +//! Tauri State wrapper around the conduction-export plugin registry. +//! +//! Held as an `Arc` because registration only happens at +//! boot (in `run()`), after which the registry is read-only for the +//! lifetime of the process. Cloning the handle is cheap. + +use std::sync::Arc; + +use conduction_export::PluginRegistry; + +#[derive(Clone)] +pub struct ExportRegistryHandle(pub Arc); + +impl ExportRegistryHandle { + pub fn new(registry: PluginRegistry) -> Self { + Self(Arc::new(registry)) + } +} diff --git a/crates/conduction-app/src/lib.rs b/crates/conduction-app/src/lib.rs index 6dc34a4..fd86439 100644 --- a/crates/conduction-app/src/lib.rs +++ b/crates/conduction-app/src/lib.rs @@ -6,6 +6,7 @@ pub mod audio_engine; pub mod commands; +pub mod export_state; pub mod http_api; pub mod library_state; pub mod settings; @@ -28,6 +29,8 @@ pub fn run() { let library = library_state::LibraryHandle::open_default().expect("library must open"); let setlists = setlist_state::SetlistHandle::new(library.shared()); let stats = system_stats::SystemStatsHandle::new(); + let export_registry = + export_state::ExportRegistryHandle::new(conduction_export::default_registry()); info!("conduction-app booting"); // localhost WebAPI を別スレッドで起動。Tauri と同じ State インスタンスを共有する。 @@ -49,6 +52,7 @@ pub fn run() { .manage(stats) .manage(settings) .manage(setlists) + .manage(export_registry) .invoke_handler(tauri::generate_handler![ commands::load_track, commands::play, @@ -115,6 +119,10 @@ pub fn run() { commands::setlist_set_transition, commands::setlist_export, commands::setlist_import, + commands::list_export_formats, + commands::list_import_formats, + commands::library_export, + commands::library_import, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/crates/conduction-export/Cargo.toml b/crates/conduction-export/Cargo.toml index 79fe189..522c619 100644 --- a/crates/conduction-export/Cargo.toml +++ b/crates/conduction-export/Cargo.toml @@ -13,6 +13,7 @@ conduction-core.workspace = true conduction-analysis.workspace = true conduction-library.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tracing.workspace = true utoipa = "5" diff --git a/crates/conduction-export/src/api.rs b/crates/conduction-export/src/api.rs new file mode 100644 index 0000000..435d596 --- /dev/null +++ b/crates/conduction-export/src/api.rs @@ -0,0 +1,118 @@ +//! Library-level export/import trait surface. +//! +//! Each plugin (cset / rekordbox-xml / rekordbox-usb / serato) implements +//! either [`Exporter`], [`Importer`], or both. The host (conduction-app) +//! resolves the right plugin via [`crate::registry::PluginRegistry`] and +//! dispatches on the user's [`Format`] choice. + +use std::path::PathBuf; + +use conduction_library::Library; +use serde::{Deserialize, Serialize}; + +use crate::{ExportError, Format}; + +/// Inputs the host gives an exporter when the user hits "Export". +/// +/// Kept off the IPC schema deliberately — the `conduction-app` layer maps +/// plain-old strings/JSON from Tauri into this internal struct. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportOptions { + /// File path or directory the plugin writes to; semantics follow + /// `Format::target_kind`. + pub destination: PathBuf, + /// When set, the plugin must compute everything but skip filesystem writes. + #[serde(default)] + pub dry_run: bool, + /// Format-specific overrides (e.g. encryption key, USB volume label). + /// JSON so the IPC layer doesn't need a per-format DTO. + #[serde(default)] + pub extra: Option, +} + +/// How an importer should treat a track that already exists in the library. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ConflictStrategy { + /// Leave the existing row untouched. + Skip, + /// Merge incoming metadata into the existing row. + Update, + /// Replace the existing row in full. + Replace, +} + +impl Default for ConflictStrategy { + fn default() -> Self { + Self::Skip + } +} + +/// Inputs the host gives an importer when the user hits "Import". +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImportOptions { + pub source: PathBuf, + #[serde(default)] + pub conflict_strategy: ConflictStrategy, + #[serde(default)] + pub extra: Option, +} + +/// Result returned to the UI after a successful export. +/// +/// Distinct from the legacy `ExportReport` in `lib.rs`, which is the +/// rekordbox-USB-specific summary used by the older `execute(plan)` API. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LibraryExportReport { + pub format: Format, + pub tracks_written: usize, + pub bytes_written: u64, + /// Non-fatal issues the user should see (e.g. "track has no beatgrid, + /// skipped TEMPO export"). + #[serde(default)] + pub warnings: Vec, +} + +/// Result returned to the UI after a successful import. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct LibraryImportReport { + pub format: Format, + pub tracks_imported: usize, + pub tracks_updated: usize, + pub tracks_skipped: usize, + #[serde(default)] + pub warnings: Vec, +} + +/// One direction (out) of a format plugin. +/// +/// Takes `&mut Library` because `conduction-library::Library` uses a raw +/// `rusqlite::Connection` (no internal Mutex), and even read-only queries on +/// SQLite go through `&mut self` to keep the borrow story honest about +/// statement caching. +pub trait Exporter: Send + Sync { + fn format(&self) -> Format; + fn label(&self) -> &'static str { + self.format().label() + } + + fn export( + &self, + library: &mut Library, + options: &ExportOptions, + ) -> Result; +} + +/// One direction (in) of a format plugin. +pub trait Importer: Send + Sync { + fn format(&self) -> Format; + fn label(&self) -> &'static str { + self.format().label() + } + + fn import( + &self, + library: &mut Library, + options: &ImportOptions, + ) -> Result; +} diff --git a/crates/conduction-export/src/format.rs b/crates/conduction-export/src/format.rs new file mode 100644 index 0000000..aaed727 --- /dev/null +++ b/crates/conduction-export/src/format.rs @@ -0,0 +1,120 @@ +//! Library-wide export/import formats. +//! +//! Each variant is one "shape" that the user can pick from the Library screen. +//! Plugins implementing [`crate::api::Exporter`] / [`crate::api::Importer`] +//! advertise the variant(s) they support. + +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum Format { + /// Conduction's own library snapshot (single JSON-ish file). + Cset, + /// rekordbox.app `DJ_PLAYLISTS` XML — single file, references audio by path. + RekordboxXml, + /// CDJ-2000 compatible USB layout (`PIONEER/` + `Contents/`). + RekordboxUsb, + /// Serato — writes Hot Cue / Beatgrid into ID3 `GEOB` frames in-place. + Serato, +} + +/// Where a format expects to read from / write to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "kebab-case")] +pub enum TargetKind { + /// A single file (e.g. `library.cset`, `rekordbox.xml`). + File, + /// A directory the plugin populates (e.g. a USB volume root). + Directory, + /// In-place mutation of audio files referenced by the library. + InPlace, +} + +impl Format { + /// Stable identifier used over the IPC boundary and in CLI flags. + pub fn id(&self) -> &'static str { + match self { + Self::Cset => "cset", + Self::RekordboxXml => "rekordbox-xml", + Self::RekordboxUsb => "rekordbox-usb", + Self::Serato => "serato", + } + } + + /// Human-facing display name. + pub fn label(&self) -> &'static str { + match self { + Self::Cset => "Conduction (.cset)", + Self::RekordboxXml => "rekordbox XML", + Self::RekordboxUsb => "rekordbox USB", + Self::Serato => "Serato", + } + } + + pub fn target_kind(&self) -> TargetKind { + match self { + Self::Cset | Self::RekordboxXml => TargetKind::File, + Self::RekordboxUsb => TargetKind::Directory, + Self::Serato => TargetKind::InPlace, + } + } + + pub fn all() -> &'static [Format] { + &[ + Self::Cset, + Self::RekordboxXml, + Self::RekordboxUsb, + Self::Serato, + ] + } +} + +/// Lightweight descriptor served over IPC so the UI can render a format +/// selector without depending on the plugin registry directly. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct FormatInfo { + pub format: Format, + pub id: String, + pub label: String, + pub target_kind: TargetKind, + /// `true` when a plugin is registered for this format on the current build. + pub available: bool, +} + +impl FormatInfo { + pub fn from_format(format: Format, available: bool) -> Self { + Self { + format, + id: format.id().to_string(), + label: format.label().to_string(), + target_kind: format.target_kind(), + available, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn id_is_kebab_case_and_unique() { + let ids: Vec<&'static str> = Format::all().iter().map(|f| f.id()).collect(); + assert_eq!(ids, ["cset", "rekordbox-xml", "rekordbox-usb", "serato"]); + let mut sorted = ids.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(sorted.len(), Format::all().len(), "format ids must be unique"); + } + + #[test] + fn serde_matches_id() { + let json = serde_json::to_string(&Format::RekordboxXml).unwrap(); + assert_eq!(json, "\"rekordbox-xml\""); + let back: Format = serde_json::from_str("\"serato\"").unwrap(); + assert_eq!(back, Format::Serato); + } +} diff --git a/crates/conduction-export/src/lib.rs b/crates/conduction-export/src/lib.rs index 69e138b..028416d 100644 --- a/crates/conduction-export/src/lib.rs +++ b/crates/conduction-export/src/lib.rs @@ -12,6 +12,16 @@ #![forbid(unsafe_code)] +pub mod api; +pub mod format; +pub mod registry; +pub use api::{ + ConflictStrategy, ExportOptions, Exporter, ImportOptions, Importer, LibraryExportReport, + LibraryImportReport, +}; +pub use format::{Format, FormatInfo, TargetKind}; +pub use registry::{default_registry, PluginRegistry}; + use std::path::PathBuf; use conduction_analysis::WaveformPreview; diff --git a/crates/conduction-export/src/registry.rs b/crates/conduction-export/src/registry.rs new file mode 100644 index 0000000..43e28cb --- /dev/null +++ b/crates/conduction-export/src/registry.rs @@ -0,0 +1,131 @@ +//! Format → plugin lookup used by the Tauri command layer. +//! +//! Plugins (cset / rekordbox-xml / rekordbox-usb / serato) register +//! themselves into a [`PluginRegistry`] at app boot; the host then resolves +//! the user's selected [`Format`] to the right [`Exporter`] / [`Importer`] +//! via this registry. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::{Exporter, Format, FormatInfo, Importer}; + +#[derive(Default)] +pub struct PluginRegistry { + exporters: HashMap>, + importers: HashMap>, +} + +impl PluginRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn register_exporter(&mut self, exporter: E) { + self.exporters.insert(exporter.format(), Arc::new(exporter)); + } + + pub fn register_importer(&mut self, importer: I) { + self.importers.insert(importer.format(), Arc::new(importer)); + } + + pub fn exporter(&self, format: Format) -> Option> { + self.exporters.get(&format).cloned() + } + + pub fn importer(&self, format: Format) -> Option> { + self.importers.get(&format).cloned() + } + + /// Returns every known format with an `available` flag so the UI can + /// render unavailable variants as disabled / "coming soon" rows. + pub fn export_formats(&self) -> Vec { + Format::all() + .iter() + .map(|f| FormatInfo::from_format(*f, self.exporters.contains_key(f))) + .collect() + } + + pub fn import_formats(&self) -> Vec { + Format::all() + .iter() + .map(|f| FormatInfo::from_format(*f, self.importers.contains_key(f))) + .collect() + } +} + +/// Default registry. Empty for now — plugin registration lands per-format in +/// subsequent phases (Phase 1+ for rekordbox XML, Phase 6+ for Serato). +pub fn default_registry() -> PluginRegistry { + PluginRegistry::new() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::{ + ExportOptions, Exporter, ImportOptions, Importer, LibraryExportReport, + LibraryImportReport, + }; + use crate::ExportError; + use conduction_library::Library; + + struct StubExporter(Format); + impl Exporter for StubExporter { + fn format(&self) -> Format { + self.0 + } + fn export( + &self, + _library: &mut Library, + _options: &ExportOptions, + ) -> Result { + Err(ExportError::NotImplemented) + } + } + + struct StubImporter(Format); + impl Importer for StubImporter { + fn format(&self) -> Format { + self.0 + } + fn import( + &self, + _library: &mut Library, + _options: &ImportOptions, + ) -> Result { + Err(ExportError::NotImplemented) + } + } + + #[test] + fn empty_registry_lists_all_formats_as_unavailable() { + let r = PluginRegistry::new(); + let exp = r.export_formats(); + assert_eq!(exp.len(), Format::all().len()); + assert!(exp.iter().all(|f| !f.available)); + } + + #[test] + fn registering_an_exporter_marks_only_that_format_available() { + let mut r = PluginRegistry::new(); + r.register_exporter(StubExporter(Format::RekordboxXml)); + let exp = r.export_formats(); + let xml = exp.iter().find(|f| f.format == Format::RekordboxXml).unwrap(); + let serato = exp.iter().find(|f| f.format == Format::Serato).unwrap(); + assert!(xml.available); + assert!(!serato.available); + } + + #[test] + fn exporter_and_importer_are_independent() { + let mut r = PluginRegistry::new(); + r.register_exporter(StubExporter(Format::Cset)); + r.register_importer(StubImporter(Format::Serato)); + + assert!(r.exporter(Format::Cset).is_some()); + assert!(r.importer(Format::Cset).is_none()); + assert!(r.exporter(Format::Serato).is_none()); + assert!(r.importer(Format::Serato).is_some()); + } +} diff --git a/ui/src/components/library/FormatPickerModal.css b/ui/src/components/library/FormatPickerModal.css new file mode 100644 index 0000000..acfce46 --- /dev/null +++ b/ui/src/components/library/FormatPickerModal.css @@ -0,0 +1,98 @@ +.format-picker-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +.format-picker { + background: var(--c-ink-2); + border: 1px solid var(--c-ink-5); + border-radius: var(--r-4); + width: min(520px, 92vw); + padding: var(--s-5); + display: flex; + flex-direction: column; + gap: var(--s-4); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.45); +} + +.format-picker-head { + display: flex; + justify-content: space-between; + align-items: center; +} + +.format-picker-close { + background: transparent; + border: none; + color: var(--c-ink-9); + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 4px 10px; + border-radius: var(--r-2); +} + +.format-picker-close:hover { + background: var(--c-ink-3); + color: var(--c-ink-11); +} + +.format-picker-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--s-2); +} + +.format-picker-row { + display: grid; + grid-template-columns: 1fr auto auto; + gap: var(--s-3); + align-items: center; + width: 100%; + background: var(--c-ink-1); + border: 1px solid var(--c-ink-5); + border-radius: var(--r-2); + padding: var(--s-3) var(--s-4); + color: var(--c-ink-11); + font-family: inherit; + font-size: var(--fs-body); + text-align: left; + cursor: pointer; +} + +.format-picker-row:hover:not(:disabled) { + background: var(--c-ink-3); + border-color: var(--c-accent); +} + +.format-picker-row:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.format-picker-label { + font-weight: 600; +} + +.format-picker-target { + font-size: var(--fs-small); + color: var(--c-ink-9); +} + +.format-picker-badge { + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + background: var(--c-ink-4); + color: var(--c-ink-10); + padding: 2px 8px; + border-radius: var(--r-2); +} diff --git a/ui/src/components/library/FormatPickerModal.tsx b/ui/src/components/library/FormatPickerModal.tsx new file mode 100644 index 0000000..3cf5933 --- /dev/null +++ b/ui/src/components/library/FormatPickerModal.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; + +import "./FormatPickerModal.css"; +import { ipc, type FormatInfo, type TargetKind } from "@/lib/ipc"; + +interface FormatPickerModalProps { + mode: "export" | "import"; + open: boolean; + onClose: () => void; + onPick: (format: FormatInfo) => void; +} + +export function FormatPickerModal({ + mode, + open, + onClose, + onPick, +}: FormatPickerModalProps) { + const [formats, setFormats] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + let cancelled = false; + setLoading(true); + setError(null); + const fetcher = + mode === "export" ? ipc.listExportFormats() : ipc.listImportFormats(); + fetcher + .then((list) => { + if (!cancelled) setFormats(list); + }) + .catch((e) => { + if (!cancelled) setError(String(e)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, mode]); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [open, onClose]); + + if (!open) return null; + const title = mode === "export" ? "Export library" : "Import library"; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-label={title} + > +
+ {title} + +
+ + {loading &&

Loading formats…

} + {error && ( +

+ {error} +

+ )} + +
    + {formats.map((f) => ( +
  • + +
  • + ))} +
+
+
+ ); +} + +function targetKindLabel(k: TargetKind): string { + switch (k) { + case "file": + return "single file"; + case "directory": + return "directory"; + case "in-place": + return "tags in audio files"; + } +} diff --git a/ui/src/lib/ipc.ts b/ui/src/lib/ipc.ts index 2de5e95..5236b2a 100644 --- a/ui/src/lib/ipc.ts +++ b/ui/src/lib/ipc.ts @@ -242,6 +242,40 @@ export const ipc = { return call("export_execute", { destination }); }, + // --- Library-level export / import (format-aware) --- + listExportFormats() { + return call("list_export_formats"); + }, + listImportFormats() { + return call("list_import_formats"); + }, + libraryExport(options: { + format: LibraryFormatId; + destination: string; + dryRun?: boolean; + extra?: unknown; + }) { + return call("library_export", { + format: options.format, + destination: options.destination, + dryRun: options.dryRun ?? false, + extra: options.extra ?? null, + }); + }, + libraryImport(options: { + format: LibraryFormatId; + source: string; + conflictStrategy?: ConflictStrategy; + extra?: unknown; + }) { + return call("library_import", { + format: options.format, + source: options.source, + conflictStrategy: options.conflictStrategy ?? "skip", + extra: options.extra ?? null, + }); + }, + // --- YouTube (yt-dlp) --- ytDlpAvailable() { return call("yt_dlp_available"); @@ -459,3 +493,38 @@ export interface AppSettings { audio_main_output: string | null; audio_cue_output: string | null; } + +// --- Library-level export / import --- + +export type LibraryFormatId = + | "cset" + | "rekordbox-xml" + | "rekordbox-usb" + | "serato"; + +export type TargetKind = "file" | "directory" | "in-place"; + +export type ConflictStrategy = "skip" | "update" | "replace"; + +export interface FormatInfo { + format: LibraryFormatId; + id: string; + label: string; + target_kind: TargetKind; + available: boolean; +} + +export interface LibraryExportReport { + format: LibraryFormatId; + tracks_written: number; + bytes_written: number; + warnings: string[]; +} + +export interface LibraryImportReport { + format: LibraryFormatId; + tracks_imported: number; + tracks_updated: number; + tracks_skipped: number; + warnings: string[]; +} diff --git a/ui/src/screens/LibraryScreen.tsx b/ui/src/screens/LibraryScreen.tsx index 068e867..04d3731 100644 --- a/ui/src/screens/LibraryScreen.tsx +++ b/ui/src/screens/LibraryScreen.tsx @@ -1,7 +1,14 @@ -import { open } from "@tauri-apps/plugin-dialog"; +import { open, save } from "@tauri-apps/plugin-dialog"; import { useCallback, useMemo, useState } from "react"; -import { ipc, type ExportPreview } from "@/lib/ipc"; +import { FormatPickerModal } from "@/components/library/FormatPickerModal"; +import { + ipc, + type ExportPreview, + type FormatInfo, + type LibraryExportReport, + type LibraryImportReport, +} from "@/lib/ipc"; import type { DeckId } from "@/types/mixer"; import type { TrackSummary } from "@/types/track"; @@ -76,6 +83,43 @@ export function LibraryScreen({ | { state: "error"; error: string } >({ state: "idle" }); + // Library-level export / import (format-aware) + const [pickerMode, setPickerMode] = useState<"export" | "import" | null>( + null, + ); + const [libraryResult, setLibraryResult] = useState< + | { kind: "export"; report: LibraryExportReport } + | { kind: "import"; report: LibraryImportReport } + | { kind: "error"; message: string } + | null + >(null); + + const handleLibraryFormatPicked = useCallback(async (f: FormatInfo) => { + const mode = pickerMode; + setPickerMode(null); + if (!mode) return; + try { + if (mode === "export") { + const destination = await pickExportDestination(f); + if (!destination) return; + const report = await ipc.libraryExport({ + format: f.format, + destination, + }); + setLibraryResult({ kind: "export", report }); + } else { + const source = await pickImportSource(f); + if (!source) return; + const report = await ipc.libraryImport({ format: f.format, source }); + setLibraryResult({ kind: "import", report }); + await refresh(); + } + } catch (e) { + setLibraryResult({ kind: "error", message: String(e) }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pickerMode]); + const [demoCueResult, setDemoCueResult] = useState(null); const handleInjectDemoCues = useCallback(async () => { setDemoCueResult("Injecting…"); @@ -152,6 +196,21 @@ export function LibraryScreen({ > {exportInfo.state === "previewing" ? "Previewing…" : "Export to USB…"} + + {filtered.length} {filtered.length !== tracks.length && <> / {tracks.length}} tracks @@ -205,6 +264,68 @@ export function LibraryScreen({

)} + {libraryResult && ( +
+ {libraryResult.kind === "export" && ( + <> + Exported {libraryResult.report.tracks_written} track(s) as{" "} + {libraryResult.report.format} + {libraryResult.report.warnings.length > 0 && ( + <>. {libraryResult.report.warnings.length} warning(s) + )} + .{" "} + + + )} + {libraryResult.kind === "import" && ( + <> + Imported {libraryResult.report.tracks_imported} new,{" "} + {libraryResult.report.tracks_updated} updated,{" "} + {libraryResult.report.tracks_skipped} skipped ( + {libraryResult.report.format}).{" "} + + + )} + {libraryResult.kind === "error" && ( + <> + Library operation failed: {libraryResult.message}{" "} + + + )} +
+ )} + setPickerMode(null)} + onPick={handleLibraryFormatPicked} + /> {exportInfo.state === "error" && (

Export preview failed: {exportInfo.error} @@ -318,3 +439,40 @@ function formatBytes(n: number): string { } return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${units[i]}`; } + +async function pickExportDestination(f: FormatInfo): Promise { + if (f.target_kind === "file") { + const ext = formatExtension(f.format); + const result = await save({ + defaultPath: ext ? `library.${ext}` : "library", + filters: ext ? [{ name: f.label, extensions: [ext] }] : undefined, + }); + return result ?? null; + } + const result = await open({ directory: true, multiple: false }); + return typeof result === "string" ? result : null; +} + +async function pickImportSource(f: FormatInfo): Promise { + if (f.target_kind === "file") { + const ext = formatExtension(f.format); + const result = await open({ + multiple: false, + filters: ext ? [{ name: f.label, extensions: [ext] }] : undefined, + }); + return typeof result === "string" ? result : null; + } + const result = await open({ directory: true, multiple: false }); + return typeof result === "string" ? result : null; +} + +function formatExtension(format: string): string { + switch (format) { + case "cset": + return "cset"; + case "rekordbox-xml": + return "xml"; + default: + return ""; + } +}