mod ai_matcher;
mod audio_extraction;
mod cache;
mod file_operations;
mod file_resolver;
mod metadata_retrieval;
mod speech_to_text;
mod temp;
pub mod model_downloader;
use ai_matcher::{ClaudeCodeMatcher, EpisodeMatcher, GeminiCliMatcher, NaivePromptGenerator};
use audio_extraction::audio_from_video;
use cache::CacheStorage;
use file_resolver::{VideoFile, compute_video_hash, scan_for_videos};
use metadata_retrieval::{
CachedMetadataProvider, Episode, MetadataProvider, TVSeries, TvMazeProvider,
};
use speech_to_text::{Transcript, audio_to_text};
use std::time::Duration;
fn compute_matching_cache_key(
video_hash: &str,
show_name: &str,
season_filter: &Option<Vec<usize>>,
matcher_type: MatcherType,
) -> String {
let sanitized_show = show_name
.to_lowercase()
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect::<String>();
let seasons_str = match season_filter {
Some(seasons) if !seasons.is_empty() => {
let mut sorted = seasons.clone();
sorted.sort_unstable();
sorted
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>()
.join("-")
}
_ => "all".to_string(),
};
let matcher_str = match matcher_type {
MatcherType::Gemini => "gemini",
MatcherType::GeminiFlash => "gemini-flash",
MatcherType::Claude => "claude",
};
format!(
"{}_{}_{}_{}",
video_hash, sanitized_show, seasons_str, matcher_str
)
}
pub use ai_matcher::EpisodeMatchingError;
pub use audio_extraction::AudioExtractionError;
pub use cache::CacheError;
pub use file_operations::FileOperationError;
pub use file_resolver::FileResolverError;
pub use metadata_retrieval::MetadataRetrievalError;
pub use speech_to_text::SpeechToTextError;
pub use file_operations::{
PlannedOperation, detect_duplicates, execute_copy, execute_rename, format_filename,
plan_operations, sanitize_filename,
};
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatcherType {
Gemini,
GeminiFlash,
Claude,
}
#[derive(Debug, Clone)]
pub enum ProgressEvent {
Started {
directory: PathBuf,
show_name: String,
},
FetchingMetadata { show_name: String },
MetadataFetched {
series_name: String,
season_count: usize,
},
ScanningVideos,
VideosFound { count: usize },
ProcessingVideo {
index: usize,
total: usize,
video_path: PathBuf,
},
Hashing { video_path: PathBuf },
HashingFinished { video_path: PathBuf },
AudioExtraction {
video_path: PathBuf,
temp_path: PathBuf,
},
AudioExtractionFinished {
video_path: PathBuf,
temp_path: PathBuf,
},
Transcription {
video_path: PathBuf,
temp_path: PathBuf,
},
TranscriptionFinished {
video_path: PathBuf,
language: String,
text: String,
},
TranscriptCacheHit {
video_path: PathBuf,
language: String,
},
Matching {
index: usize,
total: usize,
video_path: PathBuf,
},
MatchingFinished {
video_path: PathBuf,
episode: Episode,
},
MatchingCacheHit {
video_path: PathBuf,
episode: Episode,
},
Complete { match_count: usize },
}
#[derive(Debug, Clone, PartialEq)]
pub struct MatchResult {
pub video: VideoFile,
pub episode: Episode,
}
#[derive(Debug, Error)]
pub enum DialogDetectiveError {
#[error("File resolution error: {0}")]
FileResolver(#[from] FileResolverError),
#[error("Audio extraction error: {0}")]
AudioExtraction(#[from] AudioExtractionError),
#[error("Speech-to-text error: {0}")]
SpeechToText(#[from] SpeechToTextError),
#[error("Metadata retrieval error: {0}")]
MetadataRetrieval(#[from] MetadataRetrievalError),
#[error("Cache error: {0}")]
Cache(#[from] CacheError),
#[error("Episode matching error: {0}")]
EpisodeMatching(#[from] EpisodeMatchingError),
#[error("IO error: {0}")]
Io(#[from] io::Error),
}
pub fn investigate_case<F>(
directory: &Path,
model_path: &Path,
show_name: &str,
season_filter: Option<Vec<usize>>,
matcher_type: MatcherType,
mut progress_callback: F,
) -> Result<Vec<MatchResult>, DialogDetectiveError>
where
F: FnMut(ProgressEvent),
{
progress_callback(ProgressEvent::Started {
directory: directory.to_path_buf(),
show_name: show_name.to_string(),
});
progress_callback(ProgressEvent::FetchingMetadata {
show_name: show_name.to_string(),
});
let metadata_cache =
CacheStorage::<TVSeries>::open("metadata", Some(Duration::from_secs(24 * 60 * 60)))?;
let transcript_cache =
CacheStorage::<Transcript>::open("transcripts", Some(Duration::from_secs(24 * 60 * 60)))?;
let matching_cache =
CacheStorage::<Episode>::open("matching", Some(Duration::from_secs(24 * 60 * 60)))?;
transcript_cache.clean()?;
matching_cache.clean()?;
let tvmaze_provider = TvMazeProvider::new();
let provider = CachedMetadataProvider::new(tvmaze_provider, metadata_cache);
let series = provider.fetch_series(show_name, season_filter.clone())?;
progress_callback(ProgressEvent::MetadataFetched {
series_name: series.name.clone(),
season_count: series.seasons.len(),
});
progress_callback(ProgressEvent::ScanningVideos);
let videos = scan_for_videos(directory)?;
if videos.is_empty() {
progress_callback(ProgressEvent::VideosFound { count: 0 });
return Ok(Vec::new());
}
progress_callback(ProgressEvent::VideosFound {
count: videos.len(),
});
let prompt_generator = NaivePromptGenerator::default();
let matcher: Box<dyn EpisodeMatcher> = match matcher_type {
MatcherType::Gemini => Box::new(GeminiCliMatcher::new(prompt_generator, None)),
MatcherType::GeminiFlash => Box::new(GeminiCliMatcher::new(
prompt_generator,
Some("gemini-2.5-flash".to_string()),
)),
MatcherType::Claude => Box::new(ClaudeCodeMatcher::new(prompt_generator)),
};
let mut match_results = Vec::new();
for (index, video) in videos.iter().enumerate() {
progress_callback(ProgressEvent::ProcessingVideo {
index,
total: videos.len(),
video_path: video.path.clone(),
});
progress_callback(ProgressEvent::Hashing {
video_path: video.path.clone(),
});
let video_hash = compute_video_hash(&video.path)?;
progress_callback(ProgressEvent::HashingFinished {
video_path: video.path.clone(),
});
let transcript = if let Some(cached_transcript) = transcript_cache.load(&video_hash)? {
progress_callback(ProgressEvent::TranscriptCacheHit {
video_path: video.path.clone(),
language: cached_transcript.language.clone(),
});
cached_transcript
} else {
progress_callback(ProgressEvent::AudioExtraction {
video_path: video.path.clone(),
temp_path: PathBuf::new(), });
let audio = audio_from_video(video)?;
progress_callback(ProgressEvent::AudioExtractionFinished {
video_path: video.path.clone(),
temp_path: audio.to_path_buf(),
});
progress_callback(ProgressEvent::Transcription {
video_path: video.path.clone(),
temp_path: audio.to_path_buf(),
});
let transcript = audio_to_text(&audio, model_path)?;
transcript_cache.store(&video_hash, &transcript)?;
progress_callback(ProgressEvent::TranscriptionFinished {
video_path: video.path.clone(),
language: transcript.language.clone(),
text: transcript.text.clone(),
});
transcript
};
let matching_cache_key =
compute_matching_cache_key(&video_hash, show_name, &season_filter, matcher_type);
let episode = if let Some(cached_episode) = matching_cache.load(&matching_cache_key)? {
progress_callback(ProgressEvent::MatchingCacheHit {
video_path: video.path.clone(),
episode: cached_episode.clone(),
});
cached_episode
} else {
progress_callback(ProgressEvent::Matching {
index,
total: videos.len(),
video_path: video.path.clone(),
});
let episode = matcher.match_episode(&transcript, &series)?;
matching_cache.store(&matching_cache_key, &episode)?;
progress_callback(ProgressEvent::MatchingFinished {
video_path: video.path.clone(),
episode: episode.clone(),
});
episode
};
let match_result = MatchResult {
video: video.clone(),
episode,
};
match_results.push(match_result);
}
progress_callback(ProgressEvent::Complete {
match_count: match_results.len(),
});
Ok(match_results)
}