MovieMapper/docs/RUST_IMPLEMENTATION.md
Jarian Cottingham 1a35eb346e chore: reorganize repo layout, remove dead files
- Move phase/plan docs into docs/
- Move legacy node:test files into tests/legacy/ with README
- Remove .backup file, test audit artifacts, and unused AI prompt/skill files
- Remove broken iOS GitHub workflows (reference missing MovieMapper-iOS/)
2026-08-20 20:11:57 +00:00

1198 lines
34 KiB
Markdown

# 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": [
"<!@(node -p \"require('node-addon-api').include\")"
],
"dependencies": [
"<!(node -p \"require('node-addon-api').gyp\")"
],
"cflags!": ["-fno-exceptions"],
"cflags_cc!": ["-fno-exceptions"],
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
"libraries": [],
"conditions": {
"OS==\"mac\"": {
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
"CLANG_CXX_LIBRARY": "libc++"
}
}
}
}]
}
```
## Implementation Details
### Phase 1: Core Foundation
#### error.rs
```rust
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ScanError {
#[error("Directory not found: {0}")]
NotFound(String),
#[error("Permission denied: {0}")]
Permission(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("FFmpeg error: {0}")]
Ffmpeg(#[from] ffmpeg_kit::Error),
}
#[derive(Error, Debug)]
pub enum ProbeError {
#[error("Could not probe file: {0}")]
CannotProbe(String),
#[error("No video stream found")]
NoVideoStream,
}
#[derive(Error, Debug)]
pub enum TVDBError {
#[error("Authentication failed")]
AuthFailed,
#[error("API request failed: {0}")]
RequestFailed(String),
#[error("Rate limit exceeded")]
RateLimited,
}
#[derive(Error, Debug)]
pub enum FileError {
#[error("File not found: {0}")]
NotFound(String),
#[error("File already exists: {0}")]
AlreadyExists(String),
#[error("Cannot create directory: {0}")]
CannotCreateDir(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
```
#### types.rs
```rust
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileMetadata {
pub path: String,
pub name: String,
pub size: u64,
pub modified: String, // ISO 8601 string
pub duration: String, // "mm:ss" format
pub quality: String,
pub fps: String,
pub is_folder: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VideoStreamInfo {
pub width: u32,
pub height: u32,
pub codec: String,
pub frame_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaInfo {
pub duration: f64,
pub format: String,
pub video_streams: Vec<VideoStreamInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Show {
pub id: u64,
pub name: String,
pub status: Option<String>,
pub first_aired: Option<String>,
pub overview: Option<String>,
pub image: Option<String>,
pub slug: Option<String>,
}
#[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<String>,
pub overview: Option<String>,
}
```
### 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<MediaInfo> {
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<String> {
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<Box<dyn Fn(u32, u32, &str) + Send>>,
) -> Result<Vec<FileMetadata>> {
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::<chrono::Utc>::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<parking_lot::Mutex<Option<(String, chrono::DateTime<chrono::Utc>)>>> =
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<String> {
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<String, serde_json::Value> = 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<Vec<Show>> {
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<Show> = 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<Show> {
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<Vec<Season>> {
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<Season> = 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<Vec<Episode>> {
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<Episode> = 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<String, String>,
}
#[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<String> {
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<String>,
}
pub async fn begin_mapping(
directory: &str,
files: Vec<FileMetadata>,
tvdb_id: u64,
client: &TVDBClient,
) -> Result<MappingResult> {
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<Arc<TVDBClient>> = 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<Callback>,
) -> NapiResult<JsObject> {
let progress_cb = move |current: u32, total: u32, filename: &str| {
if let Some(ref cb) = progress_callback {
let _ = cb.call::<JsObject, JsObject>(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<JsObject> {
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<JsObject> {
// Convert JsObject to Vec<FileMetadata>
let files_array: Vec<JsObject> = 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<JsObject> {
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