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

34 KiB

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/)

[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/)

{
  "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

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

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

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

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

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

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

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

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)

#[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

#[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

# 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

# .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

#[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