From a3ed9b0d61fe41745c76cd4c9adfbcc24bdd21cb Mon Sep 17 00:00:00 2001 From: xxvw Date: Fri, 15 May 2026 14:00:12 +0900 Subject: [PATCH 1/6] feat(export): add Format enum (cset / rekordbox-xml / rekordbox-usb / serato) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new format module declares the four library-level export/import shapes the user can pick from the UI, along with a TargetKind classifier (file / directory / in-place) so callers can branch on how to ask for a destination. FormatInfo is the IPC-facing descriptor (id, label, target_kind, available) that the UI will use to render the format selector. Stable kebab-case ids ("cset", "rekordbox-xml", ...) are pinned by a test so the IPC contract stays consistent. No plugins are registered yet — that comes in subsequent commits. --- Cargo.lock | 1 + crates/conduction-export/Cargo.toml | 3 + crates/conduction-export/src/format.rs | 120 +++++++++++++++++++++++++ crates/conduction-export/src/lib.rs | 3 + 4 files changed, 127 insertions(+) create mode 100644 crates/conduction-export/src/format.rs 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-export/Cargo.toml b/crates/conduction-export/Cargo.toml index 79fe189..81d17a1 100644 --- a/crates/conduction-export/Cargo.toml +++ b/crates/conduction-export/Cargo.toml @@ -19,3 +19,6 @@ utoipa = "5" # Phase 2 以降で実際の PDB / ANLZ writer を組む。Phase 1 では依存セットアップのみ。 rekordcrate = "0.3" + +[dev-dependencies] +serde_json.workspace = true 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..23de094 100644 --- a/crates/conduction-export/src/lib.rs +++ b/crates/conduction-export/src/lib.rs @@ -12,6 +12,9 @@ #![forbid(unsafe_code)] +pub mod format; +pub use format::{Format, FormatInfo, TargetKind}; + use std::path::PathBuf; use conduction_analysis::WaveformPreview; From 03b067ec8fd55b758cca0468b8e4629d91d5a31a Mon Sep 17 00:00:00 2001 From: xxvw Date: Fri, 15 May 2026 14:01:57 +0900 Subject: [PATCH 2/6] feat(export): Exporter/Importer traits with options + reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new api module defines: - Exporter / Importer object-safe traits taking a &Library and per-direction Options struct, returning a per-direction Report. - ExportOptions / ImportOptions carry destination + dry_run + an opaque serde_json::Value for format-specific knobs (USB volume label, conflict rules, etc.), so the IPC layer doesn't need a DTO per plugin. - ConflictStrategy (skip / update / replace) controls how an importer handles existing rows. - LibraryExportReport / LibraryImportReport keep the IPC schema on the Reports (utoipa::ToSchema) while leaving Options as internal-only types to avoid pulling PathBuf and serde_json::Value into the OpenAPI schema. No plugins implement these traits yet — that lands in subsequent commits. --- crates/conduction-export/Cargo.toml | 4 +- crates/conduction-export/src/api.rs | 113 ++++++++++++++++++++++++++++ crates/conduction-export/src/lib.rs | 5 ++ 3 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 crates/conduction-export/src/api.rs diff --git a/crates/conduction-export/Cargo.toml b/crates/conduction-export/Cargo.toml index 81d17a1..522c619 100644 --- a/crates/conduction-export/Cargo.toml +++ b/crates/conduction-export/Cargo.toml @@ -13,12 +13,10 @@ 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" # Phase 2 以降で実際の PDB / ANLZ writer を組む。Phase 1 では依存セットアップのみ。 rekordcrate = "0.3" - -[dev-dependencies] -serde_json.workspace = true diff --git a/crates/conduction-export/src/api.rs b/crates/conduction-export/src/api.rs new file mode 100644 index 0000000..1f0dff9 --- /dev/null +++ b/crates/conduction-export/src/api.rs @@ -0,0 +1,113 @@ +//! 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. +pub trait Exporter: Send + Sync { + fn format(&self) -> Format; + fn label(&self) -> &'static str { + self.format().label() + } + + fn export( + &self, + library: &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: &Library, + options: &ImportOptions, + ) -> Result; +} diff --git a/crates/conduction-export/src/lib.rs b/crates/conduction-export/src/lib.rs index 23de094..04b7786 100644 --- a/crates/conduction-export/src/lib.rs +++ b/crates/conduction-export/src/lib.rs @@ -12,7 +12,12 @@ #![forbid(unsafe_code)] +pub mod api; pub mod format; +pub use api::{ + ConflictStrategy, ExportOptions, Exporter, ImportOptions, Importer, LibraryExportReport, + LibraryImportReport, +}; pub use format::{Format, FormatInfo, TargetKind}; use std::path::PathBuf; From 2108c3964a8b7e7868cc463ca21ca0bd59783ad9 Mon Sep 17 00:00:00 2001 From: xxvw Date: Fri, 15 May 2026 14:02:42 +0900 Subject: [PATCH 3/6] feat(export): plugin registry + default_registry constructor PluginRegistry holds Arc / Arc keyed by Format so the Tauri command layer can resolve "user picked rekordbox-xml" to a concrete plugin. Each direction (in/out) is registered independently: some plugins (rekordbox-usb) will only ship export at first, others (serato) read and write the same shape. export_formats() / import_formats() return FormatInfo with availability flags so the UI can render every known format and grey out the ones not yet implemented in this build. default_registry() returns an empty registry for now; per-format registration lands in Phase 1+ (rekordbox XML), Phase 6+ (serato), etc. --- crates/conduction-export/src/lib.rs | 2 + crates/conduction-export/src/registry.rs | 131 +++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 crates/conduction-export/src/registry.rs diff --git a/crates/conduction-export/src/lib.rs b/crates/conduction-export/src/lib.rs index 04b7786..028416d 100644 --- a/crates/conduction-export/src/lib.rs +++ b/crates/conduction-export/src/lib.rs @@ -14,11 +14,13 @@ 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; diff --git a/crates/conduction-export/src/registry.rs b/crates/conduction-export/src/registry.rs new file mode 100644 index 0000000..6d9c066 --- /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: &Library, + _options: &ExportOptions, + ) -> Result { + Err(ExportError::NotImplemented) + } + } + + struct StubImporter(Format); + impl Importer for StubImporter { + fn format(&self) -> Format { + self.0 + } + fn import( + &self, + _library: &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()); + } +} From c5497ae415e35342d20349bafef3fbfc518ea2ba Mon Sep 17 00:00:00 2001 From: xxvw Date: Fri, 15 May 2026 14:04:10 +0900 Subject: [PATCH 4/6] refactor(export): take &mut Library so importers can write conduction-library's Library wraps a raw rusqlite::Connection without an internal Mutex, so every method (including read-only ones) needs &mut self. Aligning the Exporter / Importer trait signature with that lets the registered importers actually call repo methods without working around the borrow checker. Doc comment explains the rationale. The stub plugins in registry tests are updated to match. --- crates/conduction-export/src/api.rs | 9 +++++++-- crates/conduction-export/src/registry.rs | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/conduction-export/src/api.rs b/crates/conduction-export/src/api.rs index 1f0dff9..435d596 100644 --- a/crates/conduction-export/src/api.rs +++ b/crates/conduction-export/src/api.rs @@ -85,6 +85,11 @@ pub struct LibraryImportReport { } /// 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 { @@ -93,7 +98,7 @@ pub trait Exporter: Send + Sync { fn export( &self, - library: &Library, + library: &mut Library, options: &ExportOptions, ) -> Result; } @@ -107,7 +112,7 @@ pub trait Importer: Send + Sync { fn import( &self, - library: &Library, + library: &mut Library, options: &ImportOptions, ) -> Result; } diff --git a/crates/conduction-export/src/registry.rs b/crates/conduction-export/src/registry.rs index 6d9c066..43e28cb 100644 --- a/crates/conduction-export/src/registry.rs +++ b/crates/conduction-export/src/registry.rs @@ -77,7 +77,7 @@ mod tests { } fn export( &self, - _library: &Library, + _library: &mut Library, _options: &ExportOptions, ) -> Result { Err(ExportError::NotImplemented) @@ -91,7 +91,7 @@ mod tests { } fn import( &self, - _library: &Library, + _library: &mut Library, _options: &ImportOptions, ) -> Result { Err(ExportError::NotImplemented) From 84722d8e83909cc3457f7aef2deb8609820fd4e3 Mon Sep 17 00:00:00 2001 From: xxvw Date: Fri, 15 May 2026 14:05:59 +0900 Subject: [PATCH 5/6] feat(export): Tauri commands list_export_formats / library_export Adds an ExportRegistryHandle state holding an Arc populated at boot from conduction_export::default_registry(), plus four new Tauri commands: - list_export_formats / list_import_formats return Vec so the UI can render every known format with an availability flag. - library_export / library_import take (format, path, ...) from the UI, build the matching ExportOptions / ImportOptions, and dispatch through the registry to the registered plugin. Until a plugin is registered for the chosen format, the command returns a clear "no exporter/importer registered for {id}" error so the UI can surface "coming soon". The legacy setlist_export / setlist_import path (for .cset of a single setlist) is untouched. --- crates/conduction-app/src/commands.rs | 68 +++++++++++++++++++++++ crates/conduction-app/src/export_state.rs | 18 ++++++ crates/conduction-app/src/lib.rs | 8 +++ 3 files changed, 94 insertions(+) create mode 100644 crates/conduction-app/src/export_state.rs 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"); From f13494621a1db4a86469565f8f1e25fef88b4818 Mon Sep 17 00:00:00 2001 From: xxvw Date: Fri, 15 May 2026 14:11:23 +0900 Subject: [PATCH 6/6] feat(ui): Library Export / Import buttons with format picker Adds two new toolbar buttons on the Library screen that open a modal listing every Format the backend knows about (cset / rekordbox-xml / rekordbox-usb / serato). Each row reads `available` from the registry and is rendered as a disabled "coming soon" row when no plugin is registered for that direction. Once a format is picked, a save/open dialog appropriate for the format's TargetKind (file vs directory) is opened and the result is dispatched through `library_export` / `library_import`. The new IPC types (FormatInfo, LibraryFormatId, ConflictStrategy, LibraryExportReport, LibraryImportReport) mirror the Rust side. CSS lives in a sibling file (FormatPickerModal.css) instead of App.css to keep the modal's styling colocated with its component. --- .../components/library/FormatPickerModal.css | 98 +++++++++++ .../components/library/FormatPickerModal.tsx | 123 +++++++++++++ ui/src/lib/ipc.ts | 69 ++++++++ ui/src/screens/LibraryScreen.tsx | 162 +++++++++++++++++- 4 files changed, 450 insertions(+), 2 deletions(-) create mode 100644 ui/src/components/library/FormatPickerModal.css create mode 100644 ui/src/components/library/FormatPickerModal.tsx 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 ""; + } +}