Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 68 additions & 0 deletions crates/conduction-app/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<FormatInfo> {
registry.0.export_formats()
}

#[tauri::command]
pub fn list_import_formats(registry: State<'_, ExportRegistryHandle>) -> Vec<FormatInfo> {
registry.0.import_formats()
}

#[tauri::command]
pub fn library_export(
registry: State<'_, ExportRegistryHandle>,
library: State<'_, LibraryHandle>,
format: Format,
destination: String,
dry_run: Option<bool>,
extra: Option<serde_json::Value>,
) -> CmdResult<LibraryExportReport> {
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<ConflictStrategy>,
extra: Option<serde_json::Value>,
) -> CmdResult<LibraryImportReport> {
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())
}
18 changes: 18 additions & 0 deletions crates/conduction-app/src/export_state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//! Tauri State wrapper around the conduction-export plugin registry.
//!
//! Held as an `Arc<PluginRegistry>` 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<PluginRegistry>);

impl ExportRegistryHandle {
pub fn new(registry: PluginRegistry) -> Self {
Self(Arc::new(registry))
}
}
8 changes: 8 additions & 0 deletions crates/conduction-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 インスタンスを共有する。
Expand All @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions crates/conduction-export/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
118 changes: 118 additions & 0 deletions crates/conduction-export/src/api.rs
Original file line number Diff line number Diff line change
@@ -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<serde_json::Value>,
}

/// 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<serde_json::Value>,
}

/// 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<String>,
}

/// 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<String>,
}

/// 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<LibraryExportReport, ExportError>;
}

/// 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<LibraryImportReport, ExportError>;
}
120 changes: 120 additions & 0 deletions crates/conduction-export/src/format.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading