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}
+ >
+
+
+ {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 "";
+ }
+}