- Implement core Rust backend with FFmpeg integration - Add TheTVDB API client with token caching - Implement directory scanner with progress callbacks - Create file manager with rename and move operations - Add audit logging functionality - Implement file mapping for TV episode renaming - Build Node.js native addon via NAPI - Include comprehensive unit and integration tests - Update gitignore to exclude build artifacts and temp files Resolves #TBD
1198 lines
34 KiB
Markdown
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 |