# Rust Implementation Plan for MovieMapper ## Project Structure ``` MovieMapper/ ├── Cargo.toml # Rust workspace configuration ├── Cargo.lock # Dependency lock file ├── rust/ # Rust backend implementation │ ├── Cargo.toml # Rust crate configuration │ ├── src/ │ │ ├── lib.rs # Library entry point │ │ ├── main.rs # Binary entry point (optional) │ │ ├── lib.h # C header for Node.js addon │ │ ├── error.rs # Custom error types │ │ ├── types.rs # Shared types (FileMetadata, etc.) │ │ ├── scanner.rs # Directory scanning logic │ │ ├── ffmpeg.rs # FFmpeg integration │ │ ├── tvdb.rs # TheTVDB API client │ │ ├── file_manager.rs # File operations │ │ ├── mapper.rs # File mapping logic │ │ └── ipc/ # IPC bridge implementation │ │ ├── mod.rs │ │ └── node_addon.rs # Node.js native addon │ ├── build.rs # Build script for FFmpeg │ └── tests/ # Integration tests │ ├── scanner_test.rs │ ├── ffmpeg_test.rs │ ├── tvdb_test.rs │ └── file_manager_test.rs ├── node/ # Node.js integration │ ├── binding.gyp # node-gyp configuration │ ├── index.js # JS wrapper for native addon │ └── package.json # Node.js dependencies ├── main.js # Electron main process (updated to use Rust) ├── renderer.js # Electron renderer (unchanged) ├── utils/ │ └── fileUtils.js # Updated to delegate to Rust └── package.json # Updated to include Rust build steps ``` ## Build Configuration ### Cargo.toml (rust/) ```toml [workspace] members = ["."] [package] name = "moviemapper-rust" version = "0.1.0" edition = "2021" description = "Rust backend for MovieMapper" [lib] name = "moviemapper_rust" crate-type = ["cdylib"] [dependencies] tokio = { version = "1.35", features = ["full"] } reqwest = { version = "0.17", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" ffmpeg-kit = { version = "6.0", features = ["full"] } thiserror = "2.0" once_cell = "1.19" uuid = { version = "1.6", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } notify = "6.2" tempfile = "3.10" [build-dependencies] cc = "1.0" [package.metadata.maturin] features = ["pyo3/extension-module"] ``` ### binding.gyp (node/) ```python { "targets": [{ "target_name": "moviemapper_native", "sources": ["src/lib.rs"], "include_dirs": [ ", } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Show { pub id: u64, pub name: String, pub status: Option, pub first_aired: Option, pub overview: Option, pub image: Option, pub slug: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Season { pub id: u64, pub number: u32, pub episode_count: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Episode { pub id: u64, pub name: String, pub number: u32, pub season_number: u32, pub aired: Option, pub overview: Option, } ``` ### Phase 2: FFmpeg Integration #### ffmpeg.rs ```rust use crate::error::{ProbeError, Result}; use crate::types::MediaInfo; use ffmpeg_next as ffmpeg; use std::path::Path; pub fn probe_file(path: &str) -> Result { let context = ffmpeg::format::input(&path) .map_err(|e| ProbeError::CannotProbe(path.to_string()))?; let duration = context.duration() as f64 / 1000000.0; let format = context.context().iformat().name().to_string(); let video_streams: Vec<_> = context .streams() .filter(|s| s.media_type() == ffmpeg::media::Type::Video) .map(|s| { let codec_params = s.parameters(); let width = codec_params.width(); let height = codec_params.height(); let codec = codec_params.id().name().to_string(); let frame_rate = s.avg_frame_rate().0 as f64 / s.avg_frame_rate().1 as f64; VideoStreamInfo { width, height, codec, frame_rate, } }) .collect(); Ok(MediaInfo { duration, format, video_streams, }) } pub fn get_duration(path: &str) -> Result { let info = probe_file(path)?; let minutes = (info.duration / 60.0) as u32; let seconds = (info.duration % 60.0) as u32; Ok(format!("{:02}:{:02}", minutes, seconds)) } pub fn get_quality(path: &str) -> Result<(String, String)> { let info = probe_file(path)?; let quality = if let Some(stream) = info.video_streams.first() { let height = stream.height; match height { h if h >= 2160 => "4K", h if h >= 1440 => "1440p", h if h >= 1080 => "1080p", h if h >= 720 => "720p", h if h >= 480 => "480p", _ => "unknown", }.to_string() } else { "unknown".to_string() }; let fps = if let Some(stream) = info.video_streams.first() { let frame_rate = stream.frame_rate; format!("{}fps", frame_rate.round() as u32) } else { "unknown".to_string() }; Ok((quality, fps)) } ``` ### Phase 3: Directory Scanning #### scanner.rs ```rust use crate::error::{Result, ScanError}; use crate::ffmpeg::{get_duration, get_quality}; use crate::types::FileMetadata; use std::fs; use std::path::Path; const MEDIA_EXTENSIONS: &[&str] = &[".mp4", ".mkv", ".avi", ".mov", ".flv", ".webm"]; pub fn is_media_file(path: &str) -> bool { Path::new(path) .extension() .and_then(|ext| ext.to_str()) .map(|ext| MEDIA_EXTENSIONS.contains(&ext.to_lowercase())) .unwrap_or(false) } pub fn scan_directory( directory_path: &str, progress_callback: Option>, ) -> Result> { let entries = fs::read_dir(directory_path) .map_err(|e| ScanError::NotFound(directory_path.to_string()))?; let mut folders = Vec::new(); let mut media_files = Vec::new(); for entry in entries { let entry = entry.map_err(|e| ScanError::Io(e))?; let path = entry.path(); if path.is_dir() { let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); folders.push(FileMetadata { path: path.to_string_lossy().to_string(), name, size: 0, modified: String::new(), duration: String::new(), quality: String::new(), fps: String::new(), is_folder: true, }); } else if path.is_file() && is_media_file(&path.to_string_lossy()) { let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); let stat = entry.metadata().map_err(|e| ScanError::Io(e))?; media_files.push((path.to_string_lossy().to_string(), name, stat)); } } let total_media = media_files.len() as u32; let mut results = Vec::new(); // Add folders first results.extend(folders); // Process media files for (index, (path, name, stat)) in media_files.into_iter().enumerate() { if let Some(ref callback) = progress_callback { callback(index as u32, total_media, &name); } let duration = match get_duration(&path) { Ok(d) => d, Err(_) => "00:00".to_string(), }; let (quality, fps) = match get_quality(&path) { Ok((q, f)) => (q, f), Err(_) => ("unknown".to_string(), "unknown".to_string()), }; let modified = stat .modified() .ok() .and_then(|t| { chrono::DateTime::::from(t).to_rfc3339_opts(chrono::SecondsFormat::Millis, true) .to_string() .into() }) .unwrap_or_else(|| String::new()); results.push(FileMetadata { path, name, size: stat.len(), modified, duration, quality, fps, is_folder: false, }); } if let Some(ref callback) = progress_callback { callback(total_media, total_media, "Complete"); } Ok(results) } ``` ### Phase 4: TheTVDB API Client #### tvdb.rs ```rust use crate::error::{Result, TVDBError}; use crate::types::{Episode, Season, Show}; use once_cell::sync::Lazy; use reqwest::Client; use serde::Deserialize; use std::collections::HashMap; static TOKEN_CACHE: Lazy)>>> = Lazy::new(|| parking_lot::Mutex::new(None)); pub struct TVDBClient { client: Client, api_key: String, } impl TVDBClient { pub fn new(api_key: String) -> Self { Self { client: Client::new(), api_key, } } async fn get_token(&self) -> Result { let mut cache = TOKEN_CACHE.lock(); if let Some((token, expiry)) = &*cache { if chrono::Utc::now() < *expiry { return Ok(token.clone()); } } let response = self .client .post("https://api4.thetvdb.com/v4/login") .json(&serde_json::json!({ "apikey": self.api_key })) .send() .await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; if !response.status().is_success() { return Err(TVDBError::AuthFailed.into()); } let json: HashMap = response.json().await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; let token = json.get("data") .and_then(|d| d.get("token")) .and_then(|t| t.as_str()) .ok_or(TVDBError::AuthFailed)? .to_string(); // Token valid for 30 days let expiry = chrono::Utc::now() + chrono::Duration::days(30); *cache = Some((token.clone(), expiry)); Ok(token) } pub async fn search(&self, query: &str) -> Result> { let token = self.get_token().await?; let response = self .client .get("https://api4.thetvdb.com/v4/search") .bearer_auth(&token) .query(&[("query", query)]) .send() .await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; if !response.status().is_success() { return Err(TVDBError::RequestFailed(response.status().to_string()).into()); } let json: serde_json::Value = response.json().await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; let data = json.get("data").unwrap_or(&serde_json::Value::Null); let shows: Vec = if let serde_json::Value::Array(arr) = data { arr.iter() .filter_map(|s| { Some(Show { id: s.get("id")?.as_u64()?, name: s.get("name")?.as_str()?.to_string(), status: s.get("status")?.get("name")?.as_str().map(|s| s.to_string()), first_aired: s.get("firstAired")?.as_str().map(|s| s.to_string()), overview: s.get("overview")?.as_str().map(|s| s.to_string()), image: s.get("image")?.as_str().map(|s| s.to_string()), slug: s.get("slug")?.as_str().map(|s| s.to_string()), }) }) .collect() } else { Vec::new() }; Ok(shows) } pub async fn get_show_details(&self, show_id: u64) -> Result { let token = self.get_token().await?; let response = self .client .get(format!("https://api4.thetvdb.com/v4/series/{}/extended", show_id)) .bearer_auth(&token) .send() .await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; if !response.status().is_success() { return Err(TVDBError::RequestFailed(response.status().to_string()).into()); } let json: serde_json::Value = response.json().await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; let data = json.get("data").ok_or(TVDBError::AuthFailed)?; Ok(Show { id: data.get("id").unwrap_or(&serde_json::Value::Null).as_u64().unwrap_or(0), name: data.get("name")?.as_str()?.to_string(), status: data.get("status")?.get("name")?.as_str().map(|s| s.to_string()), first_aired: data.get("firstAired")?.as_str().map(|s| s.to_string()), overview: data.get("overview")?.as_str().map(|s| s.to_string()), image: data.get("image")?.as_str().map(|s| s.to_string()), slug: data.get("slug")?.as_str().map(|s| s.to_string()), }) } pub async fn get_seasons(&self, show_id: u64) -> Result> { let token = self.get_token().await?; let response = self .client .get(format!("https://api4.thetvdb.com/v4/series/{}/extended", show_id)) .bearer_auth(&token) .send() .await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; if !response.status().is_success() { return Err(TVDBError::RequestFailed(response.status().to_string()).into()); } let json: serde_json::Value = response.json().await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; let data = json.get("data").ok_or(TVDBError::AuthFailed)?; let seasons = data.get("seasons").unwrap_or(&serde_json::Value::Null); let season_list: Vec = if let serde_json::Value::Array(arr) = seasons { arr.iter() .filter_map(|s| { Some(Season { id: s.get("id")?.as_u64()?, number: s.get("number")?.as_u64()? as u32, episode_count: 0, // Will be fetched separately }) }) .collect() } else { Vec::new() }; Ok(season_list) } pub async fn get_episodes(&self, show_id: u64, season_number: u32) -> Result> { let token = self.get_token().await?; let response = self .client .get(format!("https://api4.thetvdb.com/v4/series/{}/episodes/default", show_id)) .bearer_auth(&token) .query(&[("page", 0)]) .send() .await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; if !response.status().is_success() { return Err(TVDBError::RequestFailed(response.status().to_string()).into()); } let json: serde_json::Value = response.json().await .map_err(|e| TVDBError::RequestFailed(e.to_string()))?; let data = json.get("data").ok_or(TVDBError::AuthFailed)?; let episodes = data.get("episodes").unwrap_or(&serde_json::Value::Null); let episode_list: Vec = if let serde_json::Value::Array(arr) = episodes { arr.iter() .filter_map(|e| { let season_num: u32 = e.get("seasonNumber")?.as_u64()? as u32; if season_num != season_number { return None; } Some(Episode { id: e.get("id")?.as_u64()?, name: e.get("name")?.as_str()?.to_string(), number: e.get("number")?.as_u64()? as u32, season_number: season_num, aired: e.get("aired")?.as_str().map(|s| s.to_string()), overview: e.get("overview")?.as_str().map(|s| s.to_string()), }) }) .collect() } else { Vec::new() }; Ok(episode_list) } } ``` ### Phase 5: File Manager #### file_manager.rs ```rust use crate::error::{Result, FileError}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::Path; #[derive(Debug, Serialize, Deserialize)] pub struct AuditLogEntry { pub timestamp: String, pub action: AuditAction, pub details: HashMap, } #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AuditAction { RenameFile { old_path: String, new_path: String, }, MoveFile { original_path: String, new_path: String, folder: String, }, } pub struct FileManager { base_path: String, } impl FileManager { pub fn new(base_path: &str) -> Self { Self { base_path: base_path.to_string(), } } pub async fn rename_file(&self, old_path: &str, new_name: &str) -> Result<()> { let old_path = Path::new(old_path); let new_path = old_path .parent() .ok_or(FileError::NotFound(old_path.to_string_lossy().to_string()))? .join(new_name); if new_path.exists() { return Err(FileError::AlreadyExists(new_path.to_string_lossy().to_string()).into()); } fs::rename(old_path, &new_path) .map_err(|e| FileError::Io(e))?; Ok(()) } pub async fn move_to_folder(&self, file_path: &str, folder_name: &str) -> Result { let file_path = Path::new(file_path); let file_dir = file_path .parent() .ok_or(FileError::NotFound(file_path.to_string_lossy().to_string()))?; let target_folder = file_dir.join(folder_name); fs::create_dir_all(&target_folder) .map_err(|e| FileError::CannotCreateDir(target_folder.to_string_lossy().to_string()))?; let file_name = file_path .file_name() .and_then(|n| n.to_str()) .ok_or(FileError::NotFound(file_path.to_string_lossy().to_string()))?; let dest_path = target_folder.join(file_name); if dest_path.exists() { return Err(FileError::AlreadyExists(dest_path.to_string_lossy().to_string()).into()); } fs::rename(file_path, &dest_path) .map_err(|e| FileError::Io(e))?; Ok(dest_path.to_string_lossy().to_string()) } pub async fn write_audit_log(&self, action: AuditAction) -> Result<()> { let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let entry = AuditLogEntry { timestamp, action, details: HashMap::new(), }; let audit_path = Path::new(&self.base_path).join(".audit"); let line = serde_json::to_string(&entry) .map_err(|e| FileError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; fs::OpenOptions::new() .create(true) .append(true) .open(&audit_path) .map_err(|e| FileError::Io(e))? .write_all(format!("{}\n", line).as_bytes()) .map_err(|e| FileError::Io(e))?; Ok(()) } } ``` ### Phase 6: File Mapping #### mapper.rs ```rust use crate::error::{Result, MappingError}; use crate::file_manager::FileManager; use crate::tvdb::TVDBClient; use crate::types::FileMetadata; pub struct EpisodeRange { pub start: u32, pub end: u32, } pub struct MappingResult { pub success_count: u32, pub error_count: u32, pub renamed_files: Vec, } pub async fn begin_mapping( directory: &str, files: Vec, tvdb_id: u64, client: &TVDBClient, ) -> Result { let file_manager = FileManager::new(directory); // Extract season and show info from directory structure let directory_path = std::path::Path::new(directory); let season_folder = directory_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("Season 1"); let show_folder = directory_path .parent() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .unwrap_or("Unknown Show"); // Extract season number (handles "Season 01", "S01", etc.) let season_num = extract_season_number(season_folder); // Extract clean show name (without [tvdbid-XXXXX] suffix) let show_name = extract_show_name(show_folder); // Get show details to confirm name let show_details = client.get_show_details(tvdb_id).await?; // Get episodes for this season let episodes = client.get_episodes(tvdb_id, season_num).await?; // Map files to episodes let mut result = MappingResult { success_count: 0, error_count: 0, renamed_files: Vec::new(), }; for file in files { if file.is_folder { continue; } // Try to find matching episode let matching_episode = episodes.iter() .find(|ep| { // Simple matching based on filename containing episode number file.name.contains(&format!("E{:02}", ep.number)) || file.name.contains(&format!("-{:02}", ep.number)) }); if let Some(episode) = matching_episode { let ext = std::path::Path::new(&file.name) .extension() .and_then(|e| e.to_str()) .unwrap_or(""); let new_name = format!( "{} S{:02}E{:02}{}{}", show_name, season_num, episode.number, if !ext.is_empty() { "." } else { "" }, ext ); let old_path = file.path.clone(); match file_manager.rename_file(&file.path, &new_name).await { Ok(_) => { result.success_count += 1; result.renamed_files.push(old_path); } Err(_) => { result.error_count += 1; } } } else { // No matching episode found result.error_count += 1; } } Ok(result) } fn extract_season_number(folder_name: &str) -> u32 { // Try patterns like "Season 01", "S01", "Season1", etc. let re = regex::Regex::new(r"(?i)season\s*(\d+)|s(\d+)").unwrap(); if let Some(caps) = re.captures(folder_name) { if let Some(num) = caps.get(1).or(caps.get(2)) { return num.as_str().parse().unwrap_or(1); } } 1 } fn extract_show_name(folder_name: &str) -> String { // Remove [tvdbid-XXXXX] suffix let re = regex::Regex::new(r"\s*\[tvdbid-[^\]]+\]").unwrap(); let name = re.replace_all(folder_name, ""); // Remove leading semicolons or other problematic characters name.trim_start_matches(|c| c == ';' || c == ':') .trim() .to_string() } ``` ### Phase 7: IPC Bridge #### ipc/node_addon.rs ```rust use crate::error::Result; use crate::ffmpeg::{get_duration, get_quality}; use crate::file_manager::{AuditAction, FileManager}; use crate::mapper::{begin_mapping, MappingResult}; use crate::scanner::scan_directory; use crate::tvdb::TVDBClient; use crate::types::FileMetadata; use napi::bindgen_prelude::*; use napi::Result as NapiResult; use once_cell::sync::Lazy; use std::sync::Arc; static TVDB_CLIENT: Lazy> = Lazy::new(|| { let api_key = std::env::var("TVDB_API_KEY") .expect("TVDB_API_KEY environment variable must be set"); Arc::new(TVDBClient::new(api_key)) }); #[napi] pub async fn scan_directory_napi( directory_path: String, progress_callback: Option, ) -> NapiResult { let progress_cb = move |current: u32, total: u32, filename: &str| { if let Some(ref cb) = progress_callback { let _ = cb.call::(None, || { let obj = JsObject::new(); obj.set_named_property("current", current)?; obj.set_named_property("total", total)?; obj.set_named_property("filename", filename.to_owned())?; Ok(obj) }); } }; let result = scan_directory(&directory_path, Some(Box::new(progress_cb))); match result { Ok(files) => { let js_files = JsArray::new(files.len() as u32); for (i, file) in files.into_iter().enumerate() { let js_file = JsObject::new(); js_file.set_named_property("path", file.path)?; js_file.set_named_property("name", file.name)?; js_file.set_named_property("size", file.size)?; js_file.set_named_property("modified", file.modified)?; js_file.set_named_property("duration", file.duration)?; js_file.set_named_property("quality", file.quality)?; js_file.set_named_property("fps", file.fps)?; js_file.set_named_property("isFolder", file.is_folder)?; js_files.set_element(i as u32, js_file)?; } let mut result = JsObject::new(); result.set_named_property("success", true)?; result.set_named_property("files", js_files)?; Ok(result) } Err(e) => { let mut result = JsObject::new(); result.set_named_property("success", false)?; result.set_named_property("error", e.to_string())?; Ok(result) } } } #[napi] pub async fn rename_file_napi( old_path: String, new_name: String, ) -> NapiResult { let file_manager = FileManager::new( std::path::Path::new(&old_path) .parent() .unwrap_or(std::path::Path::new(".")) .to_string_lossy() .to_string() ); match file_manager.rename_file(&old_path, &new_name).await { Ok(_) => { let mut result = JsObject::new(); result.set_named_property("success", true)?; result.set_named_property("message", "File renamed successfully")?; Ok(result) } Err(e) => { let mut result = JsObject::new(); result.set_named_property("success", false)?; result.set_named_property("error", e.to_string())?; Ok(result) } } } #[napi] pub async fn begin_mapping_napi( directory: String, files: JsObject, tvdb_id: u64, ) -> NapiResult { // Convert JsObject to Vec let files_array: Vec = files.iter().collect(); let mut metadata_files = Vec::new(); for file_obj in files_array { let path: String = file_obj.get_named_property("path")?; let name: String = file_obj.get_named_property("name")?; let size: u64 = file_obj.get_named_property("size")?; let modified: String = file_obj.get_named_property("modified")?; let duration: String = file_obj.get_named_property("duration")?; let quality: String = file_obj.get_named_property("quality")?; let fps: String = file_obj.get_named_property("fps")?; let is_folder: bool = file_obj.get_named_property("isFolder")?; metadata_files.push(FileMetadata { path, name, size, modified, duration, quality, fps, is_folder, }); } let result = begin_mapping(&directory, metadata_files, tvdb_id, &TVDB_CLIENT).await?; let mut js_result = JsObject::new(); js_result.set_named_property("success", true)?; js_result.set_named_property("renamed", result.success_count)?; js_result.set_named_property("errors", result.error_count)?; js_result.set_named_property("renamedFiles", { let arr = JsArray::new(result.renamed_files.len() as u32); for (i, file) in result.renamed_files.into_iter().enumerate() { arr.set_element(i as u32, file)?; } arr })?; Ok(js_result) } #[napi] pub async fn test_tvdb_api_napi() -> NapiResult { match TVDB_CLIENT.search("Breaking").await { Ok(results) => { let mut result = JsObject::new(); result.set_named_property("success", true)?; result.set_named_property("message", format!("Found {} shows", results.len()))?; Ok(result) } Err(e) => { let mut result = JsObject::new(); result.set_named_property("success", false)?; result.set_named_property("error", e.to_string())?; Ok(result) } } } ``` ## Testing Strategy ### Unit Tests (Cargo test) ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_is_media_file() { assert!(is_media_file("movie.mp4")); assert!(is_media_file("video.MKV")); assert!(!is_media_file("document.pdf")); } #[test] fn test_extract_season_number() { assert_eq!(extract_season_number("Season 01"), 1); assert_eq!(extract_season_number("S02"), 2); assert_eq!(extract_season_number("Season 3"), 3); assert_eq!(extract_season_number("Unknown"), 1); } } ``` ### Integration Tests ```rust #[cfg(test)] mod integration_tests { use super::*; #[tokio::test] async fn test_scan_directory() { let files = scan_directory("test_data/movies", None).await; assert!(files.is_ok()); assert!(files.unwrap().len() > 0); } #[tokio::test] async fn test_tvdb_search() { let client = TVDBClient::new("test_key".to_string()); let results = client.search("Breaking Bad").await; assert!(results.is_ok()); assert!(!results.unwrap().is_empty()); } } ``` ## Build & Deployment ### Build Commands ```bash # Build Rust library cd rust cargo build --release # Build Node.js addon cd node npm install npm run build # Test cargo test node test-rust-integration.js # Package for distribution npm run package ``` ### CI/CD ```yaml # .github/workflows/rust.yml name: Rust CI on: [push, pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Rust uses: actions-rs/toolchain@v1 with: toolchain: stable override: true - name: Build run: cargo build --release - name: Test run: cargo test - name: Lint run: cargo clippy ``` ## Performance Benchmarks ### Target Metrics - Directory scanning: < 100ms for 100 files - FFmpeg metadata extraction: < 50ms per file - TVDB API calls: < 500ms average - Memory usage: < 100MB for typical workflow ### Benchmark Suite ```rust #[cfg(test)] mod benchmarks { use super::*; use test::Bencher; #[bench] fn bench_scan_directory(b: &mut Bencher) { b.iter(|| scan_directory("test_data/large_dir", None)); } #[bench] fn bench_ffmpeg_probe(b: &mut Bencher) { b.iter(|| probe_file("test_data/video.mp4")); } } ``` ## Migration Checklist ### From Node.js to Rust - [ ] Replace `utils/fileUtils.js` with Rust bindings - [ ] Update `main.js` to use Rust IPC - [ ] Test all file operations - [ ] Verify TVDB API integration - [ ] Performance testing - [ ] Memory leak testing - [ ] Documentation ### Testing Steps 1. Run existing Node.js tests 2. Run Rust unit tests 3. Run integration tests 4. Performance comparison 5. Manual testing of all features ## Success Criteria - [ ] All features work identically to Node.js version - [ ] Performance improvement (2x faster for large directories) - [ ] Memory usage below Node.js baseline - [ ] No crashes or memory leaks (valgrind clean) - [ ] All tests passing - [ ] Documentation complete